一次线上 分库分表 故障的复盘记录
背景
我们订单库是 4 库 × 4 表,按 user_id 取模分片,中间件用的 ShardingSphere-JDBC。平时查询、更新都带 user_id,上线快一年没出过大的幺蛾子。结果上周四下午,一个历史数据修复任务把整个订单库差点拖死。
故障现象
14:07 开始告警,订单查询接口大量 500,P99 直接拉满。应用日志里一片:
HikariPool-1 - Connection is not available, request timed out after 30000ms.
第一反应是数据库崩了?登上监控看,MySQL CPU 没满,磁盘也没炸,但是 16 个分片的连接数都顶到了上限,活跃连接里全是同一条 SQL。
慢日志里也是它:
UPDATE t_order
SET status = 3, update_time = NOW()
WHERE status = 1
AND update_time < DATE_SUB(NOW(), INTERVAL 30 DAY);
这条 SQL 没带 user_id。在分库分表里,没带分片键的 UPDATE 会被广播到所有分片。ShardingSphere 日志里 Actual SQL 打了 16 条,ds_0 到 ds_3 的每个 t_order_0 到 t_order_3 都跑了一遍。说实话,平时看日志知道有广播路由,但真到线上看到 16 条 Actual SQL 一起锁表,感觉还是不一样。
排查过程
先别急着找根因,先止血。我们第一时间去 xxl-job 后台把 orderRepairJob 停了,不然任务重试还会继续打。这里有个小坑:一开始只停了一台实例上的本地定时任务,后来发现调度中心还有重试,等于没停干净...
停任务后开始看连接。在每台 MySQL 上执行:
SHOW FULL PROCESSLIST;
SELECT id, db, command, time, state, LEFT(info, 120) AS sql_text
FROM information_schema.processlist
WHERE info LIKE 'UPDATE t_order%';
能看到不少会话卡在 updating,还有一些在等锁。当时不敢一把梭全 kill,先 kill 掉非核心业务里等得最久的会话,再观察连接数。这里说真的,kill 完不是立刻就好,InnoDB 回滚也要时间,有一两个分片回滚了差不多几分钟。
应用侧也做了降级,把订单非核心接口先限流,避免新请求继续把连接池占满。等连接数下来后,再把应用实例分批重启,连接池才慢慢恢复。
根因
根因不复杂,甚至有点蠢:
- 修复任务确实写了时间范围,但条件还是太宽,30 天前的
status = 1订单量比预想大很多。 - SQL 没带
user_id,ShardingSphere 直接广播到 16 个分片,每个分片都在更新和锁行。 - 任务本身没有分批,也没有分布式锁。调度重试加上重复触发,等于把同一条 SQL 又补了几刀。
- 订单接口和这个任务共用一个连接池,任务把连接占满,正常请求只能干等,最后
HikariPool超时。
修复方式
止血完成后,我们把任务改成了按 user_id 分批处理。先查出一批需要修复的用户,再按用户维度循环更新,每批 200 个用户,批次之间 sleep 一下。伪代码大概这样:
List<Long> userIds = orderRepairMapper.selectRepairUserIds(lastId, 200);
for (Long userId : userIds) {
orderRepairMapper.updateByUserId(userId);
lastId = userId;
}
对应 SQL 必须带分片键:
UPDATE t_order
SET status = 3, update_time = NOW()
WHERE user_id = #{userId}
AND status = 1
AND update_time < DATE_SUB(NOW(), INTERVAL 30 DAY);
另外给这个任务单独配了一个小连接池,加了 Redis 分布式锁,调度中心只允许单实例执行。重试次数也从 3 次改成了 0 次,失败就告警,人工确认后再跑。
后续改进
这次之后我们加了几个比较土但有用的限制:
- SQL 审核里直接拦
UPDATE/DELETE不带分片键的语句,至少强制人工确认。 - 修复类任务必须分批,必须能限流,必须有分布式锁,禁止和在线接口共用连接池。
- 开了 ShardingSphere 的 SQL 日志监控,对
Actual SQL数量做告警,单条 UPDATE 打超过 1 个分片就先报警再说。 - 把这次的处理步骤写进 runbook:先停任务,再看
processlist,再分批 kill,再限流,最后重启。
复盘写到这里,最想提醒自己的是:分库分表不是配完中间件就完事了,最危险的往往不是正常业务 SQL,而是那些临时修复、跑批、补数据。它们看起来只是“跑一下”,但没带分片键的时候,跑的可是全部分片。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。