90%的Insert与Delete并发死锁源于RR级别下Gap锁和插入意向锁的互斥冲突;DELETE未命中记录仍加Gap锁,INSERT尝试插入同一间隙时因锁不兼容而等待,交叉执行易形成循环等待。

Insert 与 Delete 并发死锁,90% 是 Gap 锁和插入意向锁在 RR 隔离级别下“误伤”彼此,不是代码写错了,而是锁范围重叠了。
为什么 DELETE 不存在的记录会引发死锁?
在 REPEATABLE READ 隔离级别下,DELETE 语句即使没匹配到任何行,也会加 Gap 锁——它锁的是“本该有数据但实际为空”的索引间隙。比如表里有 id = 10 和 id = 20,执行 DELETE FROM t WHERE id = 15,InnoDB 就会锁住 (10, 20) 这个间隙。
此时如果另一个事务想往同一间隙插数据(如 INSERT INTO t VALUES (15)),就会触发插入意向锁,而这个意向锁与已有的 Gap 锁不兼容,直接等待。若两个事务交叉执行(A 先删再插,B 先插再删),就极易形成循环等待链。
- Gap 锁之间是兼容的,但 Gap 锁与插入意向锁互斥
-
DELETE加 Gap 锁的前提:WHERE 条件走索引、未命中记录、隔离级别 ≥ RR - MySQL 8.0+ 可通过
performance_schema.data_locks查看具体锁类型和LOCK_DATA范围
如何快速定位是哪个 DELETE 在挡 INSERT 的路?
别只盯着 SHOW ENGINE INNODB STATUS 里那句 INSERT intention lock 等待信息——它从不告诉你谁在 hold 锁。真正要查的是持有 Gap 锁的事务:
- 先查活跃事务:
SELECT * FROM information_schema.INNODB_TRX WHERE TRX_STATE = 'RUNNING' - 再查锁详情(MySQL 8.0+):
SELECT * FROM performance_schema.data_locks WHERE LOCK_TRX_ID = 'xxx',把上一步的TRX_ID填进去 - 重点看字段:
LOCK_MODE = 'GAP'或'NEXT-KEY',且LOCK_DATA显示的范围覆盖你要插入的值(例如你要插id=105,它锁了(100, 110)) - 对应事务的
TRX_QUERY很可能是一条没提交的DELETE ... FOR UPDATE或已执行但未 COMMIT 的普通DELETE
INSERT ... ON DUPLICATE KEY UPDATE 为什么也容易卷入死锁?
这个语句表面是“原子操作”,实际分两步:先按唯一键做 duplicate key 检查(隐式加 S 锁或 X 锁),再决定 INSERT 还是 UPDATE。当多个事务并发执行时,极易出现锁顺序不一致:
- 事务 A 插入
u_contract_number='A',先获取插入意向锁,再升级为 X 锁 - 事务 B 同时插入
u_contract_number='B',但它的索引扫描路径或锁申请顺序与 A 不同 - 若底层索引结构导致两者分别持有了对方需要的 Gap 锁(比如都锁了
(100, 200),但各自又试图插入不同位置),就满足了死锁四条件中的“循环等待” - 尤其注意:唯一键冲突本身不会直接死锁,但冲突后回滚不干净、或与其他 Gap 锁叠加时,会放大风险
绕过 Gap 锁冲突的实操建议
不能一概禁用 Gap 锁(否则幻读重现),但可针对性收敛影响面:
- 把
DELETE改成带明确存在性判断的逻辑:先SELECT ... FOR UPDATE确认存在,再删;或改用应用层幂等控制,避免无谓的 Gap 锁 - 对高并发插入场景,尽量让插入值落在已有索引记录“之外”,比如用自增主键 + 时间戳前缀,避开中间间隙
- 必要时临时降级隔离级别:
READ COMMITTED下 Gap 锁仅用于外键检查和唯一约束,不用于防止幻读,能大幅减少 Gap 锁范围 - 监控长期未提交事务:
SELECT * FROM information_schema.INNODB_TRX WHERE TIME_TO_SEC(NOW() - TRX_STARTED) > 60,它们往往是 Gap 锁残留的源头
Gap 锁本身不可见、不报错、不阻塞 SELECT,但它像一张隐形网——你只有在 INSERT/UPDATE/DELETE 互相卡住时,才意识到它一直在那儿。真正难处理的不是锁本身,而是它和插入意向锁配合起来制造的“静默冲突”。


















