Gap Lock 死锁实战:5 秒在本地复现 MySQL 间隙锁死锁
TL;DR:两个事务更新不同行,却因非唯一索引上的 Gap Lock 形成循环等待而死锁。文末附完整可复现 SQL,本地 5 秒跑出死锁(MySQL 8.0+ 验证通过)。
事故背景
2 点,订单服务死锁告警。两个事务都在执行 insert,插入的还都不是同一行数据------为什么会产生死锁?
死锁日志的关键信息:
sql
LATEST DETECTED DEADLOCK
------------------------
TRANSACTION 1, ACTIVE 3 sec inserting
insert into order_item values(12, 102, 1)
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index idx_product_id
lock_mode X locks gap before rec insert intention waiting
TRANSACTION 2, ACTIVE 2 sec inserting
insert into order_item values(14, 105, 2)
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index idx_product_id
lock_mode X locks gap before rec insert intention waiting
两个事务等的是同一类锁:insert intention waiting(插入意向锁)。而它们插入的数据根本不在同一行------凶手是 Gap Lock(间隙锁)。
下面先讲原理,再给出 5 秒复现的最小案例。强烈建议本地跑一遍,比看十篇文章管用。
一、先搞清楚:InnoDB 到底有哪些锁
很多人只知道"行锁"和"表锁",但 InnoDB 在可重复读(REPEATABLE-READ)隔离级别下,为了防止幻读,加锁的逻辑远比"锁住你查到的那行"复杂。
2.1 三种行级锁
| 锁类型 | 锁什么 | 什么时候出现 |
|---|---|---|
| Record Lock | 锁住索引上的一条具体记录 | 精确匹配唯一索引或主键时 |
| Gap Lock | 锁住两条记录之间的间隙(不包括端点) | 非唯一索引的等值查询、范围查询 |
| Next-Key Lock | Record Lock + Gap Lock,锁住记录 + 它前面的间隙 | 可重复读下的默认加锁方式 |
说白了:
- Record Lock 是"锁住这一行"
- Gap Lock 是"锁住这个空隙,不让别人往里面插数据"
- Next-Key Lock 是"锁住这一行 + 它前面的空隙"
假设有表 t(id=10, id=15, id=20),在可重复读下:
vbnet
select * from t where id = 10
Next-Key Lock 锁住: (-∞, 10] ← 这是间隙 + 记录
↑ 间隙部分 ↑ 记录部分
如果 id 是主键(唯一索引),InnoDB 会把 Next-Key Lock 降级为 Record Lock,只锁 id=10 这一行。
但如果 id 是普通索引(非唯一),就不会降级,还会额外锁住 (10, 15) 这个间隙。
非唯一索引上的等值查询,InnoDB 会"多锁一段间隙"来防止幻读。这段多出来的间隙锁,恰恰是死锁的高发区。
二、完整复现:5 秒内发生死锁
我用一个最小化的案例来复现这次死锁。强烈建议你本地跑一遍,比看十篇文章管用。
3.1 建表和数据
sql
CREATE TABLE t_lock_test (
a INT,
b INT,
c INT,
PRIMARY KEY (a),
KEY idx_b (b) -- b 是非唯一索引
);
INSERT INTO t_lock_test VALUES (10, 10, 10), (15, 15, 15), (20, 20, 20);
数据分布:
css
a(主键) b(普通索引)
10 10
15 15
20 20
3.2 死锁复现步骤
打开两个 MySQL 客户端(会话 1 和会话 2),按以下顺序操作:
sql
时间线 会话 1 会话 2
T1 BEGIN;
T2 BEGIN;
T3 UPDATE t_lock_test SET c=1 WHERE b=10; -- 正常执行
T4 UPDATE t_lock_test SET c=2 WHERE b=15; -- 正常执行
T5 INSERT INTO t_lock_test VALUES(12,12,12); -- ⏳ 等待...
T6 INSERT INTO t_lock_test VALUES(14,14,14); -- 💥 死锁!
会话 2 报错:
vbnet
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction
5 秒之内,死锁就产生了。
3.3 逐步分析锁的变化
T3:会话 1 执行 UPDATE ... WHERE b=10
在非唯一索引 idx_b 上,b=10 的等值查询加的是 Next-Key Lock,还会额外锁住下一条记录前面的间隙。
会话 1 持有的锁:
| 索引 | 锁类型 | 锁范围(大白话) |
|---|---|---|
idx_b |
Next-Key Lock (X) | (-∞, 10] --- b 从负无穷到 10 的间隙 + b=10 记录本身 |
PRIMARY |
Record Lock (X) | a=10 这一行(主键是唯一索引,降级为 Record Lock) |
idx_b |
Gap Lock (X) | (10, 15) --- b 从 10 到 15 之间的间隙 |
注意:
b=10不是唯一索引,所以 InnoDB 没有降级。它不仅锁住了 b=10 前面的间隙(Next-Key Lock),还额外锁住了 (10, 15) 这段间隙(Gap Lock)。这意味着别人不能往 b=10~15 之间插入数据。为什么 update 一行要锁两段间隙?因为 InnoDB 在可重复读下要防止幻读------如果只锁 b=10 这一行,另一个事务可以往 b=10 和 b=15 之间插入一条 b=12 的记录,你下次再查
b<=10可能就多了一行。
T4:会话 2 执行 UPDATE ... WHERE b=15
同理,会话 2 在 idx_b 上也加了 Next-Key Lock + Gap Lock:
| 索引 | 锁类型 | 锁范围(大白话) |
|---|---|---|
idx_b |
Next-Key Lock (X) | (10, 15] --- b 从 10 到 15 的间隙 + b=15 记录 |
PRIMARY |
Record Lock (X) | a=15 这一行 |
idx_b |
Gap Lock (X) | (15, 20) --- b 从 15 到 20 之间的间隙 |
会话 1 锁了
(10, 15)的 Gap,会话 2 锁了(10, 15]的 Next-Key(包含同样的 Gap)。Gap Lock 之间是兼容的,所以此时不冲突,两个事务都能正常执行。
T5:会话 1 执行 INSERT INTO t VALUES(12, 12, 12)
要插入 b=12,落在间隙 (10, 15) 里。
InnoDB 的规则是:往一个被 Gap Lock 占着的间隙里插入数据,必须先获得插入意向锁(Insert Intention Lock)。
但这个间隙被会话 2 的 Next-Key Lock 占着(Next-Key Lock 包含了 (10, 15] 的 Gap 部分)。
插入意向锁必须等 Gap Lock 释放。所以会话 1 卡住了。
T6:会话 2 执行 INSERT INTO t VALUES(14, 14, 14)
要插入 b=14,也落在间隙 (10, 15) 里。
同样需要插入意向锁。但这个间隙被会话 1 的 Gap Lock 占着。
会话 2 也卡住了。
css
会话 1 等 会话 2 释放 (10,15] 的 Gap
会话 2 等 会话 1 释放 (10,15) 的 Gap
→ 循环等待 → 死锁!
MySQL 检测到死锁,回滚其中一个事务(通常是代价较小的那个),另一个自动继续执行。
三、为什么 Gap Lock 是"隐秘杀机"
这个死锁之所以难排查,因为它违反直觉:
- 两个事务操作的是不同的行(b=10 和 b=15),看起来不应该冲突
- 死锁发生在
insert上,不是update上------让人觉得"插入不应该锁别人" - Gap Lock 锁的不是数据,而是不存在的东西(间隙)
在可重复读隔离级别下,非唯一索引的等值查询会"多锁一段间隙"。这段间隙不属于任何一行数据,但它阻止了别人往这个空隙里插入新记录。当两个事务各自锁了一段间隙,然后又都想往对方的间隙里插入数据时,就形成了循环等待------死锁。
四、怎么避免这类死锁
方案 1:用唯一索引替代普通索引
如果 b 字段业务上是唯一的,改成唯一索引:
sql
ALTER TABLE t_lock_test DROP INDEX idx_b;
ALTER TABLE t_lock_test ADD UNIQUE INDEX uk_b (b);
在唯一索引上,等值查询命中记录时只会加 Record Lock,不会额外加 Gap Lock,死锁条件就不存在了。
注意:这里是"命中记录"才降级。如果唯一索引等值查询的是不存在的值(比如
where b=13但没有这条记录),InnoDB 仍然会加 Gap Lock 来防止幻读。本文场景中update命中了记录,所以适用。
适用场景:字段本身就有唯一性约束(如订单号、用户 ID)。但如果字段允许重复(如商品分类、状态值),就不能用唯一索引。
方案 2:降低隔离级别为 READ COMMITTED
sql
-- 全局改(慎重)
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 会话级改(安全)
SET SESSION transaction_isolation = 'READ-COMMITTED';
在 READ COMMITTED 下,InnoDB 对普通数据操作不会加 Gap Lock(只在外键约束检查和唯一性检查时才会出现),Next-Key Lock 也会降级为 Record Lock。
风险:READ COMMITTED 不能防止幻读。同一事务内两次相同的范围查询,可能得到不同的结果集。如果你的业务依赖"同一事务内多次读结果一致",就不能降级。 另外改隔离级别可能影响主从复制(基于 statement 的 binlog 在 RC 下可能不一致),建议用 row-based binlog 格式。
方案 3:保持加锁顺序一致
如果多个事务都要对同一批数据加锁,按固定顺序操作,就不会形成循环等待:
java
// ❌ 危险:两个事务的操作顺序相反
// 事务1: 先锁 b=10,再插 b=12
// 事务2: 先锁 b=15,再插 b=14
// ✅ 安全:按 b 值从小到大顺序操作
// 事务1: 先锁 b=10,再插 b=12(b 从小到大)
// 事务2: 先锁 b=10,再锁 b=15,再插 b=14(同样从小到大)
实际做法:业务层按某个固定维度(如 ID 升序、时间先后)排序后再操作。
方案 4:缩小事务范围
java
// ❌ 危险:在一个大事务里先 update 再 insert
@Transactional
public void processOrder(Order order) {
orderItemMapper.updateStatus(order.getId()); // 加 Gap Lock
orderItemMapper.insertItem(order.getItem()); // 要插入意向锁 → 可能死锁
}
// ✅ 安全:update 和 insert 分两个小事务
public void processOrder(Order order) {
doUpdateStatus(order.getId()); // 小事务 1
doInsertItem(order.getItem()); // 小事务 2
}
@Transactional
public void doUpdateStatus(Long orderId) {
orderItemMapper.updateStatus(orderId);
}
@Transactional
public void doInsertItem(OrderItem item) {
orderItemMapper.insertItem(item);
}
小事务加锁时间短,持有锁的窗口小,和其他事务交叉的概率就低。
⚠️ 原子性风险:拆分事务会破坏原子性!如果
update和insert在业务上必须同时成功或同时失败(例如"更新订单状态"与"写入订单明细"必须原子),拆成两个事务后,一旦 insert 失败,update 已经提交、无法回滚,就会造成数据不一致。这个方案只适用于:update 和 insert 之间没有原子性要求,或拆分后通过其他机制(补偿事务、消息队列重试等)保证最终一致性的场景。如果业务要求强原子性,请优先选方案 1 或方案 2。
五、排查死锁的方法
6.1 开启死锁日志
sql
-- 查看当前状态
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
-- 开启(推荐在生产环境始终开启)
SET GLOBAL innodb_print_all_deadlocks = ON;
开启后,每次发生死锁,MySQL 都会在错误日志里记录完整的锁信息,包括:
- 哪两个事务参与了死锁
- 每个事务持有什么锁、等什么锁
- 涉及的索引、记录和间隙范围
6.2 查看当前锁情况
sql
-- 查看正在等待的锁
SELECT * FROM performance_schema.data_lock_waits;
-- 查看当前持有的锁
SELECT * FROM performance_schema.data_locks;
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS;
performance_schema.data_locks是 MySQL 8.0+ 的新表,取代了老版本的INNODB_LOCKS。如果你用的是 MySQL 5.7,对应的表是INNODB_LOCKS和INNODB_LOCK_WAITS。
6.3 死锁日志怎么看
拿到死锁日志后,重点看三件事:
- 哪个事务被回滚了 --- MySQL 选了代价较小的事务回滚,看它是哪个业务操作
- 两个事务等的是什么锁 ---
lock_mode X locks gap before rec insert intention waiting表示等插入意向锁 - 涉及的索引是什么 --- 如果是普通索引上的 Gap Lock,大概率是本文描述的场景
六、CheckList:上线前排查死锁风险
| # | 检查项 | 风险点 | 正确做法 |
|---|---|---|---|
| 1 | 非唯一索引上的等值查询 | 会触发 Gap Lock + Next-Key Lock | 能用唯一索引就用唯一索引 |
| 2 | 同一事务内先 update 再 insert | update 加 Gap Lock,insert 要插入意向锁 | 缩小事务范围,或改隔离级别 |
| 3 | 多事务操作顺序不一致 | 循环等待 → 死锁 | 按固定顺序(如 ID 升序)操作 |
| 4 | 隔离级别 | REPEATABLE-READ 是 MySQL 默认,会加 Gap Lock | 如果能接受幻读,考虑 READ-COMMITTED |
| 5 | 死锁日志 | 没开 innodb_print_all_deadlocks,出问题看不到根因 | 生产环境始终开启 |
| 6 | 大事务 | 锁持有时间长,交叉概率高 | 事务范围越小越好 |
七、总结
回到凌晨的死锁告警:两个事务更新不同行,却因为非唯一索引上的 Gap Lock 形成了循环等待。
记住这三点:
- 可重复读隔离级别下,非唯一索引的等值查询会多锁一段间隙(Gap Lock)
- Gap Lock 之间互相兼容,但插入意向锁必须等 Gap Lock 释放
- 当两个事务各持一段 Gap Lock 又都想往对方的间隙里插入时 → 死锁
避坑方向:能用唯一索引就用唯一索引;不行就缩小事务范围,减少锁持有时间;再不行就让多事务按固定顺序操作。
附录:本地复现完整 SQL
sql
-- 1. 建表
CREATE TABLE t_lock_test (
a INT,
b INT,
c INT,
PRIMARY KEY (a),
KEY idx_b (b)
);
INSERT INTO t_lock_test VALUES (10, 10, 10), (15, 15, 15), (20, 20, 20);
-- 2. 开启死锁日志(可选)
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 3. 会话 1 执行
BEGIN;
UPDATE t_lock_test SET c = 1 WHERE b = 10; -- 加 Next-Key Lock + Gap Lock
INSERT INTO t_lock_test VALUES (12, 12, 12); -- 等待插入意向锁...
-- 4. 会话 2 执行(在另一个客户端)
BEGIN;
UPDATE t_lock_test SET c = 2 WHERE b = 15; -- 加 Next-Key Lock + Gap Lock
INSERT INTO t_lock_test VALUES (14, 14, 14); -- 死锁!会话 2 被回滚
-- 5. 会话 1 的 INSERT 自动成功(对方回滚后锁释放了)
COMMIT;
建议你在本地跑一遍这个案例(本文基于 MySQL 8.4 验证,8.0+ 均可复现),亲眼看到死锁是怎么产生的,比看任何文章都有用。
⚠️ 复现要点 :两个会话必须交替执行------会话 1 的 INSERT 卡住后,立刻切到会话 2 执行 INSERT,才会触发死锁。如果先跑完一个会话再跑另一个,只会单向阻塞,不会死锁。
你在生产环境遇到过 MySQL 死锁吗?评论区聊聊你的排查经历。
觉得有用的话点个赞 + 收藏,掘金的收藏权重很高,收藏多才能推给更多踩坑的同学。
系列导航
本文是「Java 生产环境踩坑实录」系列第 4 篇,往期回顾:
- 第 1 篇:Redis 分布式锁的正确姿势:你写的可能是"假锁"
- 第 2 篇:用了 3 年 Spring Boot 才发现:@Transactional 的 7 个坑
- 第 3 篇:ThreadLocal 内存泄漏:你的应用正在悄悄 OOM
- 第 4 篇:MySQL 间隙锁是怎么"悄悄"制造死锁的(本文)
下篇预告:一条 INSERT 引发的血案------唯一索引并发插入为什么也会死锁?你以为唯一索引就安全了?未必。
系列持续更新中,关注公众号 云技纵横 💪