大事务 DELETE 锁表排查:删 3344 万行,一个清理任务变成周期炸弹

大事务 DELETE 锁表排查:删 3344 万行,一个清理任务变成周期炸弹

一次真实故障的完整复盘。每周六上午十点准时发作,凶手是个"人畜无害"的清理定时任务。核心是两件反常识的事:有索引不代表会用索引,删旧数据不代表碰不到新数据。文末附大批量 DML 上线三件套。

故障现象

这个 bug 有个让人头皮发麻的特征:它每周六上午十点,准时发作。

现象是充值发奖挂了。用户充了钱,该到账的活动奖励发不下去,报错齐刷刷指向一句 Lock wait timeout exceeded。持续十到三十分钟,然后自己好了。下周六,同一时间,再来一次。

排查到最后,凶手是一个"人畜无害"的后台定时任务:每周清一次过期日志。一个连业务逻辑都不碰的清理 Job,怎么就周期性地把核心链路锁死了?

一、案发现场:两个八竿子打不着的操作,撞了

先把冲突双方摆出来。

阻塞方是一个定时任务,每周六十点跑,删掉一张审计日志表里 14 天前的旧数据。一条 SQL:

sql 复制代码
DELETE FROM audit_log WHERE create_time < '14天前';

被阻塞方 是充值消息的消费者。每次判断用户充值够不够资格拿奖,都会往同一张 audit_logINSERT 一行留痕。

看一眼就该觉得奇怪: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%,优化器可能主动放弃二级索引走全表扫。上线前 EXPLAINtype,别靠假设。
  • RR 隔离级别下,全表扫描会锁住扫过的每一行加上表尾间隙,牵连时间线上八竿子打不着的 INSERT。同样的 SQL 在 RC 下不会。
  • 清理任务的频率要匹配数据增量。攒着一次删,迟早从"清理"变成"事故"。

你的服务里,那个"删过期数据"的定时任务,是怎么写的?欢迎在评论区把它翻出来对一眼。


平时我会把这类线上排查过程整理成案例记下来,慢慢攒成了一个"经验怪谈"系列,公众号 啫煲捞饭 同步更新,感兴趣可以来看看。

相关推荐
LinuxGeek10243 小时前
Kylin-Server-V11、openEuler-22.03和openEuler-24.03适配原生rpm的MySQL 8.4.11版本正式发布
大数据·mysql·kylin
Lucifer三思而后行5 小时前
TDSQL MySQL 版 DBA 实用 100 条命令
mysql·tdsql
染指11109 小时前
72.高级RAG-sql检索器
python·sql·mysql·llama·llamaindex·高级rag
wWYy.9 小时前
Mysql:覆盖索引
android·数据库·mysql
无忧.芙桃10 小时前
MySQL数据库原理与实践(五):内置函数
数据库·mysql
wWYy.12 小时前
Mysql:一行数据是怎么存储的?
android·数据库·mysql
☆凡尘清心☆1 天前
CentOS Stream 9 专用 LNMP 全自动一键脚本
linux·运维·mysql·nginx·lnmp·centos stream 9
xqqxqxxq1 天前
DML 表数据:插入、删除、修改
笔记·mysql
xxwl5851 天前
数据库后端接口测试报告
spring boot·mysql·tomcat