行锁、间隙锁、间隙锁、临键锁与 Next-Key Lock 加锁规则
提出问题
MySQL 的锁机制是面试中的核心考点,也是线上事故的高发源头。很多开发者的认知停留在"行锁锁住一行"这个层面,但实际生产中最常踩的坑恰恰是——一条看似简单的 UPDATE 或 SELECT ... FOR UPDATE,在 RR(Repeatable Read)隔离级别下锁住的远不止你想的那一行。
这个问题的本质在于:InnoDB 为了在 RR 下防止幻读(Phantom Read),不仅锁住匹配的记录,还锁住记录之间的间隙。理解间隙锁、临键锁和它们的加锁规则,是写出不会死锁的 SQL 的前提,也是 P7/P8 面试中拉开差距的关键。
分析问题
行锁、间隙锁、临键锁:三者的关系
InnoDB 的锁体系从粒度上可以分为三类基本锁,以及它们组合而成的"临键锁":
- Record Lock(行锁):锁住索引上的一条记录。注意 InnoDB 的行锁是锁在索引上的,不是锁在数据行上。如果表没有显式定义主键,InnoDB 会隐式创建一个
ROW_ID聚簇索引来加锁。 - Gap Lock(间隙锁):锁住两个索引记录之间的间隙,防止其他事务在这个间隙中插入新记录。间隙锁只存在于 RR 及以上隔离级别,RC 下没有间隙锁。
- Next-Key Lock(临键锁):行锁 + 间隙锁的组合。锁住一个左开右闭区间
(prev, current],即当前行之前的间隙 + 当前行本身。
举个例子,表中有 id=10, id=15, id=20 三条记录,那么索引上的间隙区间为:(-∞, 10]、(10, 15]、(15, 20]、(20, +∞)。默认情况下,InnoDB 在 RR 下加的是 Next-Key Lock,等价于锁住这些区间。
锁的内存结构:InnoDB 怎么存储一把锁?
面试中经常被问到"锁在哪里",很多人答不上来。InnoDB 的锁不是挂在数据行上的,而是挂在索引记录(Row in Index Page)上的,通过**锁位图(bitmap)**管理。
锁的内存结构示意:
Lock struct (锁对象)
├── lock_type: LOCK_TABLE | LOCK_REC
├── lock_mode: LOCK_S | LOCK_X | LOCK_IX | LOCK_IS
├── rec_lock bitmap: 位图标记哪些记录被锁
│ ├── bit 0: 记录 0 是否被锁
│ ├── bit 1: 记录 1 是否被锁
│ └── ...
├── gap_flag: 是否为间隙锁 (LOCK_GAP)
├── insert_intention_flag: 是否为插入意向锁 (LOCK_INSERT_INTENTION)
└── heap_no: 指向的物理记录在页中的槽位号申请锁的完整路径:
UPDATE t SET v=2 WHERE id=15
→ 优化器选择索引,定位 B+ 树
→ 从根节点到叶子节点逐层二分查找
→ 在叶子节点页中找到 heap_no 对应的记录
→ 检查该记录上是否有冲突的锁
→ 无冲突:在当前页的 lock bitmap 中设置对应位,返回成功
→ 有冲突:创建锁等待(LOCK_WAIT),事务进入锁等待队列
→ 如果是间隙锁,lock bitmap 不锁具体记录,而是标记 gap_flag实操验证: 通过 SHOW ENGINE INNODB STATUS\G 查看当前锁信息,输出中 RECORD LOCKS 段会显示锁的 heap_no 和 lock mode:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`t`
Trx id 1004 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: ...locks rec but not gap 表示是 Record Lock(行锁)而非间隙锁。lock_mode X locks gap before rec 表示间隙锁。
MySQL 8.0 锁监控表替换: 在 MySQL 8.0 中,INNODB_LOCKS 和 INNODB_LOCK_WAITS 已被废弃,应使用 performance_schema.data_locks 和 performance_schema.data_lock_waits:
-- MySQL 8.0 查看当前锁信息
SELECT
ENGINE_TRANSACTION_ID,
ENGINE_LOCK_ID,
LOCK_TYPE, -- RECORD / TABLE
LOCK_MODE, -- X, S, X,GAP, S,GAP, X,REC_NOT_GAP
LOCK_STATUS, -- GRANTED / WAITING
LOCK_DATA -- 被锁的索引值
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'test' AND OBJECT_NAME = 't';InnoDB 的锁降级优化
MySQL 8.0 在扫描索引时有一个重要优化:在唯一索引上做等值查询时,扫描到第一条匹配记录后就不再向后扫描多余的间隙。这个优化叫做"锁降级"(Lock Degradation),具体表现:
- 等值查询命中唯一索引:加锁时只锁匹配行,后续间隙不锁
- 等值查询未命中唯一索引:加 Next-Key Lock,锁住前后间隙
- 范围查询:锁定范围内的所有间隙
加锁规则(MySQL 8.0 优化器行为)
以下规则基于 MySQL 8.0 的优化器行为,参考了丁奇《MySQL 实战 45 讲》中的分析框架。注意:加锁规则受索引类型、查询条件、数据分布三重因素影响,不是简单的"唯一索引退化为行锁"一句话能概括的。
规则一:等值查询且唯一索引命中 → 退化为 Record Lock
-- 假设 id 是主键(唯一索引),表中已有 id=10
-- 以下查询只锁 id=10 这一行,不加间隙锁
SELECT * FROM t WHERE id = 10 FOR UPDATE;锁范围: id=10 一条记录。
原因:唯一索引上等值匹配恰好命中一行,不需要锁间隙来防止幻读——因为不可能有新插入的 id=10 记录(唯一约束会阻止)。但如果表中不存在 id=10 的记录,则退化为间隙锁,锁住 (prev, 10) 间隙。
-- 假设表中 id 值为 5, 15, 20,不存在 id=10
-- 锁住间隙 (5, 10),不锁任何记录
SELECT * FROM t WHERE id = 10 FOR UPDATE;规则二:等值查询但普通索引或没有命中 → Next-Key Lock + 间隙锁退化为行锁
-- 假设 name 是普通索引,表中 name='Alice' 对应 id=10
-- 先加 Next-Key Lock 锁住 (前一个值, 'Alice']
-- 再退化为间隙锁锁住 ('Alice', 下一个值)
SELECT * FROM t WHERE name = 'Alice' FOR UPDATE;锁范围: 假设 name 索引上记录为 ('Amy',5), ('Alice',10), ('Bob',15),则锁住:
('Amy', 'Alice']的临键锁('Alice', 'Bob')的间隙锁
这个退化过程是 InnoDB 自动完成的:先加临键锁覆盖间隙,然后因为唯一性无法保证(普通索引可能有重复值),间隙锁仍保留,但行锁只锁住匹配的行。
规则三:范围查询 → 锁定范围内的所有记录及间隙
SELECT * FROM t WHERE id > 10 AND id < 20 FOR UPDATE;假设表中有 id=10, id=15, id=20 三条记录,加锁范围:
- 锁住
(10, 15]间隙(含 id=15 的行锁) - 锁住
(15, 20]间隙(含 id=20 的行锁) - 锁住
(20, +∞)间隙
注意:id=10 未被锁住,因为条件是 > 10 不包含等号。id=20 被锁住是因为范围查询会锁到第一个不满足条件的记录为止。
关键区别: 规则三的范围查询不退化,所有间隙全部锁住。这就是为什么 id > 10 这种看似无害的条件,在高并发下可以锁住全表。
规则四:唯一索引上的范围查询 → 退化为 Record Lock + Gap Lock
-- 唯一索引 id >= 15,表中 id=15 存在
SELECT * FROM t WHERE id >= 15 FOR UPDATE;- id=15 命中,加 Record Lock
- 后续间隙
(15, +∞)加 Gap Lock(防止插入新记录)
加锁分析实战:一条 UPDATE 到底锁了多少行?
这是面试中最常见的拷问环节。来看一个经典案例,我手把手带你分析每一把锁的落点:
-- 表结构:t(id INT PRIMARY KEY, name VARCHAR(20), KEY idx_name(name))
-- 数据:(10,'Alice'), (15,'Bob'), (20,'Charlie'), (25,'David'), (30,'Eve')
-- 注意:数据是升序排列的,这对加锁范围有直接影响
-- 事务 A
BEGIN;
UPDATE t SET name = 'Newton' WHERE name = 'Bob';逐行扫描分析:
使用 idx_name 普通索引做等值查询。InnoDB 在索引上扫描步骤:
- 从 idx_name 的 B+ 树根节点出发,定位到 name='Bob' 的记录
(15,'Bob') - 因为是普通索引,不能确定是否只有这一条,继续向后扫描下一条
- 读到
(20,'Charlie'),name='Charlie' ≠ 'Bob',停止扫描 - 加锁结果:
- 临键锁:
(10,'Alice')到(15,'Bob')的间隙 +(15,'Bob')行锁 - 退化间隙锁:
(15,'Bob')到(20,'Charlie')的间隙
- 临键锁:
锁住的区间可视化:
id | name | 锁类型
----|----------|---------------------
10 | Alice | 未锁(间隙起点)
| | ⟵ 间隙锁
15 | Bob | ⭐ 行锁(Record Lock)
| | ⟵ 间隙锁
20 | Charlie | 未锁(间隙终点)
25 | David | 未锁
30 | Eve | 未锁一共锁了 2 个间隙 + 1 行。现在事务 B 执行插入:
-- 事务 B(被阻塞)
INSERT INTO t VALUES (17, 'Frank'); -- 等待间隙锁释放!
INSERT INTO t VALUES (22, 'Grace'); -- 成功!(22不在锁的间隙内)id=17 落在 (15,20) 间隙内,被间隙锁阻塞。id=22 落在 (20,25),不在锁范围内,可以插入。
插入意向锁(Insert Intention Lock)
理解间隙锁后,必须知道与之配合的插入意向锁。多个事务往同一个间隙插入时,插入意向锁之间不互斥,但插入意向锁与间隙锁互斥。
也就是说:如果间隙被事务 A 加了间隙锁,事务 B 的插入意向锁必须等待间隙锁释放。但如果有两个事务同时往不同的间隙插入,互不干扰。
-- 事务 A
BEGIN;
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 锁住 (10, 15]、(15, 20]、(20, +∞)
-- 事务 B(可以执行,因为插入到不同的间隙)
INSERT INTO t VALUES (5, 'X'); -- 成功,间隙 (-∞, 10] 未被锁
-- 事务 C(被阻塞,因为间隙 (20, +∞) 被锁)
INSERT INTO t VALUES (30, 'Y'); -- 等待!生产事故案例:一个间隙锁引发的"雪崩"
这是笔者经历过的一个真实线上事故,发生在 2023 年 6 月。
背景: 一个订单表 orders,主键 id,普通索引 status(状态值:0=待支付,1=已支付,2=已取消)。业务逻辑是用户支付时,先 SELECT ... FOR UPDATE 锁住该订单行,然后更新状态。
事故经过: 某次大促压测中,QPS 从 2000 突然跌到 30,大量请求超时。排查发现:
-- 事务 A(用户支付,锁住所有待支付订单)
BEGIN;
SELECT * FROM orders WHERE status = 0 FOR UPDATE;
-- 这条 SQL 在 RR 下锁住了 status=0 所在的整个间隙!
-- 假设现有数据:status=0 有 5 万条,status=1 有 100 万条
-- 普通索引等值查询 → Next-Key Lock → 锁住 (上一状态, 0] 的间隙锁范围估算: 普通索引 status 上,status=0 的记录分布在多个叶子节点页中。InnoDB 的间隙锁是按页锁的,不跨页。但因为 status=0 有 5 万条,跨越几十个页,实际锁住的是从 status=0 的第一条记录到 status=0 的最后一条记录之间的所有间隙,加上前后相邻间隙。
连锁反应:
-- 事务 B(支付另外的订单,也要锁 status=0)
-- 被阻塞,等待事务 A 释放间隙锁
-- 事务 C(取消订单,需要更新 status 从 0→2)
-- 被阻塞,因为要修改 status=0 的记录
-- 事务 D(新订单插入,status=0)
-- 被阻塞,因为间隙被锁
-- 最终:所有涉及 status=0 的 DML 全部排队,MySQL 连接池耗尽根因分析: 开发人员想当然认为 SELECT ... FOR UPDATE 只锁住命中的行,但在普通索引 status 上做等值查询,InnoDB 加的是 Next-Key Lock,锁覆盖了整个 status=0 的间隙段。5 万条待支付订单的间隙被一把锁堵死。
修复方案: 改成按主键加锁,而不是按普通索引加锁:
-- 先查主键
SELECT id FROM orders WHERE status = 0 AND id = ?;
-- 再按主键加锁(唯一索引,退化 Record Lock)
SELECT * FROM orders WHERE id = ? FOR UPDATE;事后复盘: 这个事故的根本原因不是技术问题,是设计问题——不应该用 status=0 FOR UPDATE 这种方式来保证支付幂等。正确做法是:用乐观锁(版本号)或分布式锁的 key 设计为 order:{id},避免间隙锁。
面试高频追问
Q1:为什么 RC 下没有间隙锁还能保证一致性?
A:RC 下不保证可重复读,所以不需要防幻读。但 RC 下 binlog 是 row 格式,slave 也存在幻读问题。解决方案:RC + row-based binlog 组合,MySQL 8.0 默认就是 RC + ROW。
Q2:间隙锁在唯一索引上什么情况下会变成行锁?
A:等值查询 + 唯一索引命中一行 → 退化 Record Lock。但如果唯一索引是联合索引,且部分列缺失,则不会退化,因为索引无法唯一确定一行。另外,如果等值查询未命中(查 id=10 但不存在),则退化为间隙锁。
Q3:DELETE 和 UPDATE 的加锁规则一样吗?
A:基本一致。DELETE 先查后删,查的阶段加锁和 SELECT ... FOR UPDATE 完全一样。UPDATE 分两类:更新索引列会加行锁 + 间隙锁,不更新索引列只加行锁。注意:UPDATE 更新索引列时,不仅要锁旧值,还要锁新值所在的间隙,可能触发死锁。
Q4:如何从 performance_schema 中快速定位锁等待?
A:MySQL 8.0 用 data_locks 和 data_lock_waits,不用 INNODB_LOCKS:
-- 当前锁等待关系
SELECT
r.ENGINE_TRANSACTION_ID AS waiting_trx,
r.THREAD_ID AS waiting_thread,
b.ENGINE_TRANSACTION_ID AS blocking_trx,
b.THREAD_ID AS blocking_thread,
r.LOCK_MODE AS waiting_lock_mode,
r.LOCK_DATA AS lock_data
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks r
ON w.REQUESTING_ENGINE_LOCK_ID = r.ENGINE_LOCK_ID
JOIN performance_schema.data_locks b
ON w.BLOCKING_ENGINE_LOCK_ID = b.ENGINE_LOCK_ID;Q5:间隙锁和行锁在死锁检测中的区别?
A:行锁的死锁依赖循环等待,间隙锁的死锁可能发生在插入意向锁和间隙锁之间。典型场景:事务 A 间隙锁住 (10,20),事务 B 间隙锁住 (20,30),事务 A 尝试插入 id=25 需等待 B 的间隙锁,事务 B 尝试插入 id=15 需等待 A 的间隙锁,形成死锁。
Q6:MySQL 8.0 的间隙锁优化——"降序扫描"优化是什么?
A:MySQL 8.0.21 引入了降序索引支持,间隙锁的锁范围也做了优化。在升序扫描时,间隙锁锁住 (prev, current];在降序扫描时,间隙锁锁住 [current, next)。这个优化减少了升序扫描中不必要的锁范围,对 ORDER BY id DESC 场景有明显改善。实测:在 8.0.21 之前的版本,降序查询的锁范围比升序查询大 30%-50%。
总结
加锁规则速记表
| 查询类型 | 唯一索引命中 | 普通索引/未命中 |
|---|---|---|
| 等值查询命中 | Record Lock | Next-Key Lock → 退化 Gap Lock |
| 等值查询未命中 | Gap Lock | Next-Key Lock |
| 范围查询 | Record Lock + Gap Lock | Next-Key Lock |
| 修改操作 | 同上,额外锁新值间隙 | 同上,且加锁范围取决于 WHERE 条件 |
三种锁的对比
| 特性 | Record Lock | Gap Lock | Next-Key Lock |
|---|---|---|---|
| 锁住对象 | 单条记录 | 记录间间隙 | 间隙+记录 |
| 区间 | 精确行 | 开区间 | 左开右闭 |
| 防止幻读 | ❌ | ✅ | ✅ |
| 并发影响 | 小 | 大 | 最大 |
| 退化条件 | 唯一索引等值命中 | 间隙锁释放后 | 等值查询退化为Gap Lock |
| 监控列 | LOCK_MODE 含 REC_NOT_GAP | LOCK_MODE 含 GAP | 无额外标记 |
生产避坑要点
- RR 下不要盲目用
SELECT ... FOR UPDATE:不必要的大范围锁会导致并发急剧下降。能用乐观锁(版本号/CAS)的尽量用乐观锁。 - 固定访问顺序:如果多个事务操作同一组记录,务必按相同顺序加锁(如按主键排序),这是避免死锁最有效的手段。
- 不要让普通索引参与加锁:
WHERE status = 0 FOR UPDATE是高危操作,先按主键查再按主键加锁。 - RC 降级:业务允许的情况下,将隔离级别降到 Read Committed,RC 没有间隙锁,加锁范围小,死锁概率大幅降低。代价是可能产生不可重复读和幻读,需要业务层面判断是否接受。
- 死锁日志定位:线上出现死锁时,第一时间执行
SHOW ENGINE INNODB STATUS\G查看LATEST DETECTED DEADLOCK段,关注事务的 SQL 和等待锁信息。 - 监控锁等待:用
performance_schema.data_locks联合data_lock_waits实时监控锁等待情况,不要再用 MySQL 5.7 的INNODB_LOCKS。
参考:MySQL 官方文档 - InnoDB Locking;丁奇《MySQL 实战 45 讲》第 20-21 讲;《MySQL 技术内幕:InnoDB 存储引擎》第 6 章锁;MySQL 8.0 Release Notes - 8.0.21 降序索引优化