大事务 DELETE 锁表排查:删 3344 万行,一个清理任务变成周期炸弹
一次真实故障的完整复盘。每周六上午十点准时发作,凶手是个"人畜无害"的清理定时任务。核心是两件反常识的事:有索引不代表会用索引,删旧数据不代表碰不到新数据。文末附大批量 DML 上线三件套。
故障现象
这个 bug 有个让人头皮发麻的特征:它每周六上午十点,准时发作。
现象是充值发奖挂了。用户充了钱,该到账的活动奖励发不下去,报错齐刷刷指向一句 Lock wait timeout exceeded。持续十到三十分钟,然后自己好了。下周六,同一时间,再来一次。
排查到最后,凶手是一个"人畜无害"的后台定时任务:每周清一次过期日志。一个连业务逻辑都不碰的清理 Job,怎么就周期性地把核心链路锁死了?
一、案发现场:两个八竿子打不着的操作,撞了
先把冲突双方摆出来。
阻塞方是一个定时任务,每周六十点跑,删掉一张审计日志表里 14 天前的旧数据。一条 SQL:
sql
DELETE FROM audit_log WHERE create_time < '14天前';
被阻塞方 是充值消息的消费者。每次判断用户充值够不够资格拿奖,都会往同一张 audit_log 里 INSERT 一行留痕。
看一眼就该觉得奇怪:DELETE 删的是 create_time < 14天前 的老数据,INSERT 写的是 create_time = now() 的新数据。这两拨数据在时间线上隔着十四天,八竿子打不着。表上还建了 idx_create_time 索引。按直觉,一个在删表头的远古数据,一个在表尾追加新数据,井水不犯河水,凭什么互相锁?
我一开始也是这么想的。这个"凭什么"的直觉,恰恰是全部误区的起点。
二、第一个数字:要删的不是一批,是 3344 万行
先查规模。
sql
SELECT COUNT(1) FROM audit_log WHERE create_time < '14天前';
-- 33,445,381
而这张表的 AUTO_INCREMENT 才一千两百多万。等一下,要删的行数比表当前的自增 ID 还大?这说明积压早就失控了:这个"每周清一次"的任务,要么长期没成功跑,要么根本追不上写入速度,过期数据越攒越多。单次要删的量,滚到了 3344 万。
记住这个"删除占比"。全表待删比例高达约 90%,这是引爆下面一切的引信。
再确认一个环境前提:
sql
SELECT @@global.transaction_isolation;
-- REPEATABLE-READ
这是 MySQL 的默认隔离级别,也是第二根引信。
三、第二个反常识:建了索引,优化器偏不走
我最初的假设是:DELETE 走 idx_create_time,只锁 14 天前那段索引范围,跟表尾的 INSERT 不重叠。前提是它真的走这个索引。
EXPLAIN 一打,傻眼了:type=ALL。全表扫描。它没走我建的索引。
为什么?这是很多人第一次撞上都不信的一条铁律:有索引不等于会用索引。 MySQL 优化器是按成本模型选执行计划的。当删除占比高达 90%,优化器一算账:
- 走二级索引
idx_create_time,要回表 3000 多万次,每次是一趟随机 I/O - 走全表扫描,则是顺着聚簇索引顺序扫一遍
顺序 I/O 比随机 I/O 便宜太多。当命中比例高到一定程度(经验阈值大约 20% 到 30%),优化器会判定"全表顺序扫反而更快",主动放弃你精心建的二级索引。索引建了,它就是不用。
这一下,DELETE 从"只碰表头旧数据"变成了"从头到尾扫整张表"。头一件不讲道理的事到这就有答案了:它俩之所以会撞,是因为 DELETE 根本不在表头老实待着,它扫过了整张表,包括 INSERT 要落脚的表尾。
四、RR 加全表扫描,等于锁住整张表
光"扫过"还不至于锁死。真正致命的是 RR 隔离级别下的锁行为。
在 RR 下,全表扫描会给扫过的每一行 加 next-key lock。注意是每一行,包括那些 create_time 根本不匹配、压根不该被删的行。而且这些锁直到事务提交才释放。3344 万行的单个大事务,提交前锁就一直攥着。
更要命的是扫到表尾时,还会加一段 gap lock (间隙锁),锁住"最后一行到正无穷"这段空隙。而新来的 INSERT 要在表尾插入,需要先拿一个插入意向锁,它和 DELETE 攥着的那段 gap lock 直接冲突。
于是链路彻底卡死:
scss
DELETE(大事务) ──持有──▶ 表尾 gap lock,提交前不放
│ 冲突
INSERT(充值消费者) ──申请──▶ 插入意向锁 ──▶ 进入锁等待
│
等超过 innodb_lock_wait_timeout(默认 50s)
│
抛 Lock wait timeout exceeded ✗
这里补一个关键对照:如果隔离级别是 RC(READ-COMMITTED),同样这条 DELETE 不会阻塞无关 INSERT。 因为 RC 下,WHERE 不匹配的行扫过即放锁,也没有 gap lock,锁范围只落在真正匹配的行上。是 RR 把锁范围放大到了"整张表加尾部间隙",才牵连了八竿子打不着的 INSERT。
五、为什么一锁就是半小时
还有个问题没答:就算锁冲突,为什么持锁能长达十到三十分钟?因为 3344 万行的单事务,同时把 InnoDB 的好几个子系统一起压垮了。挑三个讲透。
锁系统膨胀。 千万级行锁塞进 lock_sys 的哈希表,表本身膨胀加上 mutex 竞争加剧,连其它无关行的加锁都跟着变慢。
Undo Log 膨胀到 GB 级。 每删一行,旧版本都要写进 undo,好让别的事务做 MVCC 一致性读时能回溯。3344 万行的旧版本堆起来是 GB 级的 undo 链,拖慢的是整个库所有事务的一致性读,不只是这张表。
页合并风暴。 大量行被标记删除后,数据页利用率跌破 50%,触发 Page Merge。合并要竞争索引的结构锁,又反过来阻塞并发 INSERT 需要的页分裂。DELETE 越删越慢,形成恶性循环。
说到底,大事务的危害从不只在目标表。它是"一条坏 SQL 拖垮一个库"的经典源头,值得当成红线来管。
六、正解:别跟优化器斗,用主键把它锁死在 range
修复的核心思路只有一句:把一个删 3344 万行的大事务,拆成无数个删 5000 行的小事务,并且用主键驱动,让优化器没机会走全表。
ini
// 第一步:时间条件只用一次,把"14 天前"翻译成一个主键上界 maxId
long maxId = repo.findMaxIdBeforeCreateTime(expireDate); // SELECT MAX(id) WHERE create_time < ?
if (maxId <= 0) return;
// 第二步:之后纯按主键分批删,每批独立事务,批间 sleep 给数据库喘息
int deleted;
do {
deleted = repo.deleteByMaxId(maxId, 5000); // DELETE WHERE id <= maxId LIMIT 5000
if (deleted > 0) Thread.sleep(intervalMs);
} while (deleted > 0);
为什么是 id <= maxId 而不是继续用 create_time < ? LIMIT 5000? 这一点值得单独记牢。
- 按
create_time删,哪怕加了 LIMIT,仍然是走二级索引再回表做随机 I/O,锁的是一堆散乱不连续的聚簇行,还是可能触发优化器的全表判断 - 按主键
id <= maxId删,主键就是聚簇索引本身,顺序扫、零回表,优化器 100% 走type=range,删除占比再高也不会退化成全表扫描
而且它对业务 INSERT 零影响:DELETE 只啃 id <= maxId(表头的旧数据),新 INSERT 落在 id = AUTO_INCREMENT(表尾),两个区间彻底不重叠,锁自然不撞。存量 3344 万行按每批 5000、单线程,大约十一分钟就清完了,压根不需要多线程。
配套还有两件事,比改 SQL 更治本。
其一,把 cron 从"每周一次"改成"每天凌晨一次" 。每天只删一天的增量(几十万行、几秒钟),频率匹配增量,积压这个引信从根上拆掉。清理任务的黄金法则:每天产多少,就每天删多少,别攒着一次删。
其二,给那句留痕 INSERT 包一层降级容错 。审计日志写失败,catch 住告警、跳过就行。一张只用于留痕、不参与发奖判定的辅助表,凭什么有资格中断核心的充值发奖?
七、大批量 DML 上线三件套
把这次的教训收敛成一张能贴在工位上的清单。
第 1 条,上线前 EXPLAIN,确认走的是你以为的索引。 别假设"我建了索引就会走它"。删除或更新占比过高时,优化器会当着你的面把它扔掉。看 type 那一列,出现 ALL 就是全表扫,直接打回。
第 2 条,超过一万行必分批,每批独立事务,批间 sleep。 没有任何参数调优能替代"把大事务拆小"这一个动作。分批循环千万别整个包进一个 @Transactional,那样等于白拆。
第 3 条,优先用主键 id <= maxId LIMIT 驱动,别用二级索引条件。 顺序扫、零回表、锁范围连续,还顺手绕开了优化器陷阱。
还有一条 Code Review 红线送给带团队的人:看到 @Query 里的 DELETE 或 UPDATE,先问三件事。有没有 LIMIT?影响行数可不可控?调用方有没有把分批循环包进一个大 @Transactional?
小结
有索引不代表会用索引,删旧数据不代表碰不到新数据。线上事故的凶手,往往就藏在这些"我以为理所当然"的缝里。
这次故障能给出的三条通用结论:
- 删除占比超过 20% 到 30%,优化器可能主动放弃二级索引走全表扫。上线前
EXPLAIN看type,别靠假设。 - RR 隔离级别下,全表扫描会锁住扫过的每一行加上表尾间隙,牵连时间线上八竿子打不着的 INSERT。同样的 SQL 在 RC 下不会。
- 清理任务的频率要匹配数据增量。攒着一次删,迟早从"清理"变成"事故"。
你的服务里,那个"删过期数据"的定时任务,是怎么写的?欢迎在评论区把它翻出来对一眼。
平时我会把这类线上排查过程整理成案例记下来,慢慢攒成了一个"经验怪谈"系列,公众号 啫煲捞饭 同步更新,感兴趣可以来看看。