Gap Lock 死锁实战:5 秒在本地复现 MySQL 间隙锁死锁

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 是"隐秘杀机"

这个死锁之所以难排查,因为它违反直觉:

  1. 两个事务操作的是不同的行(b=10 和 b=15),看起来不应该冲突
  2. 死锁发生在 insert 上,不是 update 上------让人觉得"插入不应该锁别人"
  3. 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);
}

小事务加锁时间短,持有锁的窗口小,和其他事务交叉的概率就低。

⚠️ 原子性风险:拆分事务会破坏原子性!如果 updateinsert 在业务上必须同时成功或同时失败(例如"更新订单状态"与"写入订单明细"必须原子),拆成两个事务后,一旦 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_LOCKSINNODB_LOCK_WAITS

6.3 死锁日志怎么看

拿到死锁日志后,重点看三件事:

  1. 哪个事务被回滚了 --- MySQL 选了代价较小的事务回滚,看它是哪个业务操作
  2. 两个事务等的是什么锁 --- lock_mode X locks gap before rec insert intention waiting 表示等插入意向锁
  3. 涉及的索引是什么 --- 如果是普通索引上的 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 形成了循环等待。

记住这三点:

  1. 可重复读隔离级别下,非唯一索引的等值查询会多锁一段间隙(Gap Lock)
  2. Gap Lock 之间互相兼容,但插入意向锁必须等 Gap Lock 释放
  3. 当两个事务各持一段 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 篇,往期回顾:

下篇预告:一条 INSERT 引发的血案------唯一索引并发插入为什么也会死锁?你以为唯一索引就安全了?未必。


系列持续更新中,关注公众号 云技纵横 💪

相关推荐
XovH1 小时前
MySQL 系列:第12篇 用户、权限与安全基础
后端
张居邪1 小时前
GitHub Actions + 阿里云 OSS:OIDC 免密同步构建产物
后端·github
无响应de神2 小时前
三、用户与权限管理
数据库·mysql
砍材农夫2 小时前
python环境|conda安装和使用(2)
后端·python
MacroZheng2 小时前
Claude Code官方桌面端正式发布,夯爆了!
java·人工智能·后端
IT_陈寒2 小时前
React的useEffect依赖数组把我坑惨了,真相其实很简单
前端·人工智能·后端
Oneslide3 小时前
ubuntu 手动安装claude
后端
GetcharZp11 小时前
玩转 Linux 机器视觉:手把手带你搞定 Ubuntu 下海康工业相机 C++ SDK
后端