间隙锁解决幻读

面试官问:"MySQL 的可重复读是怎么解决幻读问题的?"

小张说:"用间隙锁。"

面试官追问:"间隙锁锁住的是什么?"

小张说:"锁住两条记录之间的空隙。"

面试官继续追问:"如果表里只有一条记录,间隙锁锁哪里?"

小张想了想:"...那没有空隙?"

面试官摇摇头。

这道题,考的是候选人对间隙锁和幻读关系的理解深度。知道"间隙锁能解决幻读"只是皮毛,知道"间隙锁锁住的是什么范围"和"为什么能防止幻读"才是真正理解。

一、幻读问题回顾 🔴

1.1 幻读的定义

幻读指的是同一事务内,两次执行相同的查询,结果集不一样(多了或少了行)。

-- T1: BEGIN;
-- T1: SELECT * FROM orders WHERE status = 0;  --> 10 行
-- T2: INSERT INTO orders VALUES (...status=0...); COMMIT;
-- T1: SELECT * FROM orders WHERE status = 0;  --> 11 行(幻读)

1.2 幻读在快照读和当前读下的表现

读类型幻读表现解决方案
快照读不发生(MVCC 保证)ReadView 决定可见版本
当前读可能发生临键锁 + 间隙锁

关键结论

  • 快照读的幻读由 MVCC 解决(ReadView)
  • 当前读的幻读由临键锁/间隙锁解决

1.3 ❌ 错误示范

候选人原话:"可重复读下没有幻读问题。"

问题诊断:不完全正确。快照读的幻读被 MVCC 解决,但当前读(FOR UPDATE)仍可能有幻读(除非用间隙锁)。

候选人原话 2:"间隙锁锁住的是行,不是空隙。"

问题诊断:混淆了记录锁和间隙锁。间隙锁只锁空隙,不锁记录。

【面试官心理】 这道题我会从快照读和当前读的区别切入。很多候选人知道"间隙锁解决幻读",但说不清楚是哪种读场景下的幻读。能区分 MVCC 和锁机制在幻读问题上的分工的候选人,才算真正理解。

二、间隙锁的工作原理 🔴

2.1 间隙锁锁住的是什么?

间隙锁锁住的是索引记录之间的空隙,目的是防止其他事务在这个空隙中插入新记录

-- 索引状态:id = {1, 5, 10, 15, 20}
-- 索引中的"间隙":(-∞, 1), (1, 5), (5, 10), (10, 15), (15, 20), (20, +∞)

-- 场景 1:查询存在的记录
SELECT * FROM orders WHERE id = 5 FOR UPDATE;
-- 间隙锁锁住 (1, 5) 和 (5, 10) 两个间隙
-- 禁止其他事务在 id ∈ (1, 5) 或 id ∈ (5, 10) 的位置插入记录

-- 场景 2:查询不存在的记录
SELECT * FROM orders WHERE id = 7 FOR UPDATE;
-- 7 不存在,落在间隙 (5, 10) 中
-- 间隙锁锁住 (5, 10) 整个间隙

2.2 间隙锁的锁住范围

-- 索引记录:1, 5, 10
-- 执行 SELECT * FROM orders WHERE id > 2 AND id < 8 FOR UPDATE;
-- 结果:命中 id=5
-- 临键锁/间隙锁锁住:
--   - [5, 10):临键锁,包含 id=5 和 (5, 10) 间隙

2.3 临键锁:记录锁 + 间隙锁

-- 临键锁(Next-Key Lock)是 InnoDB 默认的锁算法
-- 临键锁 = 记录锁 + 间隙锁
-- 锁住前开后闭区间 [a, b)

-- 示例:索引记录为 {1, 5, 10}
-- 临键锁覆盖:
--   [1, 5)    锁 1 和 (1, 5)
--   [5, 10)   锁 5 和 (5, 10)
--   [10, +∞)  锁 10 和 (10, +∞)

三、为什么间隙锁能防止幻读?🟡

3.1 当前读的场景

-- T1: BEGIN;
-- T1: SELECT * FROM orders WHERE id BETWEEN 1 AND 10 FOR UPDATE;
-- T1 临键锁锁住 [1, 10) 区间

-- T2: BEGIN;
-- T2: INSERT INTO orders VALUES (id=6, ...);  -- 被阻塞!
-- T2 想在间隙 (5, 10) 中插入 id=6
-- 但这个间隙被 T1 的临键锁锁住了

-- T1: SELECT * FROM orders WHERE id BETWEEN 1 AND 10;
-- 结果:仍然是第一次查询的行数,没有幻读

3.2 锁的范围取决于索引

-- 索引 idx_status_type(status, type)
-- 索引记录:(0,1), (0,2), (1,1), (1,2)

-- T1: SELECT * FROM orders WHERE status = 0 FOR UPDATE;
-- 临键锁锁住多个区间:
--   [status=0, type=1] 到 [status=0, type=2]
--   [status=0, type=2] 到 [status=1, type=1]

-- T2: INSERT INTO orders VALUES (status=0, type=1);  -- 被阻塞!

-- T2: INSERT INTO orders VALUES (status=0, type=3);  -- 落在 (0,2) 到 (1,1) 之间,也被阻塞

3.3 唯一索引的特殊情况

-- order_no 是唯一索引
-- 索引记录:'A001', 'A010', 'A020'

-- 等值查询(结果存在):退化为记录锁
SELECT * FROM orders WHERE order_no = 'A010' FOR UPDATE;
-- 只锁 'A010' 这条记录,不锁间隙

-- 等值查询(结果不存在):退化为间隙锁
SELECT * FROM orders WHERE order_no = 'A015' FOR UPDATE;
-- 锁住 (A010, A020) 之间的间隙

四、生产避坑 🟡

4.1 无索引列的间隙锁灾难

-- orders 表有 1000 万行,status 列没有索引
-- T1: UPDATE orders SET status = 2 WHERE status = 1;  -- 全表扫描 + 当前读
-- InnoDB 会对扫描到的每一行加临键锁
-- 1000 万行全部被锁,锁的范围扩大到全表

-- T2: INSERT INTO orders VALUES (...);  -- 被阻塞!锁等待超时
⚠️

对没有索引的列执行 UPDATE/DELETE/SELECT FOR UPDATE,会导致 InnoDB 锁住全表。生产环境中,这种查询轻则导致大量锁等待,重则导致数据库连接耗尽。

4.2 范围查询的锁扩大

-- 索引记录:1, 5, 10, 15, 20
-- 范围查询
SELECT * FROM orders WHERE id BETWEEN 5 AND 15 FOR UPDATE;
-- 临键锁锁住:
--   [5, 10), [10, 15), (15, 20) 多个区间
-- 锁范围比预期更大

-- 更糟糕的是,RR 模式下下一个查询可能锁住更多区间

4.3 锁与死锁

间隙锁和间隙锁之间不互斥

-- T1: SELECT * FROM orders WHERE id > 5 AND id < 10 FOR UPDATE;  -- 锁 (5, 10)
-- T2: SELECT * FROM orders WHERE id > 8 AND id < 15 FOR UPDATE;  -- 锁 (8, 15)

-- (5, 10) 和 (8, 15) 有重叠,但两个间隙锁不互斥
-- 两个查询可以同时执行

-- 但如果 T1 要插入 id=7,T2 要插入 id=12
-- 两个插入都会被对方持有的锁阻塞
-- 形成死锁(但不是间隙锁之间的死锁)

【面试官心理】 这道题我能从多个角度追问。比如:"为什么唯一索引等值查询能退化为记录锁?"能答出"因为唯一索引保证了不存在重复值,不需要锁间隙防止插入"的候选人,说明他对锁退化机制有深入理解。


级别考察重点期望回答
P5概念理解间隙锁锁住记录之间的空隙,防止幻读
P6原理深度临键锁 = 记录锁 + 间隙锁,锁住 [a, b) 区间
P7实战边界唯一索引退化、无索引列的锁灾难、锁扩大