MySQL 间隙锁死锁问题的原理、场景与完整解决方案
间隙锁死锁是 InnoDB 引擎在可重复读(RR)隔离级别下的高频死锁场景,它的成因和普通行锁死锁不同,核心根源是「间隙锁之间兼容,但间隙锁与插入意向锁互斥」的特性,极易在「先查后插」的并发场景下触发循环等待。
一、核心原理铺垫
1. 什么是间隙锁
间隙锁(Gap Lock)是 InnoDB 在 RR 隔离级别下,为了解决幻读问题引入的锁机制:
- 它锁的不是具体的数据行,而是索引之间的空闲间隙,防止其他事务往间隙中插入新数据。
- 触发条件:使用当前读(
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE)时,如果查询条件未命中具体行,或者是范围查询,就会对命中的间隙加锁。 - 关键特性:间隙锁与间隙锁之间是兼容的------多个事务可以同时持有同一个间隙的间隙锁,不会互相阻塞。
2. 为什么间隙锁会导致死锁
和间隙锁配对的还有「插入意向锁(Insert Intention Lock)」:事务执行 INSERT 时,会先向目标间隙申请插入意向锁。
核心互斥规则:间隙锁 ↔ 插入意向锁 互斥。
由此就形成了经典的死锁闭环:
- 事务 A 对间隙 G 加间隙锁(成功,无阻塞)
- 事务 B 对间隙 G 加间隙锁(成功,间隙锁之间兼容)
- 事务 A 往间隙 G 插入数据,申请插入意向锁 → 被事务 B 的间隙锁阻塞
- 事务 B 往间隙 G 插入数据,申请插入意向锁 → 被事务 A 的间隙锁阻塞
→ 循环等待,死锁触发。
二、最常见的 3 种间隙锁死锁场景
场景1:「不存在则插入」的去重逻辑(最高频)
这是业务开发中最容易踩的坑,典型代码逻辑:先通过 SELECT ... FOR UPDATE 判断记录是否存在,不存在则执行 INSERT。
示例(表 user 主键为 id,已有数据 id=1、id=5,间隙为 (1,5))
| 时间线 | 事务A | 事务B |
|---|---|---|
| T1 | SELECT * FROM user WHERE id=3 FOR UPDATE; id=3不存在,对间隙(1,5)加X型间隙锁 |
|
| T2 | SELECT * FROM user WHERE id=3 FOR UPDATE; 同样对间隙(1,5)加间隙锁,成功不阻塞 |
|
| T3 | INSERT INTO user(id,name) VALUES(3,'a'); 申请插入意向锁,被事务B的间隙锁阻塞,等待 |
|
| T4 | INSERT INTO user(id,name) VALUES(3,'b'); 申请插入意向锁,被事务A的间隙锁阻塞,等待 |
|
| T5 | 死锁触发,InnoDB自动回滚其中一个事务 |
场景2:范围更新 + 反向插入
两个事务对相反方向的范围加锁,随后互相向对方的锁范围内插入数据,形成交叉间隙锁等待。
例如:
- 事务A:
UPDATE goods SET stock=stock-1 WHERE id > 10,对 (10, +∞) 加间隙锁 - 事务B:
UPDATE goods SET stock=stock-1 WHERE id < 20,对 (-∞, 20) 加间隙锁 - 随后事务A插入id=15,事务B插入id=12,双方都需要对方间隙内的插入意向锁,形成死锁。
场景3:无索引/低区分度索引导致全表间隙锁
如果更新/查询条件不走索引,InnoDB 会进行全表扫描,对整张表的所有间隙都加上间隙锁,相当于锁了全表的插入能力,任何并发插入都极易触发死锁。
三、死锁排查方法
1. 查看实时死锁日志
执行以下命令查看最近一次死锁的详细信息,这是定位根因的核心手段:
sql
SHOW ENGINE INNODB STATUS;
重点关注 LATEST DETECTED DEADLOCK 段落:
- 查看两个事务分别持有的锁类型(
gap lock代表间隙锁,insert intention代表插入意向锁) - 查看锁的范围(索引区间),确认是否为同一个间隙
- 查看触发死锁的具体 SQL,对应到业务代码
2. 持久化死锁日志
开启全局配置,让所有死锁都记录到 MySQL 错误日志中,方便回溯排查:
sql
SET GLOBAL innodb_print_all_deadlocks = ON;
该配置线上可动态开启,无需重启,建议生产环境默认开启。
3. 辅助定位手段
- 结合业务日志,确认死锁发生时的并发接口、请求参数,还原并发执行路径。
- 检查对应 SQL 的执行计划,确认是否走索引、索引区分度如何。
四、分级解决方案(按推荐优先级排序)
方案1:代码层优化,从根源规避「先查后插」逻辑(最有效)
针对最高频的「不存在则插入」场景,用原子 UPSERT 语法替代「先查后插」,彻底打破「先加间隙锁、再申请插入意向锁」的死锁路径。
推荐写法:INSERT ... ON DUPLICATE KEY UPDATE
sql
-- 原逻辑:先SELECT FOR UPDATE判断,再INSERT
-- 优化后:原子插入,冲突则更新
INSERT INTO user(id, name, update_time)
VALUES (3, 'a', NOW())
ON DUPLICATE KEY UPDATE name = VALUES(name), update_time = NOW();
为什么能避免死锁 :
该语句是原子操作,直接尝试插入数据,若唯一键冲突则对已存在的行加记录锁并执行更新。不会出现「两个事务同时持有间隙锁」的阶段,第二个事务只会等待第一个事务的行锁,不会形成循环等待。
注意事项
- 必须依赖唯一索引/主键才能触发冲突更新逻辑。
- 不推荐使用
REPLACE INTO,它本质是「删除旧行+插入新行」,会导致主键自增跳跃、触发器异常执行等副作用。
方案2:统一加锁顺序,缩小锁范围
如果业务必须保留「先查后插」逻辑,按以下规则优化:
- 所有事务按相同顺序加锁:比如统一按 ID 从小到大的顺序执行更新/查询,避免反向范围加锁导致的交叉间隙重叠。
- 用精准等值查询替代范围查询:范围越大,间隙锁覆盖范围越广,冲突概率越高。能精准命中行的,就不要用范围条件。
- 缩小事务粒度:事务内只保留核心的数据库操作,减少锁的持有时间,降低冲突概率。
方案3:索引优化,避免全表间隙锁
- 确保所有当前读语句都走索引 :给
WHERE条件的字段建立合适的索引,无索引会导致全表扫描,全表间隙加锁,死锁概率指数级上升。 - 优先使用唯一索引:唯一索引的等值查询命中行时只会加记录锁,不会产生间隙锁;普通索引的间隙锁范围更大,冲突概率更高。
- 避免低区分度索引:区分度差的索引会导致单次查询锁定大量间隙,极易和其他事务重叠。
方案4:调整隔离级别为 RC(从根源消除间隙锁)
如果业务可以接受「不可重复读」,将隔离级别从 RR 下调为读已提交(RC),是性价比极高的方案:
- RC 级别下,InnoDB 会关闭间隙锁(仅外键和唯一键校验保留间隙锁),从根源上消除间隙锁死锁。
- RC 级别锁粒度更小,并发性能更高,是互联网企业的主流选型。
ini
# my.cnf 配置
transaction_isolation = READ-COMMITTED
适用前提:业务不需要严格的可重复读,绝大多数业务系统(电商、办公、后台系统)都满足。
方案5:应用层兜底:死锁自动重试
死锁无法 100% 杜绝,必须做兜底处理:
- 在业务代码中捕获 MySQL 死锁异常(错误码
1213),实现指数退避的自动重试机制(重试2~3次即可)。 - 因为 InnoDB 检测到死锁后会自动回滚代价更小的事务,重试后通常可以正常执行。
五、常见误区澄清
-
误区:唯一索引不会产生间隙锁
错误。唯一索引的等值查询,若未命中任何记录,依然会对命中的间隙加间隙锁;只有命中了具体的行,才只会加记录锁。
-
误区:间隙锁之间是互斥的
错误。间隙锁之间互相兼容,多个事务可以同时持有同一个间隙的锁;真正互斥的是间隙锁和插入意向锁。
-
误区:关闭间隙锁会导致严重幻读
RC 级别下,通过 MVCC 快照读,绝大多数业务场景感知不到幻读;只有当前读场景下可能出现幻读,业务影响极小,远低于频繁死锁带来的故障影响。
六、企业级最佳实践总结
- 优先选型:业务无强一致要求时,默认使用 RC 隔离级别,从根源减少间隙锁问题。
- 代码规范 :高并发去重插入场景,强制使用
INSERT ... ON DUPLICATE KEY UPDATE,禁止「SELECT FOR UPDATE + INSERT」的写法。 - 索引底线:所有 UPDATE、DELETE、FOR UPDATE 查询必须走索引,定期巡检慢SQL与全表扫描。
- 兜底保障:应用层统一封装死锁重试逻辑,同时开启死锁日志持久化,便于问题回溯。