Skip to content

04.2 - 行锁与间隙锁

定位:行锁和间隙锁是 InnoDB 并发控制最核心的机制——理解加锁规则才能写出无死锁的代码 面试高频度:⭐⭐⭐⭐⭐ 考查方式:Next-Key Lock 加锁范围、间隙锁与幻读、不同隔离级别下的加锁差异


一、这是什么?为什么需要它?

是什么

InnoDB 的行锁分为三种类型:

锁类型范围目的
Record Lock锁定单条索引记录行级互斥
Gap Lock锁定两条索引记录之间的间隙防止插入(防幻读)
Next-Key LockRecord 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 不同隔离级别下的加锁差异

隔离级别普通 SELECTSELECT ... 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锁分类**
  • 死锁分析:**死锁分析与排查**
  • 隔离级别:**事务隔离级别**
  • ← 返回 **锁机制索引**

Knowledge4J — Java 知识库