能复现死锁,需满足三个条件:可重复读隔离级别、非唯一索引(如name)、两个事务对不存在的值执行SELECT ... FOR UPDATE;此时InnoDB加Next-Key Lock(即Gap Lock + Record Lock),后续INSERT因insert intention lock与Gap Lock互斥而触发死锁。

能复现,5秒内就能在本地 MySQL 8.0+ 触发死锁。关键不是“能不能”,而是必须满足三个条件:可重复读(RR)隔离级别、非唯一索引(或无索引)、两个事务对**不存在的值**执行 SELECT ... FOR UPDATE 或 UPDATE。
为什么用 SELECT ... FOR UPDATE 而不是普通 UPDATE?
因为间隙锁在 RR 下的触发逻辑有差异:
-
UPDATE对不存在的记录:直接加 Gap Lock(比如UPDATE t SET c=1 WHERE id=3,id=3 不存在 → 锁 (1,5)) -
SELECT ... FOR UPDATE对不存在的记录:同样加 Gap Lock,但更易暴露死锁链——它不改数据,只加锁,后续插入意图更清晰 - MyBatis-Plus 的
saveOrUpdate(entity, wrapper)内部先UPDATE再INSERT,正是这种“查无再插”的模式,天然适配死锁路径
最小复现 SQL(含建表与事务步骤)
以下语句在两个 MySQL 客户端窗口中顺序执行即可复现(注意别用同一个连接):
CREATE TABLE t_gap ( id INT PRIMARY KEY, name VARCHAR(10), INDEX idx_name (name) ) ENGINE=InnoDB; <p>INSERT INTO t_gap VALUES (1, 'a'), (5, 'e'), (10, 'j');
此时 name 索引存在间隙:('a', 'e') 和 ('e', 'j')。
窗口 A:
BEGIN; SELECT * FROM t_gap WHERE name = 'c' FOR UPDATE;
窗口 B(立即执行,不等 A 提交):
BEGIN; SELECT * FROM t_gap WHERE name = 'g' FOR UPDATE;
然后 A 执行:
INSERT INTO t_gap VALUES (2, 'c');
几乎同时,B 执行:
INSERT INTO t_gap VALUES (3, 'g');
→ 其中一个会报错:ERROR 1213 (40001): Deadlock found when trying to get lock。
为什么 INSERT 会卡住?关键在插入意向锁
Gap Lock 本身不互斥,多个事务可以同时持有同一间隙的 X Gap Lock;但 INSERT 前必须申请 insert intention lock,而它和 Gap Lock 是互斥的:
- A 持有
('a','e')的 Gap Lock,B 的INSERT 'g'需要insert intention lockon('e','j')→ 不冲突 - 但 B 的
INSERT 'g'实际落在('e','j'),而 A 的 Gap Lock 已覆盖该区间?不,A 锁的是('a','e')—— 等等,这里错了? - 真正死锁点在于:A 在
name='c'查询时,因name是非唯一索引,InnoDB 按 Next-Key Lock 加锁,实际锁定的是('a','e'];B 同理锁定('e','j'];但当两者都尝试插入时,insert intention lock会尝试在各自间隙内加锁,而 InnoDB 内部对间隙边界的判定可能重叠(尤其在二级索引 B+ 树分裂点附近),最终形成 A 等 B 的间隙锁释放、B 等 A 的间隙锁释放 - 最稳复现方式:让两个查询命中**同一个间隙**,例如都查
name = 'c',但用不同事务 —— 这时 Gap Lock 相同,insert intention lock必然冲突
容易被忽略的细节:隔离级别和索引类型
复现失败?大概率栽在这两点:
- 没确认当前会话隔离级别:
SELECT @@transaction_isolation;必须是REPEATABLE-READ;设成READ-COMMITTED就不会加 Gap Lock,死锁消失 - 误用了主键或唯一索引:
WHERE id = 3(id 是主键)→ 只加 Record Lock,不锁间隙 → 无死锁;必须用二级非唯一索引(如name)或无索引字段(会退化为表锁,但现象不同) - 客户端自动提交开着:
SET autocommit = 0;必须手动BEGIN,否则每个语句都是独立事务,锁一提交就释放
真正难调试的,是线上业务里那些隐式走二级索引的 saveOrUpdate 调用——它不报错,只在凌晨流量高峰突然爆发死锁,而日志里只显示两个 INSERT 在等同一行的 insert intention waiting。


















