唯一索引 INSERT 死锁实战:5 秒复现交叉插入的 S 锁循环等待
上一篇说"用唯一索引能避免 Gap Lock 死锁"。有读者问:那唯一索引是不是就安全了?未必。
一、事故现场
下午,订单服务又报了死锁。这次跟间隙锁无关,死锁发生在唯一索引上。
死锁日志的关键信息(生产环境原始日志,下文用最小化案例 t_unique_test 本地复现):
sql
LATEST DETECTED DEADLOCK
------------------------
TRANSACTION 1, ACTIVE 2 sec inserting
insert into order_item(id, order_no) values(4, 'ORD002')
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 62 page no 3 n bits 72 index uk_order_no
lock_mode S waiting
TRANSACTION 2, ACTIVE 1 sec inserting
insert into order_item(id, order_no) values(5, 'ORD001')
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 62 page no 3 n bits 72 index uk_order_no
lock_mode S waiting
注意看:两个事务等的都是 lock_mode S waiting,是 S 锁,不是 X 锁,也不是 gap lock。
而且事务 1 插入的是 ORD002,事务 2 插入的是 ORD001,根本不是同一个值。为什么插入不同的值会死锁?
二、先回顾:上一篇说了什么
上一篇《MySQL 间隙锁是怎么"悄悄"制造死锁的》里有一个结论:
唯一索引上,等值查询命中记录时会降级为 Record Lock,不会额外加 Gap Lock。
所以很多读者看完后的第一反应是:把普通索引改成唯一索引,死锁问题就解决了。
这个结论没错,但有个前提:它说的是 UPDATE 命中记录的场景。INSERT 的加锁逻辑完全不同,唯一索引也逃不掉另一个死锁陷阱。
三、完整复现:5 秒内发生死锁
还是老规矩,最小化案例,建议本地跑一遍。
3.1 建表和数据
sql
CREATE TABLE t_unique_test (
id INT,
order_no VARCHAR(20),
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no) -- order_no 是唯一索引
);
空表,没有任何数据。
3.2 死锁复现步骤
打开两个 MySQL 客户端,按以下顺序操作:
sql
时间线 会话 1 会话 2
T1 BEGIN;
T2 BEGIN;
T3 INSERT INTO t_unique_test
VALUES(1, 'ORD001'); -- ✅ 成功
T4 INSERT INTO t_unique_test
VALUES(2, 'ORD002'); -- ✅ 成功
T5 INSERT INTO t_unique_test
VALUES(3, 'ORD002'); -- ⏳ 等待...
T6 INSERT INTO t_unique_test
VALUES(4, 'ORD001'); -- 💥 死锁!
会话 2 报错:
vbnet
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction
3.3 为什么会死锁
逐步分析:
T3:会话 1 插入 ORD001
成功。会话 1 持有 order_no='ORD001' 这条记录的 X 锁。
T4:会话 2 插入 ORD002
成功。会话 2 持有 order_no='ORD002' 这条记录的 X 锁。
T5:会话 1 插入 ORD002
ORD002 已经被会话 2 插入了(但还没提交)。InnoDB 做唯一性检查时,发现 order_no='ORD002' 这条记录已存在,于是尝试对这条记录加 S 锁来确认冲突。
但这条记录的 X 锁被会话 2 持有,S 锁和 X 锁互斥,所以会话 1 卡住了。
T6:会话 2 插入 ORD001
同理,ORD001 被会话 1 持有 X 锁。会话 2 做唯一性检查时需要加 S 锁,被会话 1 的 X 锁挡住。
会话 1 等 会话 2 释放 ORD002 的 X 锁(才能加 S 锁做唯一性检查)
会话 2 等 会话 1 释放 ORD001 的 X 锁(才能加 S 锁做唯一性检查)
循环等待 → 死锁!
MySQL 检测到死锁,回滚其中一个事务,另一个继续执行。
四、这个死锁为什么"隐秘"
跟间隙锁一样,这个死锁也违反直觉:
- 两个事务插入的是不同的值(ORD001 和 ORD002),看起来不应该冲突
- 死锁发生在唯一性检查阶段,不是插入阶段,让人觉得"检查不应该锁别人"
- 日志里写的是
lock_mode S waiting,很多人看到 S 锁不会往死锁方向想
要搞懂这个死锁,得先看懂唯一索引的 INSERT 加锁逻辑:
INSERT 到唯一索引时,InnoDB 会先做唯一性检查。如果发现目标值已被另一个未提交事务插入,会对那条记录加 S 锁等待。S 锁等 X 锁释放,两个事务一旦交叉插入对方已持有的值,就形成循环等待。
跟 Gap Lock 死锁对比一下:
| Gap Lock 死锁(上一篇) | 唯一索引 INSERT 死锁(本篇) | |
|---|---|---|
| 锁类型 | Gap Lock(间隙锁) | S 锁(共享锁,唯一性检查) |
| 触发条件 | 非唯一索引 + 等值查询 + 并发插入同一间隙 | 唯一索引 + 并发交叉插入对方已持有的值 |
| 等待关系 | insert intention 等 gap lock | S 锁等 X 锁 |
| 前提 | 隔离级别为 REPEATABLE-READ | 任何隔离级别都会发生 |
注意最后一行:Gap Lock 死锁在 READ COMMITTED 下可以避免,但唯一索引 INSERT 死锁在任何隔离级别都会发生,降低隔离级别也没用。
五、怎么避免这类死锁
方案 1:保持插入顺序一致
跟上一篇的思路一样,核心是避免循环等待。如果所有事务都按相同顺序插入,就不会交叉:
java
// ❌ 危险:两个事务的插入顺序相反
// 事务1: INSERT ORD001 → INSERT ORD002
// 事务2: INSERT ORD002 → INSERT ORD001
// ✅ 安全:统一按 order_no 排序后插入
// 事务1: INSERT ORD001 → INSERT ORD002(按字母序)
// 事务2: INSERT ORD001 → INSERT ORD002(同样按字母序)
实际做法:批量插入前先按唯一键排序,保证所有事务的插入顺序一致。
方案 2:INSERT IGNORE 做幂等,但别指望它防死锁
很多文章会告诉你"用 INSERT IGNORE 遇到冲突直接跳过,不会死锁"。这是个常见误区,实测会打脸:
sql
INSERT IGNORE INTO t_unique_test VALUES(3, 'ORD002');
原因在于:INSERT IGNORE 改变的只是"检测到冲突之后 "的处理方式,跳过并返回 0,而不是报错回滚。但"检测冲突"这一步,它和普通 INSERT 没区别,照样要对冲突记录加 S 锁做唯一性确认。S 锁等 X 锁的循环等待,一个都没少。
实测结果:两个事务交叉 INSERT IGNORE,照样死锁,死锁日志里的锁模式和普通 INSERT 一模一样。
那它到底有什么用?幂等。业务上"有就跳过、没有才插"的场景,它比普通 INSERT 优雅(不报错)。但防死锁还得靠方案 1 排序,或者捕获 1213 异常重试。
注意:
INSERT IGNORE会忽略所有错误(不只是唯一键冲突),需要精确控制时改用INSERT ... ON DUPLICATE KEY UPDATE。
方案 3:ON DUPLICATE KEY UPDATE 做幂等更新,同样不能防死锁
跟 INSERT IGNORE 类似,ON DUPLICATE KEY UPDATE(ODKU)也常被误以为能绕开死锁,实际不能:
sql
INSERT INTO t_unique_test VALUES(3, 'ORD002')
ON DUPLICATE KEY UPDATE order_no = VALUES(order_no);
它得先"确认存在冲突"才能转 UPDATE,而确认冲突这步照样要对冲突记录加 S 锁。拿到 S 锁确认后,再请求 X 锁执行更新,对方事务还握着 X 锁没提交,X 锁请求照样阻塞。两个事务交叉 ODKU,照样循环等待死锁。
实测结果:交叉插入下 ODKU 同样死锁,死锁日志里看到的是 lock_mode X waiting(UPDATE 阶段等 X 锁)。
它的真正价值是"有就更新、没有才插",比"先 SELECT 再判断 INSERT/UPDATE"省一次往返。但防死锁这事,还得靠方案 1。
澄清一个常见说法:有人说 ODKU"检查唯一性时会触发 Gap Lock",这不准确。唯一索引上做唯一性检查,加的是记录锁(
locks rec but not gap),不含 Gap Lock。它参与的是本篇讲的 S 锁 / X 锁循环等待,跟 Gap Lock 无关。
方案 4:批量插入替代循环单条插入
java
// ❌ 危险:循环单条插入,每条都可能遇到冲突
for (String orderNo : orderNoList) {
mapper.insert(new OrderItem(orderNo));
}
// ✅ 安全:一次性批量插入,减少事务交叉窗口
mapper.batchInsert(orderNoList);
sql
INSERT INTO t_unique_test VALUES
(3, 'ORD001'),
(4, 'ORD002'),
(5, 'ORD003');
批量插入把多条记录放在一个语句里,单条语句内部不会自我死锁,也减少了事务数量和持锁时长,交叉窗口大幅缩小。
六、CheckList:唯一索引死锁风险排查
| # | 检查项 | 风险点 | 正确做法 |
|---|---|---|---|
| 1 | 多事务并发 INSERT 相同唯一键值 | 交叉插入形成循环等待 | 按唯一键排序后统一插入 |
| 2 | 循环单条 INSERT | 每条都是独立的锁等待机会 | 改用批量 INSERT |
| 3 | 幂等场景用普通 INSERT | 冲突时加 S 锁等待 | 配合排序插入 + 捕获 1213 重试(INSERT IGNORE / ODKU 不能防死锁) |
| 4 | 多事务操作顺序不一致 | 循环等待 → 死锁 | 按固定顺序操作 |
| 5 | 以为唯一索引不会死锁 | 放松警惕,不做防护 | 唯一索引的死锁类型不同,但依然存在 |
| 6 | 大事务包含多条 INSERT | 锁持有时间长,交叉概率高 | 缩小事务范围 |
七、总结
回到这次死锁告警:两个并发请求各自处理一批订单,订单有重叠,插入顺序相反,在唯一索引上形成了循环等待。
三个要点:
- 唯一索引 INSERT 时,如果目标值已被未提交事务持有,会加 S 锁等待确认
- 两个事务交叉插入对方持有的唯一键值 → S 锁等 X 锁 → 循环等待 → 死锁
- 这个死锁在任何隔离级别都会发生,降低隔离级别没用;INSERT IGNORE 和 ON DUPLICATE KEY UPDATE 也防不住
避坑方向:能排序就排序插入;幂等场景用 INSERT IGNORE / ODKU 处理冲突,但别当防死锁手段;能批量就别循环单条插;兜底记得捕获 1213 异常重试。
附录:本地复现完整 SQL
sql
-- 1. 建表
CREATE TABLE t_unique_test (
id INT,
order_no VARCHAR(20),
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no)
);
-- 2. 会话 1 执行
BEGIN;
INSERT INTO t_unique_test VALUES(1, 'ORD001'); -- ✅ 成功
INSERT INTO t_unique_test VALUES(3, 'ORD002'); -- ⏳ 等待...
-- 3. 会话 2 执行(在另一个客户端)
BEGIN;
INSERT INTO t_unique_test VALUES(2, 'ORD002'); -- ✅ 成功
INSERT INTO t_unique_test VALUES(4, 'ORD001'); -- 💥 死锁!会话 2 被回滚
-- 4. 会话 1 的第二条 INSERT 自动成功(对方回滚后锁释放了)
COMMIT;
建议本地跑一遍(本文基于 MySQL 8.0 验证,8.0+ 均可复现)。
复现要点:两个会话必须交替执行,会话 1 先成功插入 ORD001,会话 2 先成功插入 ORD002,然后交叉插入对方的值。如果先跑完一个会话再跑另一个,只会报 duplicate key 错误,不会死锁。
你在生产环境遇到过唯一索引死锁吗?评论区聊聊你的排查经历。
觉得有用的话点个赞 + 收藏,掘金的收藏权重很高,收藏多才能推给更多踩坑的同学。
系列导航
本文是「Java 生产环境踩坑实录」系列第 5 篇,往期回顾:
- 第 1 篇:Redis 分布式锁的正确姿势:你写的可能是"假锁"
- 第 2 篇:用了 3 年 Spring Boot 才发现:@Transactional 的 7 个坑
- 第 3 篇:ThreadLocal 内存泄漏:你的应用正在悄悄 OOM
- 第 4 篇:MySQL 间隙锁是怎么"悄悄"制造死锁的
- 第 5 篇:唯一索引并发插入为什么也会死锁(本文)
下篇预告:MySQL RR 隔离级幻读到底解决没有?面试背了 100 遍的"快照读 vs 当前读",生产里到底是什么表现?
系列持续更新中,关注我。