资讯动态

深入理解InnoDB锁三角:记录锁、间隙锁与Next-Key Lock实战解析

发布时间:2026/10/20 13:46:45 来源:尧图企业网站定制
相信不少开发者第一次被数据库锁整破防都是在线上业务突然卡顿、监控面板冒出大量锁等待的时候。我印象最深的一次排障是在某订单系统的日志里看到大量lock_mode X locks gap before rec的等待记录牵出来的正是这几位主角Next-Key Lock、记录锁和间隙锁。这三个术语经常一起出现但又很少有人能把它们的关系一次讲透——所以这篇就来把这个锁三角拆开揉碎从原理、加锁范围、实操排查一直聊到避坑经验。如果屏幕前的你正在被间隙锁和临键锁到底有什么区别为什么唯一索引等值查询没间隙锁这类问题困扰或者想系统搞懂 InnoDB 在可重复读下的锁机制这篇文章就是为你准备的。1. 先建立全景认知三种锁到底是什么关系1.1 从一个真实的锁等待案例说起我先描述一下当时遇到的线上场景。事务 A 执行了一条UPDATE语句按非唯一索引更新一批数据迟迟不提交事务 B 也想更新同一范围内的数据结果卡在等待状态。我用SHOW ENGINE INNODB STATUS看了一眼锁信息里面出现了lock_mode X locks gap before rec和lock_mode X两种类型的锁。前者是间隙锁后者是记录锁两者组合在一起就是我们常说的 Next-Key Lock。先说结论记录锁锁住的是索引记录本身间隙锁锁住的是索引记录之间的空隙而 Next-Key Lock 是前两者的结合体——同时锁住一个间隙和这条间隙后面的记录。InnoDB 在可重复读隔离级别下默认使用 Next-Key Lock这就是它能防止幻读的核心武器。1.2 快照读与当前读的差别在深入锁之前必须把快照读和当前读的概念拎出来。因为很多人搞不懂为什么明明 MVCC 已经能隔离读写还要加这么多锁。快照读Snapshot Read走的是 MVCC 机制比如普通的SELECT语句它读取的是事务开始时的快照版本不需要加锁。而当前读Current Read读的是数据的最新版本比如SELECT ... FOR UPDATE、UPDATE、DELETE这些操作必须加锁否则并发更新会导致数据错乱。所以 MVCC 负责的是读读不冲突、读写不冲突而锁负责的是写写不冲突。前面提到的三种锁都服务于当前读操作。如果事务里全是普通SELECT根本不会跟记录锁、间隙锁发生正面冲突。1.3 用一个区间图理解锁的叠加关系假设一张表的主键索引里有 1、5、8 三条记录我们按主键范围(5, 8)加锁。用文字来表示record lock只锁 8 这条记录区间范围是[8, 8]。gap lock锁的是 5 和 8 之间的空隙区间范围是(5, 8)注意是开区间不包括 5 和 8 本身。next-key lock锁的是空隙加记录区间范围是(5, 8]左开右闭。如果把几条索引记录排成一列记录锁就是锁住某个点间隙锁就是锁住两个点之间的线段而临键锁就是线段 点的组合。这样理解 Next-Key Lock 和另外两种锁的关系就非常直观了——它是它们俩的父集。2. 记录锁最简单也最容易被忽视的锁2.1 记录锁的加锁原则记录锁Record Lock是最基础的锁类型作用对象是索引记录。注意InnoDB 的锁是加在索引上的而不是加在整行数据上的。哪怕表没有显式定义索引InnoDB 也会在后台创建一个聚簇索引来承载记录锁。加锁原则非常明确锁住一条具体的索引记录。这条记录可以是主键索引里的记录也可以是二级索引里的记录。如果命中唯一索引的等值查询InnoDB 会直接锁定这一条记录不会额外锁间隙。注意记录锁不等于行级锁。行级锁是从数据维度说的而记录锁是从索引维度说的。一条数据可能有多个索引每个索引上都可以有对应的记录锁。2.2 不同索引下记录锁的表现我用一个实际表结构来说明。假设有张users表CREATE TABLE users ( id INT PRIMARY KEY, user_no VARCHAR(20) UNIQUE, age INT, name VARCHAR(50), KEY idx_age (age) ) ENGINEInnoDB;然后插入几条数据INSERT INTO users VALUES (1, U001, 18, 张三), (2, U002, 22, 李四), (3, U003, 25, 王五), (4, U004, 30, 赵六), (5, U005, 35, 孙七);场景一等值查询命中主键。事务 A 执行BEGIN; UPDATE users SET name 张三改 WHERE id 3;此时 InnoDB 在主键索引 id3 的记录上加记录锁。事务 B 执行UPDATE users SET name 李四改 WHERE id 3会被阻塞但更新 id1 或者 id5 的记录完全不受影响。场景二等值查询命中唯一索引。BEGIN; UPDATE users SET name 王五改 WHERE user_no U003;此时 InnoDB 会先在user_no唯一索引上锁定 U003 对应的记录再回表到主键索引锁定 id3 的记录。两个索引记录都加了记录锁但都不会锁间隙。场景三等值查询命中普通索引idx_age。BEGIN; UPDATE users SET name 张三改 WHERE age 18;情况就不一样了。普通索引允许重复值等值查询可能命中多条记录虽然当前只有一条 age18。为了保证没有其他事务往 age18 这个位置插入新记录InnoDB 不仅会锁住 age18 这条索引记录还会在 age18 前后的间隙加间隙锁。也就是说当查询无法通过唯一性保证只锁一条时记录锁就会升级为临键锁。2.3 实操中几个容易踩的点第一记录锁只在索引存在时才有效。如果查询条件走了全表扫描InnoDB 会对聚簇索引里的所有记录加锁而不是只锁条件匹配的行。这个场景我是真遇到过某个UPDATE语句的WHERE字段没有索引数据量只有几万行结果一执行把整表更新路径上的记录全锁了并发写直接堵死。第二唯一索引等值查询有例外情况。当WHERE条件是唯一索引但记录不存在时InnoDB 不会什么都不锁而会在记录不存在的位置加一个间隙锁。比如WHERE user_no U006而 U006 不存在它会在 U005 后面的间隙加锁防止其他事务插入 U006。代码审计时重点检查该有索引却没索引的 SQL这是记录锁性能事故的第一大来源比事务不提交还常见。3. 间隙锁为了防幻读把锁的范围扩大了一点点3.1 间隙锁是怎么划定范围的间隙锁Gap Lock锁定的是一个范围但不包括范围内的记录本身。这个范围由索引记录的位置决定。还是用上面users表举例假设idx_age索引上的值是 18、22、25、30、35。如果事务 A 执行BEGIN; SELECT * FROM users WHERE age BETWEEN 20 AND 24 FOR UPDATE;InnoDB 会怎么做它首先在逻辑上需要锁住满足条件的所有记录。age22 满足条件但 age18 和 age25 不满足。为了防止间隙被插入新数据导致幻读InnoDB 会在 (18, 22]、(22, 25] 两个区间加锁。注意间隙锁的区间是开区间即(18, 22)这个范围里的值可以锁住但边界记录 18 和 22 本身不会由间隙锁来锁。正是因为这样后面的临键锁才有存在的必要把间隙锁和记录锁一组合边界记录也被锁住整个 (18, 22] 就没有任何插入和修改的余地了。3.2 间隙锁的业务影响面很多开发者以为间隙锁只影响插入操作实际上它的影响面更广。看这个例子事务 ABEGIN; SELECT * FROM users WHERE age 25 FOR UPDATE;此时 InnoDB 会对 age30、35 的记录加记录锁并对 (25, 30)、(30, 35)、(35, ∞) 这些间隙加间隙锁。那么事务 B 执行INSERT INTO users (id, user_no, age, name) VALUES (6, U006, 28, 周八);会被阻塞。这个大家能理解——28 落在了 (25, 30) 这个间隙里。但很多人想不到的是下面这条语句也会被阻塞UPDATE users SET name 李四改 WHERE age 22;为什么因为事务 A 锁住了 (25, 30) 等间隙而事务 B 要更新 age22 这条记录。它需要先判断 22 是否落在某个被锁的间隙里吗不是的——真正的原因在于事务 B 的 UPDATE 走idx_age索引时InnoDB 为了锁定 age22 这条索引记录也可能需要先对涉及的范围加临键锁。具体到 InnoDB 的实现更新一条记录时如果无法确认范围内没有其他未加锁的间隙锁的检测就会把间隙锁纳入冲突判断于是发生等待。这个场景比较微妙我用更直白的方式总结间隙锁锁定的是位置凡是往这个位置插入新值、或者修改其他记录让它移动到被锁间隙里的操作都会和间隙锁冲突。所以它影响的绝不仅仅是 INSERT。3.3 间隙锁升级与优化方向间隙锁太宽很容易引发并发性能问题。处理思路主要有几个方向。第一个方向是把隔离级别从 REPEATABLE READ 降到 READ COMMITTED。在 READ COMMITTED 级别下InnoDB 会禁用间隙锁外键检查和死锁检测相关场景例外只保留记录锁从而减少锁冲突。代价是可能产生幻读但很多业务场景其实并不在意幻读。第二个方向是优化 SQL 走唯一索引。唯一索引等值命中时不会产生间隙锁因此能查 WHERE 条件的字段尽量让条件落在唯一性约束上。第三个方向是控制事务粒度。间隙锁的生命周期跟着事务走事务不提交锁就一直不释放。一个常见的生产事故长这样事务 A 大范围加锁后执行了外部接口调用耗时三秒这期间所有插入和更新全被堵住了。所以锁相关的代码务必秉持快进快出原则——提前算好要更新的行数缩短持有锁的时间。4. Next-Key Lock临键锁的加锁粒度与退化条件4.1 临键锁的加锁范围示例Next-Key Lock临键锁在技术上就是记录锁 间隙锁。它的锁定范围是一个左开右闭区间前半段是间隙后半段是记录。比如(18, 22]的含义是不包含 18包含 22同时也包含 18 和 22 之间的所有值。用实际 SQL 演示。假设要执行BEGIN; SELECT * FROM users WHERE age 22 FOR UPDATE;由于 age 是普通索引InnoDB 无法确定只有一条 age22 的记录实际上表里只有一条但机制上不能这样假设所以它会加临键锁范围是(18, 22]。这样做本身就是为了锁住位置防止幻读间隙锁覆盖 18 到 22 之间的空隙防止插入 age19、20、21 的新记录。记录锁锁住 age22 这条记录防止其他事务修改它。当一个事务持有(18, 22]的临键锁时另一个事务想插入 age20 的记录会被间隙锁挡住想更新 age22 的记录会被记录锁挡住。两路都封死这就是临键锁防幻读的完整逻辑。4.2 隔离级别对临键锁的影响事务隔离级别是临键锁的开关。MVCC 在读已提交和可重复读下都能做快照读但当前读的加锁策略完全不同。可重复读下 InnoDB 默认加临键锁读已提交下退化为记录锁。这个差别是从机制上设计出来的——只有临键锁才能在当前读这条路径上把幻读堵死。如果你在 READ COMMITTED 下跑同样的 SQLBEGIN; SELECT * FROM users WHERE age 22 FOR UPDATE;InnoDB 只会在 age22 这条记录上加记录锁不会锁相邻间隙。好处是并发插入其他 age 值不会被阻塞坏处是同一个事务里再执行一次相同条件查询可能读出新插入的记录——也就是幻读。生产环境选择隔离级别时不要只看哪个更安全要看业务能不能容忍幻读。能容忍就上 READ COMMITTED 换并发不能容忍老老实实待在 REPEATABLE READ并接受临键锁带来的锁竞争。4.3 临键锁退化为记录锁的条件有几个典型场景临键锁会退化成记录锁锁粒度变窄性能更好。场景一唯一索引等值查询且记录存在。前面已经提过唯一索引等值命中时唯一性已经保证了不可能有第二条相同值的记录所以不需要锁间隙只要锁住该记录即可。WHERE user_no U003就是这种。场景二主键等值查询且记录存在。主键本身就是唯一索引同理。场景三唯一索引等值查询时碰到唯一性冲突。比如事务 A 想插入 user_noU003但这条记录已经存在。此时它会尝试对唯一索引加共享锁如果不冲突只锁住这条记录。场景四unique 索引等值匹配但记录不存在的特殊情况。这里注意——记录不存在时临键锁会退化成间隙锁而不是退化成什么都不锁。比如WHERE user_no U999且 U999 不存在InnoDB 会在 U005 之后的间隙上加一个间隙锁锁定区间是(U005, ∞)防止其他事务插入 U999。所以不存在并不代表不锁。4.4 组合索引下的临键锁组合索引和单列索引在临键锁上最大的不同是锁的范围由多个列的值共同决定。举个常见例子表里有联合唯一索引(a, b)如果查询条件是WHERE a 1 AND b 2且记录存在InnoDB 能根据这个唯一索引精确定位到一条记录加记录锁即可。但如果查询条件是WHERE a 1只用到了最左前缀无法保证唯一性就会退化为临键锁锁定a1对应的索引区间。如果查询条件完全没用到索引那就是另一回事了——全表扫描会对聚簇索引里每一条记录尝试加临键锁整体锁范围大幅膨胀。这种 SQL 在并发稍高的系统里基本是事故元凶。5. 锁问题的排查与验证实操5.1 用系统表看锁信息排查锁问题第一步是学会看 InnoDB 的锁视图。MySQL 5.7 开始information_schema下有几张直接关联锁的表比如INNODB_TRX、INNODB_LOCK_WAITS但 5.7 里真正展示加锁明细的是performance_schema.data_locks和data_lock_waits需开启相关采集项。常用的排查 SQL 大概是这样的-- 查看当前事务 SELECT * FROM information_schema.INNODB_TRX\G -- 查看锁等待关系 SELECT * FROM performance_schema.data_lock_waits\Gdata_locks表里有几个关键字段值得注意LOCK_TYPE对应 RECORD 或 TABLELOCK_MODE对应 X、S、X,GAP、X,REC_NOT_GAP 等LOCK_DATA表示锁住的索引值。X, REC_NOT_GAPX 锁类型是记录锁。X, GAPX 锁类型是间隙锁。XX 锁类型是临键锁默认模式。S, REC_NOT_GAPS 锁类型是记录锁。我排查锁等待时习惯先查INNODB_TRX看看哪些事务是 RUNNING 状态但很久没提交再查data_lock_waits找等待链条最后用SHOW ENGINE INNODB STATUS看最新的锁等待和死锁日志。5.2 用 SHOW ENGINE INNODB STATUS 读死锁日志死锁日志在SHOW ENGINE INNODB STATUS输出的LATEST DETECTED DEADLOCK部分。看日志重点不是读那一大堆十六进制而是找两部分事务一持有哪些锁、等待哪些锁事务二持有哪些锁、等待哪些锁。一个典型的死锁日志长这样简化版*** (1) TRANSACTION: TRANSACTION 3001, ACTIVE 10 sec LOCK WAIT lock struct (s), lock size: n MySQL thread id: 100 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 10 page no 4 n bits 72 index PRIMARY of table db.users lock_mode X locks gap before rec insert intention waiting *** (2) TRANSACTION: TRANSACTION 3002, ACTIVE 8 sec LOCK WAIT *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 10 page no 4 n bits 72 index PRIMARY of table db.users lock_mode X locks gap before rec insert intention *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 10 page no 4 n bits 72 index PRIMARY of table db.users lock_mode X locks gap before rec insert intention如果看到两个事务都在等待insert intention锁插入意向锁典型情况就是两个事务各自持有不同间隙的间隙锁又都试图往对方持有的间隙里插入新记录间隙锁之间不互斥但插入意向锁与间隙锁互斥于是互相等待形成死锁。这类死锁最常见的解法是规范插入顺序、缩小加锁范围或让相关表启用更宽松的隔离级别。5.3 锁超时参数与监控建议锁等待超时由innodb_lock_wait_timeout控制默认 50 秒。 这个值需要结合实际业务设定——设太小正常的锁竞争也被中断设太大故障时系统会长时间卡住。调优方面我一般这样处理先把innodb_lock_wait_timeout调到 5 到 10 秒快速暴露锁竞争问题。用SHOW ENGINE INNODB STATUS和performance_schema.data_lock_waits观察等待来源。定位到问题 SQL 后优先从 SQL 本身、索引和事务粒度入手解决参数只是兜底。开启innodb_status_output_locks参数可以输出更完整的锁信息但注意生产环境不要长期开启否则错误日志会刷得飞起。建议排查期间临时打开排查完关掉。6. 常见问题速查与避坑清单6.1 高频问题速查表问题现象可能原因处理思路UPDATE 等值查询走了普通索引另一事务插入相同索引值被阻塞普通索引等值命中时加了临键锁锁住间隙改为唯一索引或降低隔离级别SELECT ... FOR UPDATE 查询不到记录但 INSERT 仍被阻塞记录不存在时加了间隙锁锁定空隙位置检查查询条件是否命中索引避免全表扫描两个事务互相持锁等待死锁日志中出现 insert intention间隙锁与插入意向锁冲突规范插入顺序缩小加锁范围统一事务内 SQL 执行顺序锁等待超时频繁业务响应变慢大事务长时间不提交间隙锁范围过大缩小事务粒度避免事务中混入外部接口调用同一 SQL 在 READ COMMITTED 下正常在 REPEATABLE READ 下阻塞隔离级别导致的临键锁差异评估幻读容忍度合理选隔离级别死锁日志中频繁出现主键间隙区插入冲突范围插入无固定顺序多个事务互相覆盖间隙实现层保证插入顺序一致或用哈希分片减少互斥区间这张表是我在排障时最常用到的思路入口很多时候照着表反查 SQL 和隔离级别问题就明朗了一大半。6.2 我在排障中积累的避坑经验经验一REPEATABLE READ 下一切范围查询都要先估算锁范围。线上数据量一大UPDATE ... WHERE create_time ?这类 SQL 扫出来的锁范围往往是灾难级的。我处理过的一个案例就是后台任务用时间范围更新订单状态每次锁住上万条索引记录对应的间隙直接拖垮了同一时段的前端下单写入。后来的修复方案是改成按主键分批更新每批几百条锁粒度立刻降下来。经验二死锁不一定代表代码有问题但频繁死锁一定代表设计有问题。偶尔一两次死锁可以通过应用层重试消化掉但频繁死锁说明事务之间有长期的锁竞争结构。此时优先检查是否可以通过统一加锁顺序解决比如所有事务都先锁主键较小的记录再锁较大的记录。经验三用 EXPLAIN 预判锁范围非常靠谱。执行计划里type为eq_ref或const大概率走唯一索引等值临键锁会退化成记录锁type为range或index通常锁范围较大需要评估是否会被间隙锁牵连。经验四插入意向锁INSERT INTENTION是间隙锁的克星。多个事务的间隙锁是可以共存的但插入意向锁和间隙锁冲突。也就是说两个事务可以同时锁同一个间隙但谁都不能往里插数据。理解这一点很多死锁日志就能看懂了。写在最后的个人体会这三把锁看名字各管一摊实际上是一条线串起来的InnoDB 为了把当前读下的幻读堵死设计了锁记录 锁间隙的机制记录锁锁点间隙锁锁段临键锁就是它们的缝合体。理解这个底层逻辑之后再复杂的锁等待问题本质上都是在问一句——当前 SQL 到底锁住了哪些点和哪些段。我自己排查锁问题的顺序也早就固定了先看隔离级别再看执行计划最后看锁等待日志。绝大多数锁事故追到根源就是两条要么 SQL 没用上该用的索引导致锁范围失控要么事务拖太长把锁的生命周期人为拉长了。把这两个口子堵住锁相关问题至少能解决八成。最后再分享一个实操小技巧排查锁问题时别光盯着INNODB_TRX里的 SQL 文本很多时候问题不在当前语句而在于这个事务前面已经执行过多少条语句、持有了多少把锁。事务是一个整体加锁效果是累积的——只看最后一条语句容易漏掉真正的元凶。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑