唯一索引 INSERT 死锁实战:5 秒复现交叉插入的 S 锁循环等待

唯一索引 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 检测到死锁,回滚其中一个事务,另一个继续执行。


四、这个死锁为什么"隐秘"

跟间隙锁一样,这个死锁也违反直觉:

  1. 两个事务插入的是不同的值(ORD001 和 ORD002),看起来不应该冲突
  2. 死锁发生在唯一性检查阶段,不是插入阶段,让人觉得"检查不应该锁别人"
  3. 日志里写的是 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 锁持有时间长,交叉概率高 缩小事务范围

七、总结

回到这次死锁告警:两个并发请求各自处理一批订单,订单有重叠,插入顺序相反,在唯一索引上形成了循环等待。

三个要点:

  1. 唯一索引 INSERT 时,如果目标值已被未提交事务持有,会加 S 锁等待确认
  2. 两个事务交叉插入对方持有的唯一键值 → S 锁等 X 锁 → 循环等待 → 死锁
  3. 这个死锁在任何隔离级别都会发生,降低隔离级别没用;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 篇,往期回顾:

下篇预告:MySQL RR 隔离级幻读到底解决没有?面试背了 100 遍的"快照读 vs 当前读",生产里到底是什么表现?


系列持续更新中,关注我。

相关推荐
沉默王二1 小时前
面试官:RAG 不用向量数据库,用 MySQL 硬扛?我:100 万向量不是很轻松?
mysql·面试·ai编程
小猿姐16 小时前
MySQL Top 10 热点问题 AI 运维实战:从内核诊断到云原生运维
mysql·云原生·aiops
云技纵横1 天前
Gap Lock 死锁实战:5 秒在本地复现 MySQL 间隙锁死锁
后端·mysql
无响应de神1 天前
三、用户与权限管理
数据库·mysql
摇滚侠2 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
ApacheSeaTunnel2 天前
实战演示 | 基于 Apache SeaTunnel 与 Apache DolphinScheduler 实现 MySQL 到 Doris 离线定时增量同步
大数据·mysql·开源·doris·数据集成·seatunnel·数据同步
DARLING Zero two♡2 天前
【MySQL数据库】数据类型与表约束
数据库·mysql
BD_Marathon2 天前
SQL学习指南——视图
数据库·sql