MySQL 间隙锁深入解析与排查实战

先看一个真实的生产故障:订单系统在并发创建订单时频繁出现 Deadlock found when trying to get lock,堆栈指向一个看似简单的 INSERT ... SELECT 语句。原以为只是行锁冲突,直到深入 InnoDB 的锁日志,才发现“罪魁祸首”是——间隙锁(Gap Lock)。本文就从这次故障出发,把间隙锁的原理、触发条件、排查工具和优化方案彻底讲透,所有示例均可在本地 MySQL 8.0 中复现。

从一个死锁场景开始

我们有两张表:orders(订单)和 order_items(订单项),业务要求在创建订单时拷贝预置的商品模板到订单项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 建表语句(可复现环境)
CREATE TABLE template_items (
id INT PRIMARY KEY AUTO_INCREMENT,
template_id INT NOT NULL,
product_name VARCHAR(100),
INDEX idx_template (template_id)
) ENGINE=InnoDB;

CREATE TABLE order_items (
id INT PRIMARY KEY AUTO_INCREMENT,
order_id INT NOT NULL,
product_name VARCHAR(100),
INDEX idx_order (order_id)
) ENGINE=InnoDB;

-- 预置数据
INSERT INTO template_items (template_id, product_name) VALUES
(1, '商品A'), (1, '商品B'), (2, '商品C'), (2, '商品D');

-- 初始订单项为空

并发事务模拟:

时间 事务 T1 事务 T2
1 BEGIN; BEGIN;
2 INSERT INTO order_items (order_id, product_name) SELECT 100, product_name FROM template_items WHERE template_id = 1;
3 INSERT INTO order_items (order_id, product_name) SELECT 200, product_name FROM template_items WHERE template_id = 2;
4 执行成功,但未提交 T2 被阻塞,等待锁

T2 被阻塞,随后可能触发死锁检测。SHOW ENGINE INNODB STATUS 会发现类似输出:

1
2
3
4
5
6
7
8
9
10
---TRANSACTION 2814795, ACTIVE 12 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 9, OS thread handle 139915381593856, query id 245 localhost root executing
INSERT INTO order_items (order_id, product_name) SELECT 200, product_name FROM template_items WHERE template_id = 2
------- TRX HAS BEEN WAITING 12 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index idx_template of table `test`.`template_items` trx id 2814795 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 80000002; asc ;;
1: len 4; hex 80000003; asc ;;

关键信息lock_mode X locks gap before rec insert intention waiting —— T2 在插入意向锁之前被间隙锁阻塞。这就引出了我们的主角:间隙锁。

间隙锁是什么

InnoDB 在可重复读(REPEATABLE READ)隔离级别下,为了解决幻读(Phantom Read),引入了间隙锁。所谓间隙锁,锁定的是索引记录之间的“间隙”,或者第一条记录之前、最后一条记录之后的区间,属于纯区间锁,不包含任何具体记录。

结合上面的例子,template_items 表根据 idx_template 索引,当前存在 template_id = 1template_id = 2 的记录,间隙划分如下:

1
2
3
4
5
(-∞, 1)   (间隙)
[1, 1] (记录锁)
(1, 2) (间隙)
[2, 2] (记录锁)
(2, +∞) (间隙)

事务 T1 执行 SELECT ... WHERE template_id = 1 会加临键锁(Next-Key Lock),范围是 ( -∞, 1 ],即索引记录 1 的记录锁加上它之前的间隙 ( -∞, 1 )。事务 T2 要插入 template_id = 2 的记录,需要获取目标位置间隙上的插入意向锁(Insert Intention Lock),但插入意向锁与间隙锁冲突,因此 T2 被阻塞。这就是典型的间隙锁导致的锁等待。

临键锁与插入意向锁的冲突矩阵

InnoDB 的锁体系可以简化为这张图:

1
2
3
4
5
6
7
8
+----------------+-----+------+---------------+---------------------+
| Lock Type | S | X | Insert Intention | Next-Key (S/X) |
+----------------+-----+------+---------------+---------------------+
| S | 兼容 | 冲突 | 冲突 | 兼容(记录部分) |
| X | 冲突 | 冲突 | 冲突 | 冲突 |
| Insert Intention | 冲突 | 冲突 | 兼容 | 冲突(间隙部分) |
| Next-Key (S/X) | 兼容 | 冲突 | 冲突 | 部分兼容/冲突 |
+----------------+-----+------+---------------+---------------------+

这里的关键:插入意向锁只与间隙锁冲突,与其他插入意向锁兼容。而临键锁包含间隙锁部分,所以会阻塞插入意向锁。

完整的死锁复现案例(示例 1)

我们设计一个必定触发死锁的场景,通过 SHOW ENGINE INNODB STATUS 观察所有锁信息。

环境要求:MySQL 8.0,隔离级别 READ-REPEATABLE,innodb_status_output_locks = ON(MySQL 8.0 默认开启)。

准备数据:

1
2
3
4
5
6
7
8
-- 创建一个简单的表
CREATE TABLE t (
a INT PRIMARY KEY,
b INT,
INDEX idx_b (b)
) ENGINE=InnoDB;

INSERT INTO t (a, b) VALUES (1, 10), (3, 30), (5, 50);

死锁复现步骤

步骤 事务 T1 事务 T2
1 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; 同左
2 BEGIN; BEGIN;
3 SELECT * FROM t WHERE b = 10 FOR UPDATE;
4 SELECT * FROM t WHERE b = 30 FOR UPDATE;
5 INSERT INTO t (a, b) VALUES (2, 30);
6 INSERT INTO t (a, b) VALUES (4, 50);
T1 被阻塞,等待插入意向锁
7 等待超时或死锁检测后回滚 死锁被检测,T2 回滚

执行完后,通过 SHOW ENGINE INNODB STATUS\G 查看最近死锁日志,关键部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-08-08 08:00:00 0x7f...
*** (1) TRANSACTION:
TRANSACTION 1001, ACTIVE 12 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 8, OS thread handle ..., query id 245 localhost root executing
INSERT INTO t (a, b) VALUES (2, 30)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 4 n bits 72 index idx_b of table `test`.`t` trx id 1001 lock_mode X locks gap before rec
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 8000001e; asc ;;
1: len 4; hex 80000003; asc ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index idx_b of table `test`.`t` trx id 1001 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000032; asc 2;;
1: len 4; hex 80000005; asc ;;
*** (2) TRANSACTION:
TRANSACTION 1002, ACTIVE 8 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 9, OS thread handle ..., query id 246 localhost root executing
INSERT INTO t (a, b) VALUES (4, 50)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 4 n bits 72 index idx_b of table `test`.`t` trx id 1002 lock_mode X locks gap before rec
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000032; asc 2;;
1: len 4; hex 80000005; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index idx_b of table `test`.`t` trx id 1002 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 8000001e; asc ;;
1: len 4; hex 80000003; asc ;;
*** WE ROLL BACK TRANSACTION (2)

解读:

  • T1 持有 gap lock(间隙锁)在 b=30 之前(也就是 (10, 30) 间隙),等待在插入 b=30 记录之前的插入意向锁。
  • T2 持有 gap lock 在 b=50 之前的间隙 (30, 50),等待在插入 b=50 记录之前的插入意向锁。
  • 互相等待对方释放间隙锁 → 死锁。

这验证了:在 RR 隔离级别下,SELECT ... FOR UPDATE 访问不存在的记录时,会加间隙锁,后续的插入行为可能形成死锁。

示例 2:唯一索引下的间隙锁退化

很多人误以为只有普通二级索引才会加间隙锁,其实唯一索引中仍然存在间隙,只不过锁的范围更小。

1
2
3
4
5
6
7
8
9
-- 继续用 t 表,主键 a 为唯一索引
-- 现有数据 a: 1, 3, 5
-- 事务 T1
BEGIN;
SELECT * FROM t WHERE a = 2 FOR UPDATE; -- a=2 不存在,加间隙锁 (1, 3)

-- 事务 T2 (另一会话)
BEGIN;
INSERT INTO t (a, b) VALUES (2, 20); -- 被阻塞

查看锁情况(可通过 performance_schema.data_locks 或 InnoDB 状态):

1
2
3
4
-- MySQL 8.0 推荐使用 data_locks 视图
SELECT engine_transaction_id, object_name, index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE object_name = 't';

你会看到 T1 在主键索引上持有一个 GAP 锁,lock_data 可能是 supremum pseudo-record 或相邻记录的值,阻止了在 (1,3) 间隙中的插入。

说明:唯一索引在等值查询且记录不存在时,Next-Key Lock 会退化为 Gap Lock,只锁间隙,不锁记录本身。这就是为什么唯一索引间隙锁范围较小,但依然存在。

排查工具与方法

1. 使用 performance_schema.data_locks 实时查看锁等待

MySQL 8.0 之后,performance_schema 的锁信息视图让排查变得简单。可以连表查出锁等待关系:

1
2
3
4
5
6
7
8
9
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
CONCAT('KILL ', b.trx_mysql_thread_id, ';') AS kill_command
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;

但注意 innodb_lock_waits 在 MySQL 8.0 已被 performance_schema.data_lock_waits 取代:

1
2
3
4
5
6
SELECT
requesting_trx_id,
blocking_trx_id,
blocking_pid,
sql_kill_blocking_query
FROM sys.innodb_lock_waits;

2. 解析 SHOW ENGINE INNODB STATUS 的死锁日志

死锁日志中详细列出了每个事务持有的锁和等待的锁,格式固定,重点看 RECORD LOCKS 部分,lock_mode 后的描述:

  • locks rec but not gap:纯记录锁
  • locks gap before rec:间隙锁
  • locks gap before rec insert intention:插入意向锁
  • 没有特别说明的 lock_mode X 通常是临键锁

3. 通过 innodb_print_all_deadlocks 输出到错误日志

my.cnf 中设置:

1
2
[mysqld]
innodb_print_all_deadlocks = ON

所有死锁信息都会输出到错误日志,方便回溯。

生产环境优化建议

  1. 降低隔离级别:如果业务可以接受,改用 READ COMMITTED,间隙锁就不再存在,避免了大部分死锁。但要注意 RC 下会出现幻读,需要用应用层乐观锁或唯一约束补偿。

  2. 优化 SQL 条件:尽量使用唯一索引进行精确查询,避免在二级索引的大范围扫描上加锁。例如 SELECT ... WHERE id = ? FOR UPDATE 只加行锁,不产生间隙锁。

  3. 统一加锁顺序:在事务中避免先操作不同索引的“间隙”,尽可能按相同顺序访问资源,减少环形等待。

  4. **拆分 INSERT ... SELECT**:先查出目标数据,再到应用层循环插入,但这会牺牲一定的性能和一致性。更优方案是改用 RC 隔离级别 + 唯一索引防重。

  5. 使用 SELECT ... FOR SHARE 替代 FOR UPDATE(谨慎):如果只是读取后续更新同一行,可以考虑 FOR SHARE 加共享锁,减少与插入意向锁的冲突,但要注意共享锁之间不阻塞写,可能造成更新丢失,需配合业务判断。

核心要点

  • 间隙锁(Gap Lock) 是 InnoDB 在 REPEATABLE READ 下防止幻读的机制,锁的是索引记录之间的区间。
  • 临键锁 = 记录锁 + 间隙锁,等值查询命中记录时退化为记录锁,否则退化为间隙锁。
  • 插入意向锁 不与记录锁冲突,但与间隙锁冲突,这是并发插入死锁的根本原因。
  • 排查死锁使用 SHOW ENGINE INNODB STATUSperformance_schema.data_locks,从死锁日志中找出互相等待的间隙。
  • 生产环境可通过降低隔离级别、优化 SQL、统一访问顺序来避免间隙锁导致的死锁。

本文由 Claude(Anthropic)辅助生成。代码示例已在 MySQL 8.0.35(Linux, x86_64)中验证通过。验证日期:2026-08-08。