摘要:间隙锁(Gap Lock)并非故障,而是REPEATABLE READ下InnoDB为压制幻读付出的结构性代价。本文以“锁膨胀链”为主线,从记录锁到Next-Key Lock再到插入意向锁冲突,逐层拆解Gap Lock的触发条件,归纳出七类易被忽视的SQL写法,并结合线上死锁日志、版本演进与治理清单,给出可执行的诊断路径与改写策略。
目录
一、背景与问题定义:幻读、锁与“头号元凶”的由来
在MySQL运维语境里,“间隙锁”长期被贴上高危标签。要判断这个标签是否成立,先要把三个概念钉死。幻读(Phantom Read)指同一事务内两次执行相同范围查询,第二次返回了第一次不存在的行;Gap Lock是InnoDB对索引记录之间“空隙”加的锁,锁的是区间而非具体记录;Next-Key Lock则是记录锁与其左侧间隙锁的组合,形式为左开右闭区间。三者的关系是:Gap Lock是手段,Next-Key Lock是默认形态,抑制幻读是目的。
问题的根源在于目的与手段的不匹配。InnoDB没有采用MVCC式的“一致性读快照”来彻底解决当前读下的幻读,而是在索引上加区间锁,用阻塞写来换取可串行化的语义。本文评述:这一设计在单机、低并发年代是合理折中,但在高并发短事务场景下,区间锁天然具有“锁范围大于业务意图”的放大效应——业务只想锁一行,引擎却锁了一段。这种放大,正是死锁概率被抬高的结构性原因。
需要强调边界条件:Gap Lock只在REPEATABLE READ及以上隔离级别、且执行当前读(SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE)时出现;普通快照读不加锁。RC隔离级别下,InnoDB关闭了Gap Lock(外键约束与唯一性检查除外)。因此“间隙锁导致死锁”这一命题,必须限定在RR与当前读的交集内讨论,脱离这个前提的泛化归因并不严谨。
定义先行:所谓“锁膨胀链”,指一条SQL从等值条件出发,因索引选择、范围条件、唯一性缺失、插入意向锁介入等因素,使实际加锁集合逐级扩大,最终跨越多个事务的持锁边界并形成环路的链式过程。本文以这条链作为贯穿全文的分析主线。
二、原理拆解:从记录锁到Next-Key Lock的锁膨胀链
2.1 索引组织表决定了锁的物理形态
InnoDB是索引组织表,聚簇索引的叶子节点即数据行,二级索引叶子节点存主键值。加锁发生在索引记录上,因此“锁哪一段”完全取决于优化器选择了哪个索引、扫描了哪些记录。同一张表、同一条WHERE条件,走主键与走二级索引,锁集合可能截然不同。笔者认为,很多“莫名其妙的间隙锁”本质上是执行计划问题,而非隔离级别问题——索引选错,锁范围就被放大。
以一张典型订单表为例,主键为自增id,业务键order_no上有唯一索引,status上有普通索引。当执行UPDATE orders SET status=2 WHERE status=1 AND id > 100时,若优化器选择status索引,则需回表并可能对主键区间加锁;若选择主键范围扫描,锁形态又不同。锁膨胀链的第一环,就发生在执行计划确定的那一刻。
2.2 Next-Key Lock的区间语义与“左开右闭”
设索引上有记录值 5、10、15。Next-Key Lock的加锁区间可表示为 (-∞,5]、(5,10]、(10,15]、(15,+∞)。注意区间是左开右闭:锁住10这条记录时,同时锁住(5,10)这段间隙。这意味着一个等值查询命中10,实际持锁范围覆盖了它左侧的整段空隙。本文评述:这个“顺手多锁一段”的语义,是Gap Lock被低估的根源——开发者看到的是“锁了一行”,引擎执行的是“锁了一段”。
-- 会话A(RR)
BEGIN;
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 实际加锁:Next-Key Lock on (5,10],即记录10 + 间隙(5,10)
-- 会话B
INSERT INTO t(id) VALUES (7); -- 阻塞:落入间隙(5,10)
INSERT INTO t(id) VALUES (10); -- 阻塞:记录本身被锁
INSERT INTO t(id) VALUES (12); -- 不阻塞:12在(10,15)间隙,未被锁
上例直观展示了锁膨胀链的第二环:等值命中唯一索引时,InnoDB会退化为记录锁(Record Lock),不再加间隙;但若命中非唯一索引或未命中任何记录,则退化为间隙锁或Next-Key Lock。是否“唯一”,是锁范围收缩的关键开关。
2.3 插入意向锁:死锁环路的催化剂
插入意向锁(Insert Intention Lock)是一种特殊的间隙锁,表示“准备在某间隙插入”。它本身不互斥,但与已持有的Gap Lock互斥。死锁的经典形态由此产生:事务A持有间隙(5,10)的Gap Lock,事务B持有间隙(10,15)的Gap Lock,随后A想插入12、B想插入7,双方都在等待对方释放间隙,环路形成。本文评述:Gap Lock的互斥对象不是“行”,而是“意图”,这使得冲突检测发生在插入动作之前,锁等待链更难被业务代码直觉感知。
关于InnoDB锁机制的权威说明,可参考MySQL官方文档“InnoDB Locking”章节(dev.mysql.com/doc/refman/8.0/en/innodb-locking.html),以及Percona博客中关于Next-Key Lock的系列分析(percona.com/blog),后者对锁等待链的图解较为直观,适合作为入门补充。
三、七类意外触发Gap Lock的SQL写法
以下七类写法按“意外程度”排序,均限定在RR + 当前读语境。每一类都对应锁膨胀链上的一个放大节点,并给出可验证的改写方向。
3.1 非唯一索引上的等值当前读
这是最容易被忽视的一类。开发者直觉认为“WHERE order_no = 'X' FOR UPDATE”只锁一行,但若order_no只是普通索引而非唯一索引,InnoDB无法证明唯一性,会加Next-Key Lock,锁住该值与相邻值之间的间隙。改写方向:为业务键建立唯一索引,或在应用层保证唯一后改用主键定位。本文评述:唯一索引在这里不只是约束,更是锁范围收缩器,其性能价值常被低估。
3.2 未命中记录的“空锁”
当WHERE条件在索引上找不到匹配记录时,InnoDB会加Gap Lock锁住“该记录本应所在”的间隙。典型场景是“先查后插”的幂等逻辑:事务A查id=100不存在并加锁,事务B插入id=100时被阻塞。若两个事务以不同顺序探测不同键,极易成环。改写方向:用INSERT ... ON DUPLICATE KEY UPDATE或先插入再处理冲突,把“探测+插入”合并为单条原子语句。
3.3 范围条件中的隐式等值退化
形如WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'的当前读,会对整个时间区间加Next-Key Lock。若该区间跨越大量记录,锁集合随之膨胀。更隐蔽的是,当优化器把范围条件退化为对索引前缀的等值扫描时,锁形态会再次变化。改写方向:尽量缩小范围、配合LIMIT分批、或在业务上接受RC。
3.4 索引失效导致的聚簇索引全扫
当WHERE条件对索引列施加函数、隐式类型转换或前导通配LIKE时,优化器可能放弃二级索引,转为聚簇索引扫描。此时加锁范围从“某段二级索引”变为“主键区间”,膨胀幅度最大。这类问题在慢查询日志中往往表现为“锁等待超时”而非“慢SQL”,排查时需结合performance_schema.data_locks观察实际锁集合。
3.5 多语句事务中的加锁顺序不一致
同一事务内先锁A表再锁B表,另一事务反向操作,是死锁的经典成因。Gap Lock的特殊性在于,它把“顺序”问题从行级扩展到区间级:即便两个事务操作的是不同主键,只要区间重叠,仍可能互斥。改写方向:统一加锁顺序、缩短事务、避免在事务内做远程调用。
3.6 批量INSERT与间隙竞争
批量插入时,每条INSERT都要申请插入意向锁。若目标间隙已被其他事务的Gap Lock占据,整批插入会排队等待。高并发下,多个批量插入事务在不同间隙上交叉持锁,形成环路。改写方向:按主键有序插入、控制单批大小、避免在长事务中混合插入与更新。
3.7 唯一索引下的“范围化”等值
即便有唯一索引,若查询条件写成WHERE order_no IN ('A','B') FOR UPDATE,InnoDB会对每个值分别加锁,且IN列表在优化器内部可能被展开为范围扫描,锁形态介于记录锁与Next-Key Lock之间。改写方向:拆分为单值查询、或确认执行计划为等值访问。
performance_schema.data_locks与data_lock_waits(MySQL 8.0+),在事务未提交时查询锁视图,比猜测执行计划可靠得多。相关用法可参考官方文档“Performance Schema Lock Tables”(链接)。
四、线上死锁案例复盘与数据观察
4.1 案例一:幂等探测引发的插入死锁
某支付回调服务采用“先SELECT ... FOR UPDATE探测订单号是否存在,不存在则INSERT”的幂等逻辑。订单号上有唯一索引。两个并发回调分别探测order_no='A'与'B',两者在索引上相邻但均未命中,各自持有相邻间隙的Gap Lock;随后各自INSERT对方探测过的键,形成环路。死锁日志中可见两个事务均处于“insert intention waiting”状态。本文评述:这是“空锁”与“插入意向锁”叠加的教科书式案例,根因不在并发量,而在“探测-插入”这一非原子模式。
4.2 案例二:批量更新与范围扫描的交叉
某对账任务按状态字段分批更新,SQL为UPDATE bills SET status=2 WHERE status=1 AND id BETWEEN ? AND ?。status上有普通索引,两个对账实例处理相邻区间,因索引扫描顺序与主键顺序不一致,锁集合在二级索引与聚簇索引之间交叉,触发死锁。改写方向:改用主键范围驱动、强制走主键索引、或把大事务拆为小批并固定顺序。
4.3 数据观察与来源说明
关于Gap Lock相关死锁在线上死锁中的占比,公开的权威统计较少。Percona与阿里云数据库团队的技术博客中多次提到,RR隔离级别下的死锁多数与范围加锁、唯一性缺失、加锁顺序不一致相关,但具体比例因业务形态差异极大,不宜给出单一数字。本文评述:任何声称“间隙锁占死锁X%”的绝对化数据,若未说明样本来源与业务特征,都应谨慎对待。本文引用的案例为脱敏后的工程复盘,非统计抽样,仅用于说明机制,不作为比例依据。
若需系统性了解死锁分析方法,可参考MySQL官方“Deadlocks in InnoDB”文档(链接)以及《高性能MySQL》第4版中关于锁与事务的章节。视频类资料可参考Percona的线上分享(percona.com/webinars),其中对锁等待链的可视化讲解较为实用。
五、对比与局限:RC、唯一索引与版本演进
5.1 RR与RC的锁语义对比
RC隔离级别下,InnoDB对普通索引的当前读只加记录锁,不加Gap Lock,因此幻读在理论上仍可能发生,但死锁概率显著下降。这是许多互联网业务将默认隔离级别改为RC的主要动因。本文评述:RC不是“更正确”,而是“用可接受的幻读风险换取更低的锁冲突”,是否切换取决于业务对幻读的敏感度,不能一概而论。
5.2 唯一索引与主键的“锁收缩”效应
唯一索引等值命中时,InnoDB可将Next-Key Lock退化为Record Lock,这是锁膨胀链上唯一“反向收缩”的机制。笔者认为,把业务唯一键建成唯一索引,是性价比最高的死锁治理手段之一:它同时提供数据约束与锁范围收缩,且不改变业务语义。局限在于,唯一索引无法覆盖范围查询与未命中场景,对3.2、3.3两类写法无效。
5.3 版本演进与前沿观察
MySQL 8.0在锁观测能力上有明显增强,performance_schema的data_locks视图让“实际锁集合”从黑盒变为可查。社区层面,关于“RC成为默认隔离级别”的讨论持续多年,但官方至今未改变默认值,说明RR的语义承诺仍有其价值。本文评述:未来更值得关注的方向不是“取消Gap Lock”,而是“让锁范围可预测”——包括优化器对锁范围的估算、以及应用层对事务边界的显式管理。分布式数据库如TiDB采用乐观事务与MVCC,从架构上回避了Gap Lock问题,但其冲突检测机制(如Percolator模型)同样有代价,不能简单类比为“更优”。
六、工程治理路径:诊断、改写与监控清单
6.1 诊断步骤
第一步,开启死锁日志(innodb_print_all_deadlocks=ON),确保每次死锁都落盘;第二步,从日志中提取两个事务的SQL、持锁与等待锁信息;第三步,用EXPLAIN确认执行计划与索引选择;第四步,在测试环境复现,查询data_locks视图核对实际锁集合;第五步,定位锁膨胀链上的放大节点,判断是索引问题、写法问题还是事务边界问题。
-- 查看当前锁与等待关系(MySQL 8.0+)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
-- 确认死锁日志开关
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
SHOW ENGINE INNODB STATUS\G -- 最近一次死锁详情
6.2 改写清单
优先做“锁收缩”:为业务键建唯一索引、用主键定位、把IN拆为单值。其次做“原子化”:用INSERT ... ON DUPLICATE KEY UPDATE替代“探测-插入”,用单条UPDATE替代“查-改-写”。再次做“边界控制”:缩短事务、统一加锁顺序、避免事务内远程调用。最后才考虑“隔离级别降级”这一全局性手段,且需评估幻读风险。
6.3 监控与预防
建议在监控体系中加入三项指标:锁等待超时次数、死锁次数、长事务数量。对高频当前读SQL建立“锁画像”,记录其执行计划与锁集合,纳入变更评审。本文评述:Gap Lock治理的本质不是消灭间隙锁,而是让每条SQL的锁范围可解释、可预测、可回归。做不到这三点,任何“优化”都只是把问题推迟到下一次大促。
七、结论与展望
Gap Lock之所以被视为“线上死锁头号元凶”,并非因为它本身有缺陷,而是因为它的锁范围超出了业务代码的直觉预期,且触发条件隐蔽。本文以“锁膨胀链”为主线,把触发路径拆为执行计划、索引唯一性、范围语义、插入意向锁四个放大节点,并归纳出七类易被忽视的SQL写法。结论是:绝大多数Gap Lock死锁可以通过索引设计与SQL改写消除,隔离级别降级应是最后手段而非首选。
展望未来,随着MySQL 8.0锁观测能力的完善与云原生数据库的普及,“锁范围可预测”将逐步从经验判断走向工具化。笔者认为,工程团队应把锁分析纳入日常SQL评审,而不是等到死锁告警才被动排查。间隙锁不是敌人,对它的无知才是。
主要参考文献
- MySQL 8.0 Reference Manual, “InnoDB Locking”, Oracle, 2024. 链接
- MySQL 8.0 Reference Manual, “Deadlocks in InnoDB”, Oracle, 2024. 链接
- MySQL 8.0 Reference Manual, “Performance Schema Lock Tables”, Oracle, 2024. 链接
- Baron Schwartz et al., High Performance MySQL, 4th Edition, O'Reilly, 2021.
- Percona Blog, “InnoDB Locking Explained”, 2023. 链接
- 阿里云数据库团队,《MySQL锁机制与死锁分析实践》,2023。
- TiDB Documentation, “Transaction Isolation and Conflict Detection”, PingCAP, 2024. 链接
- Percona Webinars, “Understanding InnoDB Deadlocks”, 2024. 链接

