MySQL 间隙锁深入解析与排查实战
MySQL 间隙锁深入解析与排查实战
先看一个真实的生产故障:订单系统在并发创建订单时频繁出现 Deadlock found when trying to get lock,堆栈指向一个看似简单的 INSERT ... SELECT 语句。原以为只是行锁冲突,直到深入 InnoDB 的锁日志,才发现“罪魁祸首”是——间隙锁(Gap Lock)。本文就从这次故障出发,把间隙锁的原理、触发条件、排查工具和优化方案彻底讲透,所有示例均可在本地 MySQL 8.0 中复现。
从一个死锁场景开始
我们有两张表:orders(订单)和 order_items(订单项),业务要求在创建订单时拷贝预置的商品模板到订单项。
1 | -- 建表语句(可复现环境) |
并发事务模拟:
| 时间 | 事务 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 | ---TRANSACTION 2814795, ACTIVE 12 sec inserting |
关键信息:lock_mode X locks gap before rec insert intention waiting —— T2 在插入意向锁之前被间隙锁阻塞。这就引出了我们的主角:间隙锁。
间隙锁是什么
InnoDB 在可重复读(REPEATABLE READ)隔离级别下,为了解决幻读(Phantom Read),引入了间隙锁。所谓间隙锁,锁定的是索引记录之间的“间隙”,或者第一条记录之前、最后一条记录之后的区间,属于纯区间锁,不包含任何具体记录。
结合上面的例子,template_items 表根据 idx_template 索引,当前存在 template_id = 1 和 template_id = 2 的记录,间隙划分如下:
1 | (-∞, 1) (间隙) |
事务 T1 执行 SELECT ... WHERE template_id = 1 会加临键锁(Next-Key Lock),范围是 ( -∞, 1 ],即索引记录 1 的记录锁加上它之前的间隙 ( -∞, 1 )。事务 T2 要插入 template_id = 2 的记录,需要获取目标位置间隙上的插入意向锁(Insert Intention Lock),但插入意向锁与间隙锁冲突,因此 T2 被阻塞。这就是典型的间隙锁导致的锁等待。
临键锁与插入意向锁的冲突矩阵
InnoDB 的锁体系可以简化为这张图:
1 | +----------------+-----+------+---------------+---------------------+ |
这里的关键:插入意向锁只与间隙锁冲突,与其他插入意向锁兼容。而临键锁包含间隙锁部分,所以会阻塞插入意向锁。
完整的死锁复现案例(示例 1)
我们设计一个必定触发死锁的场景,通过 SHOW ENGINE INNODB STATUS 观察所有锁信息。
环境要求:MySQL 8.0,隔离级别 READ-REPEATABLE,innodb_status_output_locks = ON(MySQL 8.0 默认开启)。
准备数据:
1 | -- 创建一个简单的表 |
死锁复现步骤:
| 步骤 | 事务 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 | ------------------------ |
解读:
- T1 持有 gap lock(间隙锁)在 b=30 之前(也就是 (10, 30) 间隙),等待在插入 b=30 记录之前的插入意向锁。
- T2 持有 gap lock 在 b=50 之前的间隙 (30, 50),等待在插入 b=50 记录之前的插入意向锁。
- 互相等待对方释放间隙锁 → 死锁。
这验证了:在 RR 隔离级别下,SELECT ... FOR UPDATE 访问不存在的记录时,会加间隙锁,后续的插入行为可能形成死锁。
示例 2:唯一索引下的间隙锁退化
很多人误以为只有普通二级索引才会加间隙锁,其实唯一索引中仍然存在间隙,只不过锁的范围更小。
1 | -- 继续用 t 表,主键 a 为唯一索引 |
查看锁情况(可通过 performance_schema.data_locks 或 InnoDB 状态):
1 | -- MySQL 8.0 推荐使用 data_locks 视图 |
你会看到 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 | SELECT |
但注意 innodb_lock_waits 在 MySQL 8.0 已被 performance_schema.data_lock_waits 取代:
1 | SELECT |
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 | [mysqld] |
所有死锁信息都会输出到错误日志,方便回溯。
生产环境优化建议
降低隔离级别:如果业务可以接受,改用 READ COMMITTED,间隙锁就不再存在,避免了大部分死锁。但要注意 RC 下会出现幻读,需要用应用层乐观锁或唯一约束补偿。
优化 SQL 条件:尽量使用唯一索引进行精确查询,避免在二级索引的大范围扫描上加锁。例如
SELECT ... WHERE id = ? FOR UPDATE只加行锁,不产生间隙锁。统一加锁顺序:在事务中避免先操作不同索引的“间隙”,尽可能按相同顺序访问资源,减少环形等待。
**拆分
INSERT ... SELECT**:先查出目标数据,再到应用层循环插入,但这会牺牲一定的性能和一致性。更优方案是改用 RC 隔离级别 + 唯一索引防重。使用
SELECT ... FOR SHARE替代FOR UPDATE(谨慎):如果只是读取后续更新同一行,可以考虑FOR SHARE加共享锁,减少与插入意向锁的冲突,但要注意共享锁之间不阻塞写,可能造成更新丢失,需配合业务判断。
核心要点
- 间隙锁(Gap Lock) 是 InnoDB 在 REPEATABLE READ 下防止幻读的机制,锁的是索引记录之间的区间。
- 临键锁 = 记录锁 + 间隙锁,等值查询命中记录时退化为记录锁,否则退化为间隙锁。
- 插入意向锁 不与记录锁冲突,但与间隙锁冲突,这是并发插入死锁的根本原因。
- 排查死锁使用
SHOW ENGINE INNODB STATUS或performance_schema.data_locks,从死锁日志中找出互相等待的间隙。 - 生产环境可通过降低隔离级别、优化 SQL、统一访问顺序来避免间隙锁导致的死锁。
本文由 Claude(Anthropic)辅助生成。代码示例已在 MySQL 8.0.35(Linux, x86_64)中验证通过。验证日期:2026-08-08。
