根本原因是delete加锁范围过大:无索引时全表扫描锁大量行与间隙,或between等范围条件引发间隙锁重叠;正确解法是建(status, id)联合索引+last_id分页删除+deletion_progress状态持久化。

为什么批量删除会触发死锁
根本原因不是“删得多”,而是DELETE在加锁时覆盖了不该锁的范围。比如用WHERE status = 'archived'但status字段没索引,InnoDB只能全表扫描,每批都锁住成千上万行+间隙,多个并发删除事务极易形成循环等待。
更隐蔽的是WHERE id BETWEEN x AND y——当id不连续或有并发写入时,间隙锁会跨出实际数据范围,A事务锁[10000,20000],B事务锁[19999,29999],就卡死了。
- 死锁日志里常出现
LATEST DETECTED DEADLOCK段,里面明确写着两个事务各自持有什么锁、等待什么锁 -
SHOW ENGINE INNODB STATUS输出中innodb_row_lock_waits值飙升,说明锁争抢已成常态 - 执行
EXPLAIN DELETE FROM t WHERE status = 'archived' LIMIT 1000,如果type是ALL,基本可以断定索引缺失是根因
必须加的索引类型和写法
光建INDEX(status)不够,因为分批删除需要排序+定位边界,单列索引无法支撑ORDER BY id LIMIT N高效执行,优化器仍可能选错索引或触发filesort,导致锁范围失控。
正确做法是建联合索引,把过滤字段放前,排序/分页字段放后:
CREATE INDEX idx_status_id ON t (status, id);
这样DELETE FROM t WHERE status = 'archived' ORDER BY id LIMIT 500才能稳定走索引,每次只锁住命中的500行及右侧一个间隙。
- 别用
INDEX(id, status)——顺序反了,status过滤失效 - 如果删除条件含时间范围(如
create_time ),索引应为<code>INDEX(create_time, id) - 已有单列索引的,先
DROP INDEX再建联合索引,避免冗余索引拖慢写入
分批删除的SQL写法和休眠控制
只写LIMIT不加休眠,等于把压力从“一次打满”变成“高频连击”,CPU和redo log写入照样扛不住,其他业务SQL依然排队等锁释放。
真正有效的分批节奏是:删一批 → 提交事务 → 应用层休眠 → 再删下一批。存储过程里的DO SLEEP(0.1)不能用,它算在事务时间内,反而延长锁持有。
- 推荐每批删
500行,应用层调用后sleep(0.3)~sleep(0.5)(SSD环境)或sleep(1)(机械盘) - 删除语句必须带
ORDER BY id,强制走主键索引,避免优化器误判 - 用
WHERE id > last_id推进,而不是BETWEEN或OFFSET;last_id必须取自上一批实际删除的最大id值,不能查MAX(id)
比“怎么删”更关键的是“删到哪了”
last_id一旦丢失或错误,轻则漏删、重则重复删(如果业务逻辑不允许重复操作),而这个问题在线上几乎无法回溯修复。
最稳妥的做法是在应用层维护一个可靠的状态记录,比如写入一张deletion_progress表,每次成功删完一批就UPSERT一条记录,包含表名、条件、当前last_id、时间戳。下次启动直接读它,而不是靠内存变量或日志推断。
另外,innodb_rollback_on_timeout建议设为ON(即值=1),否则事务超时只回滚最后一条语句,前面已删的数据还在,状态彻底混乱。











