相信不少开发者第一次被数据库锁"整破防",都是在线上业务突然卡顿、监控面板冒出大量锁等待的时候。我印象最深的一次排障,是在某订单系统的日志里看到大量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) ) ENGINE=InnoDB;然后插入几条数据:
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 在主键索引 id=3 的记录上加记录锁。事务 B 执行UPDATE users SET name = '李四改' WHERE id = 3会被阻塞,但更新 id=1 或者 id=5 的记录完全不受影响。
场景二:等值查询命中唯一索引。
BEGIN; UPDATE users SET name = '王五改' WHERE user_no = 'U003';此时 InnoDB 会先在user_no唯一索引上锁定 U003 对应的记录,再回表到主键索引锁定 id=3 的记录。两个索引记录都加了记录锁,但都不会锁间隙。
场景三:等值查询命中普通索引idx_age。
BEGIN; UPDATE users SET name = '张三改' WHERE age = 18;情况就不一样了。普通索引允许重复值,等值查询可能命中多条记录(虽然当前只有一条 age=18)。为了保证没有其他事务往 age=18 这个"位置"插入新记录,InnoDB 不仅会锁住 age=18 这条索引记录,还会在 age=18 前后的间隙加间隙锁。也就是说,当查询无法通过唯一性保证"只锁一条"时,记录锁就会升级为临键锁。
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 会怎么做?它首先在逻辑上需要锁住满足条件的所有记录。age=22 满足条件,但 age=18 和 age=25 不满足。为了防止间隙被插入新数据导致幻读,InnoDB 会在 (18, 22]、(22, 25] 两个区间加锁。
注意,间隙锁的区间是开区间,即(18, 22)这个范围里的值可以锁住,但边界记录 18 和 22 本身不会由间隙锁来锁。正是因为这样,后面的临键锁才有存在的必要:把间隙锁和记录锁一组合,边界记录也被锁住,整个 (18, 22] 就没有任何插入和修改的余地了。
3.2 间隙锁的业务影响面
很多开发者以为间隙锁只影响"插入操作",实际上它的影响面更广。看这个例子:
事务 A:
BEGIN; SELECT * FROM users WHERE age > 25 FOR UPDATE;此时 InnoDB 会对 age=30、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 要更新 age=22 这条记录。它需要先判断 22 是否落在某个被锁的间隙里吗?不是的——真正的原因在于,事务 B 的 UPDATE 走idx_age索引时,InnoDB 为了锁定 age=22 这条索引记录,也可能需要先对涉及的范围加临键锁。具体到 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 无法确定只有一条 age=22 的记录(实际上表里只有一条,但机制上不能这样假设),所以它会加临键锁,范围是(18, 22]。这样做本身就是为了"锁住位置,防止幻读":
- 间隙锁覆盖 18 到 22 之间的空隙,防止插入 age=19、20、21 的新记录。
- 记录锁锁住 age=22 这条记录,防止其他事务修改它。
当一个事务持有(18, 22]的临键锁时,另一个事务想插入 age=20 的记录,会被间隙锁挡住;想更新 age=22 的记录,会被记录锁挡住。两路都封死,这就是临键锁防幻读的完整逻辑。
4.2 隔离级别对临键锁的影响
事务隔离级别是临键锁的开关。MVCC 在读已提交和可重复读下都能做快照读,但当前读的加锁策略完全不同。可重复读下 InnoDB 默认加临键锁,读已提交下退化为记录锁。这个差别是从机制上设计出来的——只有临键锁才能在当前读这条路径上把幻读堵死。
如果你在 READ COMMITTED 下跑同样的 SQL:
BEGIN; SELECT * FROM users WHERE age = 22 FOR UPDATE;InnoDB 只会在 age=22 这条记录上加记录锁,不会锁相邻间隙。好处是并发插入其他 age 值不会被阻塞,坏处是同一个事务里再执行一次相同条件查询,可能读出新插入的记录——也就是幻读。
生产环境选择隔离级别时,不要只看"哪个更安全",要看业务能不能容忍幻读。能容忍,就上 READ COMMITTED 换并发;不能容忍,老老实实待在 REPEATABLE READ,并接受临键锁带来的锁竞争。
4.3 临键锁退化为记录锁的条件
有几个典型场景,临键锁会退化成记录锁,锁粒度变窄,性能更好。
场景一:唯一索引等值查询且记录存在。
前面已经提过,唯一索引等值命中时,唯一性已经保证了"不可能有第二条相同值的记录",所以不需要锁间隙,只要锁住该记录即可。WHERE user_no = 'U003'就是这种。
场景二:主键等值查询且记录存在。
主键本身就是唯一索引,同理。
场景三:唯一索引等值查询时碰到唯一性冲突。
比如事务 A 想插入 user_no='U003',但这条记录已经存在。此时它会尝试对唯一索引加共享锁,如果不冲突,只锁住这条记录。
场景四:unique 索引等值匹配但记录不存在的特殊情况。
这里注意——记录不存在时,临键锁会退化成间隙锁,而不是退化成什么都不锁。比如WHERE user_no = 'U999'且 U999 不存在,InnoDB 会在 U005 之后的间隙上加一个间隙锁,锁定区间是(U005, +∞),防止其他事务插入 U999。所以不存在并不代表不锁。
4.4 组合索引下的临键锁
组合索引和单列索引在临键锁上最大的不同,是锁的范围由多个列的值共同决定。举个常见例子,表里有联合唯一索引(a, b),如果查询条件是WHERE a = 1 AND b = 2,且记录存在,InnoDB 能根据这个唯一索引精确定位到一条记录,加记录锁即可。但如果查询条件是WHERE a = 1(只用到了最左前缀),无法保证唯一性,就会退化为临键锁,锁定a=1对应的索引区间。
如果查询条件完全没用到索引,那就是另一回事了——全表扫描会对聚簇索引里每一条记录尝试加临键锁,整体锁范围大幅膨胀。这种 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 或 TABLE,LOCK_MODE对应 X、S、X,GAP、X,REC_NOT_GAP 等,LOCK_DATA表示锁住的索引值。
X, REC_NOT_GAP:X 锁,类型是记录锁。X, GAP:X 锁,类型是间隙锁。X:X 锁,类型是临键锁(默认模式)。S, REC_NOT_GAP:S 锁,类型是记录锁。
我排查锁等待时,习惯先查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 文本,很多时候问题不在当前语句,而在于这个事务前面已经执行过多少条语句、持有了多少把锁。事务是一个整体,加锁效果是累积的——只看最后一条语句,容易漏掉真正的元凶。