delete比insert更易引发死锁,根本原因是delete在rr隔离级别下无差别加gap锁(即使记录不存在),而insert仅按需申请插入意向锁;两者锁类型互斥且delete锁范围更广、更难预测。

Delete 操作比 Insert 更容易引发死锁,根本原因不是“删比插重”,而是 Delete 在 RR 隔离级别下会无差别加 Gap 锁(哪怕记录不存在),而 Insert 只在必要时申请插入意向锁;两者锁类型天然不兼容,且 Delete 的锁范围更难预测、更易覆盖 Insert 所需间隙。
DELETE 即使没命中记录也会加 Gap 锁
在 REPEATABLE READ 隔离级别下,DELETE FROM t WHERE id = 15 这条语句,只要 id 走索引,哪怕表里根本没有 id = 15 的行,InnoDB 仍会锁定索引中该值“应处”的间隙 —— 比如前后记录是 id = 10 和 id = 20,它就锁住 (10, 20)。
这种行为常被误认为“删空了就没事”,实际恰恰相反:没删到的 DELETE,反而成了最隐蔽的锁持有者。
- Gap 锁之间是兼容的,但 Gap 锁与插入意向锁互斥
- INSERT 尝试往
(10, 20)插id = 15,必须申请插入意向锁,立刻被阻塞 - 如果另一个事务也执行了类似未命中的
DELETE,再反向插,就极易形成 A 等 B、B 等 A 的循环等待
INSERT 的锁是“按需申请+快速释放”,DELETE 的锁是“先占后判”
INSERT 加锁分两步:先申请插入意向锁(轻量、范围窄),再根据是否触发唯一键冲突决定是否升级为 X 锁或 S 锁。整个过程锁粒度可控、路径明确。
而 DELETE 是“扫描即加锁”:只要 WHERE 条件走索引,InnoDB 就会在遍历过程中对每个扫描到的间隙(包括未命中区)预加 Gap 锁或 Next-Key 锁,哪怕最终一行都没删。
- 没有索引的
DELETE会升级为全表扫描 + 行锁,锁范围爆炸式扩大 - 有联合索引但只用部分列(如
WHERE a = ?,索引是(a, b)),仍可能锁整个a值对应的所有间隙 -
DELETE ... LIMIT 1不减少 Gap 锁范围 —— 它仍要先扫描、加锁,再裁剪
如何快速确认是不是 DELETE 在挡路
别只盯着 SHOW ENGINE INNODB STATUS 里那句 “waiting for insert intention lock” —— 它从不告诉你谁 hold 了 Gap 锁。真正要查的是正在运行却没提交的 DELETE 事务:
- 查活跃事务:
SELECT * FROM information_schema.INNODB_TRX WHERE TRX_STATE = 'RUNNING' - 挑出疑似事务 ID,查它的锁:
SELECT * FROM performance_schema.data_locks WHERE LOCK_TRX_ID = 'xxx'(MySQL 8.0+) - 重点过滤:
LOCK_MODE是'GAP'或'NEXT-KEY',且LOCK_DATA显示的范围包含你要 INSERT 的值(比如你要插id=105,它锁了(100, 110)) - 对应事务的
TRX_QUERY很可能是一条已执行但未COMMIT的DELETE
Insert ON DUPLICATE KEY UPDATE 为什么也危险
这个语句表面原子,实际执行分两阶段:先按唯一键做存在性检查(隐式加 S/X 锁),再决定 INSERT 还是 UPDATE。并发时极易因索引扫描顺序或锁申请时机不同,导致两个事务分别持有了对方需要的 Gap 锁。
例如:
- 事务 A 执行
INSERT ... ON DUPLICATE KEY UPDATE插入u_contract_number='A',先锁住(100, 200)间隙 - 事务 B 同时插入
u_contract_number='B',也锁住同一间隙,但因内部排序或缓冲区状态差异,尝试插入位置不同 - 结果 A 等 B 释放某段间隙,B 等 A 释放另一段 —— 死锁成立
Gap 锁的“不可见性”和“范围模糊性”,才是这类死锁最难排查的地方:它不报错、不显式阻塞,只在并发 INSERT 时突然爆发,且源头事务可能早已执行完 DELETE,只差一个 COMMIT。











