MVCC 与间隙锁:幻读到底防住了没

0 阅读18分钟

前言

MySQL 事务面试里有一道"送命题":

可重复读(Repeatable Read,RR)能防住幻读吗?

不少人脱口而出:不能,只有串行化(Serializable)才能防幻读

这个答案在 SQL 标准里没错,但在 MySQL InnoDB 里却是错的。InnoDB 的 RR 通过 MVCC + 间隙锁(Gap Lock) 的组合,确实把幻读防住了。

但很多人听完后反而更迷糊:

  1. MVCC 到底怎么"看到"旧版本的? —— 不是简单一句"快照"就解释得清的
  2. Read View 里那四个字段到底在比较什么? —— m_idsmin_trx_id 总是搞混
  3. 间隙锁锁的"间隙"长什么样? —— 不是锁行,那是锁空气吗?
  4. 为什么普通 SELECT 不怕幻读,而 UPDATE 还要靠间隙锁?

这篇文章会用生活化的类比 + 具体事务时间线 + 流程图,把 MVCC 与间隙锁彻底讲透。

一、📋 事务隔离级别与并发问题

1.1 四种并发问题

先统一语言。多个事务同时操作同一份数据时,可能产生四种异常现象:

问题通俗解释发生的场景
脏读读到了别人还没提交的数据事务 A 修改了某行,事务 B 立刻读到;随后 A 回滚,B 读到的就是"假数据"
不可重复读同一事务内,两次读同一行,结果不一样事务 A 第一次读到余额 1000;事务 B 修改并提交后,A 再次读到 800
幻读同一事务内,两次范围查询,结果行数不一样事务 A 先查 age > 18 有 3 条;事务 B 插入一条 age = 20,A 再查有 4 条
丢失更新两个事务同时改同一行,后提交的覆盖了先提交的A、B 同时读取库存 10,都扣 1,结果库存变成 9 而不是 8

1.2 SQL 标准隔离级别

SQL 标准定义了四种隔离级别,对上面的问题有不同的防御能力:

隔离级别脏读不可重复读幻读实现核心
读未提交(RU)❌ 可能❌ 可能❌ 可能几乎不加控制
读已提交(RC)✅ 避免❌ 可能❌ 可能MVCC,每条 SELECT 生成新 Read View
可重复读(RR)✅ 避免✅ 避免❌ 标准允许MVCC,事务级 Read View
串行化(Serializable)✅ 避免✅ 避免✅ 避免严格加锁串行

SQL 标准认为 RR 下幻读仍可能发生,因为标准只规定了"同一行"的可见性,没规定"新插入行"。

1.3 MySQL InnoDB 的 RR 不一样

InnoDB 在 RR 下做了额外优化:

  • 普通 SELECT:走 MVCC 快照读,靠 Read View 保证看不到别的事务新插入的数据。
  • SELECT ... FOR UPDATE / UPDATE / DELETE:走当前读,靠 Next-Key Lock(临键锁) 锁住范围,阻止别的事务插入。

所以 MySQL InnoDB 的 RR 是能防幻读的。后面我们会分两条线讲清楚:MVCC 负责"快照读防幻读",间隙锁负责"当前读防幻读"。

二、💻 MVCC 原理:快照里的世界

2.1 为什么要有 MVCC?

想象数据库是一间教室,里面有一排座位(数据行)。很多老师(事务)同时进来改座位牌。

如果没有 MVCC,任何老师改座位牌的时候,其他老师都不能看——必须等改完。这样读写互相阻塞,并发很低。

MVCC 的做法是:每次修改座位牌之前,先拍一张旧照片存起来。其他老师想看的时候,可以去看照片,不用等正在修改的老师。只有真正要修改的老师才需要抢"写锁"。

这就是 MVCC 的核心思想:写操作不阻塞读操作,读操作不阻塞写操作

2.2 每行数据背后的三个隐藏字段

InnoDB 在每行数据后面偷偷加了三个隐藏字段:

┌─────────────┬─────────────┬─────────────┬─────────────────┐
│  DB_TRX_ID  │ DB_ROLL_PTR │  DB_ROW_ID  │   用户数据列     │
├─────────────┼─────────────┼─────────────┼─────────────────┤
│   6 字节    │   7 字节    │   6 字节    │   name, age...  │
│ 最后修改它   │  回滚指针    │  隐藏主键    │                 │
│ 的事务 ID   │ 指向旧版本   │ 没有主键时   │                 │
│             │             │ InnoDB 自动生成 │               │
└─────────────┴─────────────┴─────────────┴─────────────────┘
  • DB_TRX_ID:最后一次修改这行数据的事务 ID。
  • DB_ROLL_PTR:指向 undo log 中的上一个版本。
  • DB_ROW_ID:如果表没有显式主键,InnoDB 用它作为隐藏主键。

用户平时看不到这三个字段,但 MVCC 的每一次判断都依赖它们。

2.3 undo log 版本链

每次修改一行数据时,InnoDB 不会直接覆盖旧数据,而是:

  1. 把旧数据复制到 undo log。
  2. 在数据页上写入新版本。
  3. 新版本的 DB_ROLL_PTR 指向 undo log 里的旧版本。

这样多次修改就形成了版本链

mermaid-diagram-2026-07-14-231506.png

版本链就像一条"时光隧道",顺着 DB_ROLL_PTR 一直往前找,能找到这行数据的所有历史版本。

2.4 Read View:事务的"快照"

当一个事务执行第一次普通 SELECT 时,InnoDB 会给它生成一个 Read View(一致性读视图)

你可以把它理解为事务拍照时记录下的"现场信息":

mermaid-diagram-2026-07-14-231646.png

四个字段的含义:

字段含义作用
m_ids生成 Read View 时,所有还没提交的事务 ID 列表判断某版本是不是"未提交"
min_trx_id活跃事务中最小的 ID比它小的版本一定已提交
max_trx_id下一个待分配的事务 ID比它大的版本是"未来事务"生成的
creator_trx_id创建 Read View 的事务自己的 ID自己修改的版本对自己可见

2.5 可见性判断规则

事务读取某一行时,拿到这行的 DB_TRX_ID(记为 trx_id),然后按下面规则判断:

1. trx_id == creator_trx_id
   → 可见(这行是我自己改的)

2. trx_id < min_trx_id
   → 可见(修改它的事务在我拍照前已经提交)

3. trx_id >= max_trx_id
   → 不可见(修改它的事务在我拍照之后才出现)

4. min_trx_id <= trx_id < max_trx_id
   → 看 trx_id 是否在 m_ids 中
      a. 在 m_ids 中  → 不可见(事务还没提交)
      b. 不在 m_ids 中 → 可见(事务已提交)

如果当前版本不可见,就顺着 DB_ROLL_PTR 找上一个版本,再用同样规则判断,直到找到可见版本。

mermaid-diagram-2026-07-14-231720.png

2.6 RC vs RR:Read View 生成时机的差异

这是面试最爱考的区分点:

隔离级别Read View 生成时机效果
读已提交(RC)每条 SELECT 都生成新的 Read View能看到其他事务最新提交的数据
可重复读(RR)事务第一次 SELECT 时生成,整个事务复用始终看到事务开始时的快照

我们用具体的时间线来看区别。假设表里有这样一行数据:

CREATE TABLE user (
    id INT PRIMARY KEY,
    name VARCHAR(50)
) ENGINE=InnoDB;

INSERT INTO user VALUES (1, 'Alice');
-- 假设插入事务 ID = 100

场景 A:读已提交(RC)

时间    事务 A (trx_id=200)            事务 B (trx_id=201)
────────────────────────────────────────────────────────────
T1     BEGIN                          BEGIN
T2     SELECT name FROM user WHERE id=1
       → 生成 Read View A
       → m_ids=[200,201], min=200, max=202
       → 当前行 trx_id=100 < 200 → 可见
       → 结果:Alice
T3                                    UPDATE user SET name='Bob' WHERE id=1
T4     SELECT name FROM user WHERE id=1
       → RC 重新生成 Read View
       → m_ids=[200,201], min=200, max=202
       → 当前行 trx_id=201 在 m_ids 中 → 不可见
       → 沿版本链找到 trx_id=100 的旧版本 → 可见
       → 结果:Alice(B 还没提交)
T5                                    COMMIT
T6     SELECT name FROM user WHERE id=1
       → RC 再次生成 Read View
       → m_ids=[200], min=200, max=202
       → 当前行 trx_id=201 不在 m_ids 中 → 可见
       → 结果:Bob(B 已提交)

在 RC 下,事务 A 第三次读到了 Bob,这就是"不可重复读"。

场景 B:可重复读(RR)

时间    事务 A (trx_id=200)            事务 B (trx_id=201)
────────────────────────────────────────────────────────────
T1     BEGIN                          BEGIN
T2     SELECT name FROM user WHERE id=1
       → 生成 Read View A,整个事务复用
       → m_ids=[200,201], min=200, max=202
       → 结果:Alice
T3                                    UPDATE user SET name='Bob' WHERE id=1
T4     SELECT name FROM user WHERE id=1
       → 复用 Read View A
       → m_ids=[200,201], trx_id=201 在 m_ids 中 → 不可见
       → 找到旧版本 trx_id=100 → 可见
       → 结果:Alice
T5                                    COMMIT
T6     SELECT name FROM user WHERE id=1
       → 仍然复用 Read View A
       → m_ids=[200,201], trx_id=201 仍在 m_ids 中 → 不可见
       → 结果:Alice

在 RR 下,事务 A 三次都读到 Alice,实现了可重复读。即使事务 B 提交了,Read View 里仍然认为 B 是"活跃事务",因为 Read View 是在 T2 生成的快照。

三、🔍 快照读 vs 当前读

MVCC 解决的是"快照读",但数据库里还有一种"当前读"。

类型SQL 例子读取方式是否加锁能否防幻读
快照读普通 SELECT读 MVCC 快照不加锁RR 下靠 Read View 防幻读
当前读SELECT ... FOR UPDATE / UPDATE / DELETE读最新已提交数据加锁RR 下靠间隙锁防幻读
-- ✅ 快照读:不加锁,看 Read View 里的版本
SELECT * FROM user WHERE age > 18;

-- ⚠️ 当前读:要加锁,读最新数据
SELECT * FROM user WHERE age > 18 FOR UPDATE;
UPDATE user SET status = 1 WHERE age > 18;
DELETE FROM user WHERE age > 18;

为什么 UPDATE 也要算当前读?

因为 UPDATE 必须基于最新数据做修改。如果它去读快照,就可能基于一个旧版本修改,导致"丢失更新"。所以 UPDATE 会先做一次当前读,找到最新行并加 X 锁,再修改。

mermaid-diagram-2026-07-14-231812.png

四、🔒 间隙锁与临键锁

4.1 什么是间隙锁?

间隙锁(Gap Lock)锁的不是某一行,而是两行之间的空隙

假设表 userid 主键已经有这些数据:

SELECT id FROM user ORDER BY id;
-- 结果:1, 5, 10, 15, 20

那么这些记录把索引轴分成了若干间隙:

(-∞, 1), (1, 5), (5, 10), (10, 15), (15, 20), (20, +∞)

如果事务 A 执行:

BEGIN;
SELECT * FROM user WHERE id > 10 FOR UPDATE;

InnoDB 会锁住 (10,15)(15,20)(20,+∞) 这些间隙,以及 id=15id=20 这两行本身。

此时事务 B 想插入:

INSERT INTO user VALUES (12, ...);

id=12 落在 (10,15) 间隙内,会被间隙锁挡住,进入阻塞状态。

mermaid-diagram-2026-07-14-231847.png

间隙锁与间隙锁之间不冲突。两个事务可以同时持有同一个间隙的间隙锁,但它们都会阻止第三个事务在这个间隙里插入。

4.2 临键锁(Next-Key Lock)

InnoDB 在 RR 下的默认锁不是单纯的间隙锁,而是 Next-Key Lock = 行锁 + 间隙锁

它的区间是左开右闭(前一条记录, 当前记录]

还是上面的数据 1, 5, 10, 15, 20

SELECT * FROM user WHERE id = 10 FOR UPDATE;

会生成 Next-Key Lock,锁住 (5, 10],即:

  • 间隙锁:(5, 10),阻止插入 id 在 5 到 10 之间的数据。
  • 记录锁:id = 10 这一行本身。

mermaid-diagram-2026-07-14-231913.png

4.3 临键锁的退化规则

InnoDB 会根据查询条件"智能"退化锁,减少锁范围:

查询条件退化结果原因
唯一索引等值查询,且命中记录记录锁(Record Lock)只有一行,不需要防插入
唯一索引等值查询,未命中记录间隙锁(Gap Lock)没有行可锁,只锁间隙
非唯一索引等值查询Next-Key Lock + 可能的间隙锁可能有多个行满足条件
范围查询Next-Key Lock必须锁住整个范围防幻读
-- 唯一索引等值命中 → 退化为记录锁
SELECT * FROM user WHERE id = 10 FOR UPDATE;
-- 锁住:id=10 这一行

-- 唯一索引等值未命中 → 退化为间隙锁
SELECT * FROM user WHERE id = 7 FOR UPDATE;
-- 锁住:间隙 (5,10)

-- 范围查询 → Next-Key Lock
SELECT * FROM user WHERE id > 5 AND id < 15 FOR UPDATE;
-- 锁住:(5,10] + (10,15)

4.4 插入意向锁(Insert Intention Lock)

插入一行之前,事务会先申请一个插入意向锁。它表示"我想在这个间隙里插入数据"。

插入意向锁的特点是:

  • 多个事务向同一个间隙的不同位置插入时,互相不冲突。
  • 但如果这个间隙已经被间隙锁锁住,插入意向锁就会等待。
-- 表 user 的 id 已有 1, 5, 10
-- 事务 A 和 B 同时向 (5,10) 间隙插入不同值
-- 事务 A: INSERT INTO user VALUES (6, ...)
-- 事务 B: INSERT INTO user VALUES (8, ...)
-- 两者不冲突,都能成功

-- 但如果事务 C 先执行:
SELECT * FROM user WHERE id > 5 AND id < 10 FOR UPDATE;
-- 事务 A 和 B 的插入都会被阻塞

五、🎯 MVCC + 间隙锁如何防止幻读

现在回到核心问题:InnoDB 的 RR 怎么防幻读?

答案是两条防线:

mermaid-diagram-2026-07-14-231938.png

5.1 快照读防幻读

普通 SELECT 走 MVCC 快照读。事务第一次 SELECT 时生成的 Read View 会固定下来,后续别的事务插入的新行,其 trx_id >= max_trx_id,对当前事务不可见。

所以即使别的事务插入了一行满足条件的数据,当前事务再次 SELECT 也看不到,因为它读的是旧快照。

-- 事务 A
BEGIN;
SELECT * FROM user WHERE age > 18;  -- 第一次读,生成 Read View
-- 结果:3 条

-- 事务 B 插入一条 age=20 并提交
INSERT INTO user VALUES (100, 'New', 20);
COMMIT;

-- 事务 A 再次查询
SELECT * FROM user WHERE age > 18;
-- 结果:仍然是 3 条(RR 快照读)

5.2 当前读防幻读

SELECT ... FOR UPDATE / UPDATE / DELETE 需要读最新数据,不能只看快照。这时就靠 Next-Key Lock 把范围锁住,阻止其他事务插入。

-- 事务 A
BEGIN;
SELECT * FROM user WHERE age > 18 FOR UPDATE;
-- 对 age > 18 的范围加 Next-Key Lock

-- 事务 B 想插入 age=20
INSERT INTO user VALUES (101, 'New2', 20);
-- ❌ 阻塞,等待事务 A 释放锁

-- 事务 A 再次查询
SELECT * FROM user WHERE age > 18 FOR UPDATE;
-- 结果仍然是 3 条,幻读被防住

5.3 为什么有人说 RR 还是会有"幻读"?

有一种特殊情况容易让人误解:

-- 事务 A
BEGIN;
SELECT * FROM user WHERE age > 18;  -- 快照读,3 条

-- 事务 B 插入 age=20 并提交
INSERT INTO user VALUES (102, 'New3', 20);
COMMIT;

-- 事务 A 执行 UPDATE
UPDATE user SET name = 'X' WHERE age > 18;
-- 当前读!会读到事务 B 插入的那行并加锁修改

-- 事务 A 再次快照读
SELECT * FROM user WHERE age > 18;
-- 结果变成了 4 条!

这看起来像是"幻读",但本质原因是:第一次是快照读,UPDATE 切换成了当前读。UPDATE 读到了事务 B 插入的那行,并对其加锁修改。修改后,这行数据的 trx_id 变成了事务 A 自己的 creator_trx_id,于是事务 A 再次快照读时就能看到它。

这不是 RR 没防住幻读,而是在事务内部先用快照读、再用当前读,导致自己把别的事务插入的数据拉进了自己的可见范围。如果业务需要严格保证范围查询结果不变,应该全程使用 SELECT ... FOR UPDATE,或者避免在事务中混合使用两种读。

六、⚠️ 常见误区与死锁

6.1 误区一:间隙锁只在 RR 下生效

✅ 正确。RC 隔离级别下,InnoDB 只加记录锁,不加间隙锁。所以 RC 下当前读是可能幻读的。

-- 设置 RC
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN;
SELECT * FROM user WHERE id > 10 FOR UPDATE;
-- 只锁 id=15, 20 两行,不锁间隙

-- 其他事务可以插入 id=12
INSERT INTO user VALUES (12, ...);
-- ✅ 成功

6.2 误区二:间隙锁之间会冲突

❌ 错误。间隙锁与间隙锁之间是兼容的。

-- 事务 A
SELECT * FROM user WHERE id = 7 FOR UPDATE;
-- 加间隙锁 (5,10)

-- 事务 B
SELECT * FROM user WHERE id = 8 FOR UPDATE;
-- 也加间隙锁 (5,10)
-- ✅ 不会阻塞

6.3 间隙锁导致的死锁

虽然间隙锁之间不冲突,但结合插入意向锁可能产生死锁:

-- 表 user 的 id: 1, 5

-- 事务 A
BEGIN;
SELECT * FROM user WHERE id = 3 FOR UPDATE;
-- 加间隙锁 (1,5)

-- 事务 B
BEGIN;
SELECT * FROM user WHERE id = 4 FOR UPDATE;
-- 也加间隙锁 (1,5)

-- 事务 A 插入
INSERT INTO user VALUES (2, ...);
-- 等待插入意向锁(与 B 的间隙锁冲突)

-- 事务 B 插入
INSERT INTO user VALUES (3, ...);
-- 等待插入意向锁(与 A 的间隙锁冲突)
-- 💥 死锁!InnoDB 会回滚其中一个事务

mermaid-diagram-2026-07-14-232032.png

七、📖 实战建议

7.1 选择合适的隔离级别

业务场景推荐隔离级别理由
只读报表 / 对账RRMVCC 快照读不加锁,长时间查询不阻塞写
高并发写入RC减少间隙锁,降低死锁概率
强一致性要求Serializable最安全,但性能最差
默认业务RR(MySQL 默认)兼顾一致性与并发

7.2 避免长事务

长事务会长时间持有 Read View 和锁,导致 undo log 无法及时清理,版本链越来越长,影响性能。

-- ❌ 错误:事务里调用外部 HTTP 接口
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
-- 调用支付回调 HTTP 接口(可能耗时几秒)
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;

-- ✅ 正确:事务最小化
-- 外部调用放在事务外
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;

7.3 确保查询走索引

没有索引时,UPDATE / DELETE 会锁全表或全表扫描,锁范围剧增。

-- ❌ 错误:没有索引,可能锁大量行
UPDATE user SET status = 1 WHERE age = 20;
-- 如果 age 没有索引,会全表扫描

-- ✅ 正确:给 age 加索引
CREATE INDEX idx_age ON user(age);
UPDATE user SET status = 1 WHERE age = 20;

7.4 按固定顺序加锁

多行更新时,按相同顺序加锁可以避免交叉死锁。

-- ❌ 错误:不同事务加锁顺序不一致
-- 事务 A:先锁 id=1,再锁 id=2
-- 事务 B:先锁 id=2,再锁 id=1

-- ✅ 正确:统一按 id 从小到大
BEGIN;
SELECT * FROM user WHERE id = 1 FOR UPDATE;
SELECT * FROM user WHERE id = 2 FOR UPDATE;
COMMIT;

八、📝 总结

一句话总结

MySQL InnoDB 的可重复读(RR)确实防住了幻读:普通 SELECT 靠 MVCC 的 Read View 读固定快照;SELECT ... FOR UPDATE / UPDATE / DELETE 等当前读靠 Next-Key Lock(行锁 + 间隙锁)阻止其他事务插入。MVCC 通过隐藏字段、undo log 版本链和 Read View 可见性规则实现;间隙锁锁住索引记录之间的空隙,专门防止"插入新行"导致的幻读。

速记核心

概念一句话
MVCC多版本并发控制,读写互不阻塞
隐藏字段DB_TRX_IDDB_ROLL_PTRDB_ROW_ID
Read View事务快照,记录活跃事务 ID
RC每条 SELECT 新 Read View
RR首次 SELECT 生成,事务复用
快照读普通 SELECT,不加锁
当前读FOR UPDATE / UPDATE / DELETE,加锁
间隙锁锁空隙,防插入
临键锁行锁 + 间隙锁,RR 默认
幻读RR 下被 MVCC + 间隙锁防住

九、高频面试题

Q1: MySQL 可重复读能防幻读吗?

。MySQL InnoDB 的 RR 通过两条路径防幻读:普通 SELECT 走 MVCC 快照读,Read View 固定,看不到别的事务新插入的行;FOR UPDATE / UPDATE / DELETE 走当前读,靠 Next-Key Lock 锁住范围阻止插入。

Q2: MVCC 是怎么实现的?

每行数据有三个隐藏字段:DB_TRX_ID(最后修改事务)、DB_ROLL_PTR(回滚指针)、DB_ROW_ID(隐藏主键)。修改时旧版本写入 undo log 形成版本链。事务第一次 SELECT 生成 Read View,包含 m_idsmin_trx_idmax_trx_idcreator_trx_id,根据可见性规则从最新版本沿版本链找到可见版本。

Q3: Read View 的可见性规则是什么?

对于自己修改的版本可见;trx_id < min_trx_id 的版本可见(已提交);trx_id >= max_trx_id 的版本不可见(未来事务);min_trx_id <= trx_id < max_trx_id 时,若 trx_idm_ids 中则不可见(未提交),不在则可见(已提交)。

Q4: RC 和 RR 的 Read View 有什么区别?

RC 下每条 SELECT 都会生成新的 Read View,所以能看到其他事务最新提交的数据;RR 下只有事务第一次 SELECT 生成 Read View,整个事务复用,因此实现可重复读。

Q5: 间隙锁锁的是什么?

间隙锁锁住的是索引记录之间的空隙,而不是记录本身。它的作用是阻止其他事务在空隙中插入新行,从而防止幻读。

Q6: 快照读和当前读有什么区别?

快照读是普通 SELECT,读 MVCC 快照,不加锁;当前读是 SELECT ... FOR UPDATEUPDATEDELETE,读最新已提交数据并加锁。

Q7: 临键锁和间隙锁的关系是什么?

临键锁(Next-Key Lock)= 间隙锁(Gap Lock)+ 记录锁(Record Lock)。它是 InnoDB 在 RR 下的默认锁,区间是左开右闭 (前一条记录, 当前记录]

十、面试速记 10 条

#要点
1MySQL RR 防住了幻读(MVCC + 间隙锁)
2MVCC = 隐藏字段 + undo log + Read View
3RC 每条 SELECT 生成 Read View
4RR 首次 SELECT 生成 Read View,事务复用
5快照读靠 Read View,当前读靠锁
6间隙锁锁间隙,防插入
7临键锁 = 行锁 + 间隙锁
8FOR UPDATE 是当前读
9MySQL RR ≠ SQL 标准 RR(多了间隙锁)
10混合快照读和当前读可能出现"自我修改可见"