04.2 - 行锁与间隙锁
定位:行锁和间隙锁是 InnoDB 并发控制最核心的机制——理解加锁规则才能写出无死锁的代码 面试高频度:⭐⭐⭐⭐⭐ 考查方式:Next-Key Lock 加锁范围、间隙锁与幻读、不同隔离级别下的加锁差异
一、这是什么?为什么需要它?
是什么
InnoDB 的行锁分为三种类型:
| 锁类型 | 范围 | 目的 |
|---|---|---|
| Record Lock | 锁定单条索引记录 | 行级互斥 |
| Gap Lock | 锁定两条索引记录之间的间隙 | 防止插入(防幻读) |
| Next-Key Lock | Record Lock + Gap Lock | 行互斥 + 防插入 |
为什么需要间隙锁?
没有间隙锁的问题——RR 下的幻读:
sql
-- 事务 A
BEGIN;
SELECT * FROM users WHERE age > 20 FOR UPDATE;
-- 查到 id=2(age=25), id=5(age=30)
-- 事务 B
INSERT INTO users (id, age) VALUES (100, 24); -- 插入成功!
COMMIT;
-- 事务 A 再查
SELECT * FROM users WHERE age > 20 FOR UPDATE;
-- 查到 id=2, id=5, id=100 ← 幻读!多了一行间隙锁阻止了这种插入——在 age > 20 的索引间隙上加锁,不让其他事务插入。
核心 insight:间隙锁是"锁住插入的可能性",而不是锁住具体的行。这是 RR 级别防幻读的关键。
二、原理拆解
2.1 三种行锁详解
Record Lock(记录锁)
索引:
id: 1 10 20 30 40 50
对 id=20 加 Record Lock:
id: 1 10 20 30 40 50
[X] ← 只锁定 id=20 这一行
id=1: ✅ 可修改
id=30: ✅ 可修改
插入 id=25: ✅ 可插入特点:最精确的锁,只锁一条索引记录。InnoDB 的行锁本质是索引锁——如果表没有索引,退化为表锁。
Gap Lock(间隙锁)
索引:
id: 1 10 20 30 40 50
对 id=20 和 id=30 之间的间隙加 Gap Lock:
id: 1 10 20 30 40 50
──[GAP]── ← 锁定 (20,30) 的间隙
id=20: ✅ 可修改
id=30: ✅ 可修改
插入 id=25: ❌ 被阻塞!(在间隙中)
插入 id=21: ❌ 也被阻塞
插入 id=35: ✅ 可以(不在 (20,30) 间隙中)间隙锁的"间隙"定义:
- 间隙是开区间,不包括两端的记录
- 例如 (20, 30) 间隙:不锁 id=20 和 id=30 本身
- 间隙锁只阻止插入,不阻止修改和删除
Next-Key Lock(临键锁)
索引:
id: 1 10 20 30 40 50
对 id=20 加 Next-Key Lock:
id: 1 10 20 30 40 50
──[NK ]── ← 锁的是 (10, 20] 区间
↑
实际上锁的是 (前一个值, 20] 的半开区间
id=20: ❌ 被锁定(Record Lock 部分)
(10,20) 间隙: ❌ 不能插入(Gap Lock 部分)
id=30: ✅ 可修改
插入 id=15: ❌ 在 (10,20) 间隙中,被阻塞
插入 id=25: ✅ 可以(在 (20,30) 间隙,这是另一个 Next-Key 范围)Next-Key Lock = Record Lock + Gap Lock,锁定"一条记录 + 前面的间隙"。
2.2 加锁规则("两个原则 + 两个优化")
MySQL 的加锁规则可以总结为两个原则 + 两个优化(源自林晓斌 MySQL 实战 45 讲):
原则一:加锁的基本单位是 Next-Key Lock
对 id=20 加锁,默认加的是 (前一个key, 20] 的 Next-Key Lock
例:索引 [1, 10, 20, 30, 40, 50]
WHERE id=20 → 加锁范围: (10, 20]原则二:查找过程中访问到的对象才会加锁
例:SELECT * FROM users WHERE id > 15 FOR UPDATE;
扫描到 id=20、id=30、id=40 ...
对每个扫描到的记录加 Next-Key Lock优化一:唯一索引上的等值查询——降级为 Record Lock
WHERE id=20(且 id 是唯一索引/主键)→ Next-Key Lock 降级为 Record Lock
只锁 id=20 本身,(10,20) 间隙不锁
为什么可以降级?
唯一索引等值查询,确定了唯一行,不需要防止其他记录插入优化二:索引上的等值查询,最后一个不满足条件的——退化为 Gap Lock
索引: [1, 10, 20, 30, 40, 50]
SELECT * FROM users WHERE id > 20 FOR UPDATE;
→ 扫描从 30 开始(第一个 >20 的值)
→ 对 30 加 Next-Key Lock (20, 30]
→ 对 40 加 Next-Key Lock (30, 40]
→ ...直到扫描结束
但最后一个扫描的行(假设 50 是最后一条):
→ 对间隙 (50, +∞) 加 Gap Lock(不是 Next-Key Lock)
→ 因为 50 不满足条件,退化为间隙锁2.3 不同隔离级别下的加锁差异
| 隔离级别 | 普通 SELECT | SELECT ... FOR UPDATE | 间隙锁 |
|---|---|---|---|
| RC | 快照读,不加锁 | 只加 Record Lock | ❌ 无 |
| RR | 快照读,不加锁 | Record + Gap + Next-Key | ✅ 有 |
| SERIALIZABLE | 全部隐式转 FOR SHARE | 全部加锁 | ✅ 有 |
RC + 行锁 + Binlog 的问题(statement 格式):
为什么 RC 下 statement 格式的 Binlog 不安全?
因为 RC 下不加间隙锁,可能导致主从数据不一致
例子:
事务 A 在 RC 下 DELETE FROM users WHERE age > 20
→ 在 master 上删除了 id=2(25), id=5(30)
→ <期间有其他事务插入了一行 age=22>
Binlog 记录: DELETE FROM users WHERE age > 20 (statement 格式)
→ slave 执行这个 SQL → 删除 id=2, id=5, id=6(age=22)
→ master 上 id=6 不在删除范围内(因为插入在 DELETE 之后)
→ slave 上 id=6 被删除了 → 主从不一致!
解决方案:
1. 用 row 格式 binlog(binlog_format=ROW)
2. 或使用 RR 隔离级别(间隙锁阻止插入)2.4 加锁实践
sql
-- 示例表和数据
CREATE TABLE t (
id INT PRIMARY KEY,
c INT,
INDEX idx_c (c)
);
INSERT INTO t VALUES (0,0), (5,5), (10,10), (15,15), (20,20);场景1:主键等值查询
sql
-- 唯一索引等值查询 → 降级为 Record Lock
BEGIN;
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 锁了: id=10 这一行(Record Lock)
-- 没锁: id=5 和其他行场景2:主键范围查询
sql
-- 范围查询 → Next-Key Lock
BEGIN;
SELECT * FROM t WHERE id >= 10 FOR UPDATE;
-- 锁了:
-- (5,10] ← Next-Key Lock(id=10)
-- (10,15] ← Next-Key Lock(id=15)
-- (15,20] ← Next-Key Lock(id=20)
-- (20,+∞) ← Gap Lock(最后一个不满足条件,退化为间隙锁)
-- 结果:
-- id=8: ❌ 被 (5,10] 间隙锁阻止插入
-- id=10: ❌ 被 Record Lock 阻止修改
-- id=13: ❌ 被 (10,15] 间隙锁阻止插入
-- id=25: ❌ 被 (20,+∞) 间隙锁阻止插入场景3:二级索引
sql
-- 二级索引等值查询
BEGIN;
SELECT * FROM t WHERE c = 10 FOR UPDATE;
-- 二级索引 c 上:
-- (5,10] ← Next-Key Lock
-- 主键 id=10 上:
-- Record Lock
-- 注意:二级索引的间隙锁范围更大
-- 因为二级索引的叶子节点包括主键值三、图解全景
InnoDB 三种行锁图示
Record Lock (记录锁)
id: 1 10 20 30 40 50
│ │ [X] │ │ │
└─────┴──────┼──────┴──────┴──────┘
只锁 id=20 这一行
Gap Lock (间隙锁)
id: 1 10 20 30 40 50
│ │ │(GAP) │ │ │
└─────┴──────┴──┼───┴──────┴──────┘
锁定 (20,30) 间隙
阻止插入 21-29
Next-Key Lock (临键锁)
id: 1 10 20 30 40 50
│ │(NK) │ │ │ │
└─────┴──┼────┼──────┴──────┴──────┘
锁定 (10,20]
阻止修改 id=20,阻止插入 11-19
间隙锁 + 当前读防幻读
┌─────────────────────────────────────────┐
│ │
│ SELECT * FROM users WHERE age>20 │
│ FOR UPDATE(当前读) │
│ │
│ ┌──────────────────────────────┐ │
│ │ MVCC 快照读看不到新插入的行 │ │
│ │ 但如果有事务想插入新行 │ │
│ │ → 被间隙锁阻塞 │ │
│ │ → 无法插入 │ │
│ │ → 第二次 SELECT FOR UPDATE │ │
│ │ 不会出现幻读 │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────┘四、实战验证
验证间隙锁的存在
sql
-- 准备数据
CREATE TABLE test_gap (
id INT PRIMARY KEY,
val INT
);
INSERT INTO test_gap VALUES (1, 100), (10, 200), (20, 300);
-- 事务 A
BEGIN;
SELECT * FROM test_gap WHERE id = 10 FOR UPDATE;
-- 唯一索引等值查询 → 降级为 Record Lock
-- 只锁 id=10
-- 事务 B
INSERT INTO test_gap VALUES (5, 150); -- ✅ 可以插入(没有间隙锁)
INSERT INTO test_gap VALUES (15, 250); -- ✅ 可以插入(没有间隙锁)验证间隙锁的插入阻塞
sql
-- 事务 A(非唯一索引或范围查询会产生间隙锁)
BEGIN;
SELECT * FROM test_gap WHERE val = 200 FOR UPDATE;
-- val 是普通索引(不是唯一索引)
-- 会对二级索引加 Next-Key Lock
-- 事务 B
INSERT INTO test_gap VALUES (15, 250); -- 可能被阻塞!
-- 取决于 idx_val 上 (200, next_val) 间隙验证 RC 下没有间隙锁
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 事务 A
BEGIN;
SELECT * FROM test_gap WHERE id > 5 FOR UPDATE;
-- RC 下只加 Record Lock,没有间隙锁
-- 事务 B
INSERT INTO test_gap VALUES (3, 50); -- ✅ 可以
INSERT INTO test_gap VALUES (15, 250); -- ✅ 可以(RC 下无间隙锁)查看当前锁
sql
-- MySQL 8.0
SELECT * FROM performance_schema.data_locks\G
-- 查看是否有间隙锁
SELECT LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks
WHERE LOCK_MODE LIKE '%GAP%';五、面试视角
| 追问 | 答案要点 |
|---|---|
| Record Lock、Gap Lock、Next-Key Lock 的区别? | Record=锁行,Gap=锁间隙(防插入),Next-Key=两者结合 |
| Next-Key Lock 的加锁范围? | (前一个值, 当前值] 的半开区间 |
| RC 和 RR 的加锁差异? | RC 只有 Record Lock,RR 有 Record+Gap+Next-Key |
| 间隙锁在什么条件下会退化为 Record Lock? | 唯一索引等值查询(确定了唯一行) |
| 间隙锁在什么条件下会退化为 Gap Lock 只? | 最后一个不满足条件的记录 |
| 间隙锁对性能有什么影响? | 降低并发度(锁更多范围),高并发场景 RC 通常比 RR 快 |
| RR 下怎么防止幻读? | MVCC 解决快照读的幻读,间隙锁解决当前读的幻读 |
📚 相关链接
- 锁基础:**InnoDB锁分类**
- 死锁分析:**死锁分析与排查**
- 隔离级别:**事务隔离级别**
- ← 返回 **锁机制索引**