硬核图解 MySQL 死锁:为什么两个看似不冲突的 Update 和 Insert 会掐死对方?
在日常的高并发系统开发中,数据库死锁是一个绕不过去的坎。很多同学在遇到死锁时,往往会有一种直觉上的困惑:
“我明明更新的是不同的记录,插入的也是不同的主键,这两个事务怎么就掐死对方了呢?”
其实,MySQL InnoDB 引擎为了防止幻读,在默认的 REPEATABLE-READ(可重复读) 隔离级别下,引入了间隙锁(Gap Lock)和临键锁(Next-Key Lock)。正是这些“隐性锁”默默地在后台划分了势力范围,导致了原本看似毫无交集的并发请求在暗地里迎头相撞。
本文将通过一个非常经典的生产死锁案例,配合清晰直观的区间图解与 MySQL 引擎死锁日志,带你一步步看清死锁产生的底层逻辑,教你如何读懂死锁日志并优雅破局。
1. 背景引入:为什么说 Gap Lock 是并发死锁的“隐形杀手”?
在 InnoDB 中,锁是加在索引上的。针对不同的索引类型和查询条件,加锁的范围大不相同:
• 记录锁(Record Lock):仅锁住单独的一行记录,如 lock_mode X locks rec but not gap。
• 间隙锁(Gap Lock):锁住两个记录之间的空隙,不包含记录本身,主要为了防止在此间隙中插入新数据以避免幻读,如 lock_mode X locks gap before rec。
• 临键锁(Next-Key Lock):记录锁与间隙锁的结合,锁住记录本身以及记录之前的间隙(左开右闭区间),这是 InnoDB 在可重复读隔离级别下的默认加锁单位。
在高并发写场景下,两个事务如果都在同一个间隙内持有 Gap 锁,它们本身是互不冲突的(Gap 锁与 Gap 锁兼容)。但当这两个事务接下来都企图在这个间隙中执行 INSERT 时,问题就来了:插入动作必须向同一个间隙申请插入意向锁(Insert Intention Lock),而插入意向锁会与对方持有的 Gap 锁产生互斥,最终引发死锁。
这就是今天我们要拆解的 Gap Lock 死锁事件。
2. 动手实操:5分钟完美复现一次真实的 Gap Lock 死锁
为了深入理解,强烈建议你在本地的 MySQL 环境中跟着我们动手敲一遍。本节实验在 MySQL 8.0、默认隔离级别 REPEATABLE-READ 下运行。
2.1 初始化实验表与数据
首先,我们创建一个包含主键索引 a 和普通二级非唯一索引 b 的测试表,并插入 3 条初始数据:

-- 创建测试表
CREATE TABLE passjava_test_lock2 (
a INT,
b INT,
c INT,
PRIMARY KEY (a),
KEY idx_b (b)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 写入初始数据
INSERT INTO passjava_test_lock2 VALUES (10, 10, 10), (15, 15, 15), (20, 20, 20);
-- 查看初始状态
SELECT * FROM passjava_test_lock2;
初始化完成后,二级非唯一索引 idx_b 上的逻辑结构如下表所示:
| 记录序号 | 主键 a (Clustered Index) | 二级索引 b (idx_b) | 锁间隙边界 |
|---|---|---|---|
| 1 | 10 | 10 | $(-\infty, 10)$ |
| 2 | 15 | 15 | $(10, 15)$ |
| 3 | 20 | 20 | $(15, 20)$ |
2.2 线程交错的“死亡华尔兹”
我们开启两个会话(Session 1 与 Session 2),并在事务中交错执行以下语句。为了让死锁日志记录得更详细,我们在实验开始前,开启全局死锁日志输出:
-- 会话 1 和会话 2 均可执行,使死锁日志输出到 MySQL 错误日志中
SET GLOBAL innodb_print_all_deadlocks = ON;
接着,按照下表中的时间轴(Time Line)执行操作:
| 时间步骤 | 会话 1 (Session 1) | 会话 2 (Session 2) | 运行状态 |
|---|---|---|---|
| T1 | BEGIN; |
事务 1 开启 | |
| T2 | BEGIN; |
事务 2 开启 | |
| T3 | UPDATE passjava_test_lock2 SET c=106 WHERE b=10; |
成功加锁,修改 1 行 | |
| T4 | UPDATE passjava_test_lock2 SET c=206 WHERE b=15; |
成功加锁,修改 1 行 | |
| T5 | INSERT INTO passjava_test_lock2 VALUES (12, 12, 12); |
[LOCK WAIT] 阻塞等待中 | |
| T6 | INSERT INTO passjava_test_lock2 VALUES (14, 14, 14); |
[DEADLOCK] 会话 2 报死锁错 | |
| T7 | [SUCCESS] 会话 1 在 5 秒后执行成功 | (会话 2 回滚) | 事务 1 顺利完成,提交 |
当执行到 T6 步时,会话 2 终端会立刻抛出如下著名的死锁报错:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
3. 核心解密:详解 InnoDB 锁升级的“三段法”
为什么看似修改不同数据行的两个 UPDATE,在接下来的 INSERT 时会彻底掐死?我们来深入还原这两个事务持有的底层锁。
3.1 意向锁与记录锁的占位
当会话 1 执行 UPDATE ... WHERE b=10 时,系统在表和行上加了如下几把锁:
1. 表级锁(TABLE IX):意向排他锁,占住表坑,声明这块表里有行正在被修改。
2. 主键记录锁(PRIMARY X, REC_NOT_GAP):因为满足二级索引 b=10 的主键 a 对应的是 10,所以主键索引上 a=10 的那一行被锁死,不允许其他事务修改。
3.2 为什么等值查询会波及到相邻的 (10, 15) 间隙?
关键的魔鬼细节在非唯一二级索引 idx_b 上:
1. Next-Key Lock(临键锁):由于 b 是非唯一二级索引,查询 b=10 会锁住该值及其前面的间隙。对应区间为:$(-\infty, 10]$。
2. GAP Lock(间隙锁):InnoDB 为了防止在 b=10 附近插入新记录导致下一次读产生幻读,在扫描到 b=10 之后,会继续向后扫描,直到发现第一条不满足条件的记录(即 b=15)。为了安全起见,它顺便把 b=10 到 b=15 之间的这段空隙也锁上了!
因此,会话 1 的二级索引锁范围是:$(-\infty, 10]$ 以及 $(10, 15)$。
我们可以通过查询 performance_schema.data_locks 表,清晰地看到会话 1 此时持有的锁:

同理,当会话 2 在 T4 步执行 UPDATE ... WHERE b=15 时,由于二级非唯一索引的特性,它需要锁住自身的 Next-Key 锁区间 $(10, 15]$,并向后锁住至下一个非等值边界前的间隙,即 $(15, 20)$。
此时,两个会话在二级索引上的势力范围重叠区域如下表:
| 索引区间 | 事务 1 锁状态 | 事务 2 锁状态 | 兼容性 |
|---|---|---|---|
| $(-\infty, 10]$ | Next-Key Lock (持有) | 无 | 兼容 |
| $(10, 15)$ | GAP Lock (持有) | Next-Key Lock (持有) | 兼容 (Gap锁与Next-Key内的Gap部分不互斥) |
| $(15, 20)$ | 无 | GAP Lock (持有) | 兼容 |
这就是著名的锁兼容定理:不同事务的 Gap 锁可以在同一个间隙中和平共处。 此时两个事务都成功拿到了锁,没有人被阻塞。
4. 惊悚对撞:两发 Insert 最终是如何锁死对方的?
真正的冲突,发生在 T5 和 T6 的插入阶段。

4.1 插入意向锁(Insert Intention Lock)的竞争规则
当会话 1 执行 INSERT (12, 12, 12) 时:
• 它尝试在二级索引的 b=12 处插入。
• 根据 12 所在的位置,插入必须落在区间 $(10, 15)$ 中。
• 要在该间隙中插入,事务 1 必须向系统申请一个插入意向排他锁(X,GAP,INSERT_INTENTION)。
• 根据互斥法则:如果一个间隙已经被其他事务持有了 Gap 锁或 Next-Key 锁,则新申请的插入意向锁必须进入等待状态,直到对方释放间隙锁。
• 由于会话 2 目前正牢牢把守着临键锁 $(10, 15]$,会话 1 的插入动作被挂起,进入 LOCK WAIT。
4.2 还原“双向合围”的死锁链条
紧接着在 T6 步,会话 2 尝试执行 INSERT (14, 14, 14):
• 它的插入值 b=14 同样也落在 $(10, 15)$ 区间中。
• 会话 2 必须为这个区间申请插入意向锁。
• 然而,会话 1 目前正持有该区间上的 Gap 锁 $(10, 15)$!
• 会话 2 的插入意向锁也必须等待会话 1 释放 Gap 锁。
至此,死锁闭环形成:
1. 会话 1 正在等待 会话 2 释放 $(10, 15]$ 的 Next-Key 锁。
2. 会话 2 正在等待 会话 1 释放 $(10, 15)$ 的 Gap 锁。
双方都拿着对方前进的钥匙,谁也不肯放手。MySQL 引擎的死锁检测器瞬间识别出这一环形等待,决定将代价较低的事务(会话 2)进行回滚,并释放其占有的所有锁。会话 1 终于等到了锁,耗时 5 秒后执行成功。
5. 抓贼拿脏:深度解读 MySQL Error Log 中的死锁日志
死锁发生后,我们可以查看 MySQL 的日志文件。以下是经过精简处理的死锁核心日志片段:
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 2766242, ACTIVE 15 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 4 row lock(s), undo log entries 2
MySQL thread id 12, query id 1194 localhost 127.0.0.1 root update
INSERT INTO passjava_test_lock2 VALUES (12, 12, 12)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 501 page no 5 n bits 72 index idx_b of table `passjava_admin`.`passjava_test_lock2` trx id 2766242 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 8000000f; asc ;; (注: hex 8000000f 对应的十进制即是 15)
1: len 4; hex 8000000f; asc ;;
*** (2) TRANSACTION:
TRANSACTION 2766243, ACTIVE 11 sec inserting
mysql tables in use 1, locked 1
5 lock struct(s), heap size 1128, 4 row lock(s), undo log entries 2
MySQL thread id 13, query id 1198 localhost 127.0.0.1 root update
INSERT INTO passjava_test_lock2 VALUES (14, 14, 14)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 501 page no 5 n bits 72 index idx_b of table `passjava_admin`.`passjava_test_lock2` trx id 2766243 lock_mode X
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 8000000f; asc ;;
1: len 4; hex 8000000f; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 501 page no 5 n bits 72 index idx_b of table `passjava_admin`.`passjava_test_lock2` trx id 2766243 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 8000000f; asc ;;
1: len 4; hex 8000000f; asc ;;
*** WE ROLL BACK TRANSACTION (2)
如何读懂这篇“判决书”?
1. 事务 1 信息:正在执行 INSERT INTO ... (12, 12, 12)。它在等待(WAITING FOR THIS LOCK TO BE GRANTED)分配一个排他临键锁前面的插入意向锁(lock_mode X locks gap before rec insert intention waiting),对应的临界数据值是 hex 8000000f(即普通索引 b=15)。
2. 事务 2 信息:正在执行 INSERT INTO ... (14, 14, 14)。它持有(HOLDS THE LOCK(S))对 b=15 的排他锁(lock_mode X),同时也因为自己的插入操作正在等待(WAITING FOR THIS LOCK TO BE GRANTED)同一个区间的插入意向锁(insert intention waiting)。
3. 终审判决:MySQL 检测到两个事务形成死循环,并在日志底部输出:WE ROLL BACK TRANSACTION (2),回滚了事务 2。
6. 破局之道:如何让你的高并发系统免受此死锁侵扰?
理解了死锁的成因,解决它就有了清晰的逻辑。在实际工程中,我们可以通过以下三种主要思路来防范和治理 Gap Lock 引起的死锁。
6.1 策略一:升级为唯一索引(Unique Index)
这是最直接、最高效的手段。
如果非唯一索引 b 可以改造成唯一索引(比如关联了业务的唯一号),那么当 UPDATE ... WHERE b=10 执行时,InnoDB 能够确定地只锁定该行记录本身,不需要再防御可能出现的幻读。此时,锁会退化为纯粹的 Record Lock(记录锁),不再产生 Gap 锁,后续的 INSERT 自然可以畅通无阻地插入到周边间隙中。
6.2 策略二:降低隔离级别至 RC(Read Committed)
如果业务允许放宽数据一致性保障(例如接受幻读风险),可以将数据库的隔离级别调整为 READ-COMMITTED(读已提交)。 在 RC 隔离级别下,InnoDB 默认关闭了间隙锁(Gap Lock)和临键锁(Next-Key Lock)。所有的查询和更新都只加记录锁,不存在间隙锁阻塞插入的问题,因而可以从根本上避开这一类型的死锁。
6.3 策略三:在应用层规范资源加锁顺序
如果必须在 RR 隔离级别下使用非唯一索引,则可以通过业务层逻辑进行干预。
将原本的“多事务无序并发”改为“有序执行”。例如,先通过分布式锁将这部分区间操作串行化,或者在事务一开始就使用 SELECT ... FOR UPDATE 按照主键升序将要加锁的范围一次性锁定,以此来打破环形等待条件。
7. 总结
非唯一二级索引上的区间更新加锁,是导致 MySQL Gap Lock 并发死锁的经典场景。希望通过本文的动手实操和图解剖析,能够帮助你建立对间隙锁和临键锁空间边界的具象认知。以后在生产中再次遇到 insert intention waiting 的报错,你就能够成竹在胸、优雅避坑!
欢迎关注Github开源项目。如果你在开发中遇到过其他诡异的死锁问题,欢迎在评论区一起交流探讨!

长按二维码关注 “边学边练”