MVCC 进阶:快照读 vs 当前读、幻读与 Next-Key Lock

一、上回说到哪了?

Java事务与MySQL事务的关系及MVCC通俗解析

上篇文章咱们把 MVCC 的基本盘聊透了------隐藏字段、Undo Log 版本链、ReadView 快照机制。用一句话概括就是:

事务开始时拍张照,之后只看照片,别人怎么改都不影响我。

如果你还没看过上篇,建议先去补一下,不然这篇可能会有点跳跃。好,咱们继续。

今天要聊的是 MVCC 的进阶话题,也是面试高频区:

  • 快照读和当前读到底有啥区别?

  • MVCC 能不能防幻读?

  • Next-Key Lock 又是什么东西?

这几个问题,我曾经被面试官连环追问过,当时答得稀碎。后来花了很长时间才理清楚,今天把我的理解分享出来。


二、快照读 vs 当前读:一个被严重低估的区别

很多人(包括曾经的我)以为 SELECT 就是读数据嘛,有什么好区分的?

区别大了。 InnoDB 里有两种完全不同的"读":

快照读(Snapshot Read)

sql 复制代码
-- 这就是快照读
SELECT * FROM account WHERE name = '张三';

特点:读的是历史版本(快照),不加锁。

上篇文章讲的 MVCC 机制,就是服务于快照读的。你看到的数据是事务开始那一刻的"照片",别人改没改、提交没提交,都不影响你。

当前读(Current Read)

sql 复制代码
-- 以下全是当前读
SELECT * FROM account WHERE name = '张三' FOR UPDATE;       -- 加排他锁
SELECT * FROM account WHERE name = '张三' LOCK IN SHARE MODE; -- 加共享锁
INSERT INTO account VALUES (3, '王五', 2000);                -- 插入
UPDATE account SET balance = 800 WHERE name = '张三';        -- 更新
DELETE FROM account WHERE name = '张三';                     -- 删除

特点:读的是最新已提交版本,而且会加锁。

一句话区分:快照读看的是"老照片",当前读看的是"实时监控"。

为什么要区分?用个生活例子

你去图书馆借书:

  • 快照读 ≈ 你拿着一本上个月出版的杂志在看。管它现在封面换没换,你手里的就是你手里那本。

  • 当前读 ≈ 你走到书架前,拿最新一期,同时在书架上贴个"此位置有人在选"的标签,不让别人动。

SELECT ... FOR UPDATE 就是走到书架前看最新版,还顺手加了个锁。UPDATEDELETE 更不用说了,你得先看到最新数据才能改,所以必然是当前读。

这个区别重要吗?太重要了。 因为接下来要讲的幻读问题,根源就在这里。


三、幻读:MVCC 的"阿喀琉斯之踵"/软肋

3.1 幻读是什么?

先上定义:

同一个事务中,两次执行相同的范围查询,第二次查询"凭空多出了"第一次没有的行。

这叫幻读(Phantom Read)------那些行就像幻影一样突然冒出来了。

3.2 一个场景

sql 复制代码
-- 初始数据:account 表有 id=1, id=5, id=10 三条记录
​
-- 事务A(id=100)
BEGIN;
SELECT * FROM account WHERE id >= 1 AND id <= 10;
-- 结果:id=1, id=5, id=10  ✅ 三条
​
        -- 此时事务B(id=200)插了一条:
        -- BEGIN;
        -- INSERT INTO account VALUES(7, '赵六', 3000);
        -- COMMIT;
​
-- 事务A 继续执行
SELECT * FROM account WHERE id >= 1 AND id <= 10;
-- 快照读,结果还是:id=1, id=5, id=10 ✅ MVCC 保护了我!没有幻读!

到这里为止,MVCC 表现完美,对吧?快照读确实防住了幻读。

但是,别高兴太早。 看下面这个场景:

sql 复制代码
-- 事务A(id=100)
BEGIN;
SELECT * FROM account WHERE id >= 1 AND id <= 10;
-- 结果:id=1, id=5, id=10  三条
​
        -- 事务B(id=200)
        -- BEGIN;
        -- INSERT INTO account VALUES(7, '赵六', 3000);
        -- COMMIT;
​
-- 事务A 接下来执行了一个 UPDATE(当前读!)
UPDATE account SET balance = 0 WHERE id >= 1 AND id <= 10;
-- 影响了几行??? 4 行!它把 id=7 也改了!!!
​
-- 事务A 再次 SELECT(快照读)
SELECT * FROM account WHERE id >= 1 AND id <= 10;
-- 结果:id=1, id=5, id=7, id=10  四条!!!
-- id=7 凭空出现了!!!
-- 💀 幻读!

等等,为什么 MVCC 没防住???

3.3 为什么会幻读?拆解每一步

咱们一步一步走:

TypeScript 复制代码
T1:事务A 做了快照读 → ReadView 生成 → 看到 id=1, 5, 10
    此时版本链:
    id=1  TRX_ID=50  (已提交)
    id=5  TRX_ID=50  (已提交)
    id=10 TRX_ID=50  (已提交)
    (id=7 还不存在)
​
T2:事务B (TRX_ID=200) 插入 id=7 并提交
​
T3:事务A 执行 UPDATE ... WHERE id >= 1 AND id <= 10
    关键点:UPDATE 是当前读!它不看快照,它读的是最新版本!
    所以它"看到了"事务B刚插入的 id=7
    然后它修改了 id=7 这行,把这行的 DB_TRX_ID 改成了 100(事务A自己的ID)
​
T4:事务A 再做快照读
    看到 id=7 这行的 DB_TRX_ID = 100 = 我自己的ID
    根据 ReadView 规则:自己的修改,当然可见 ✅
    所以 id=7 出现在结果集中 → 幻读!

根因找到了:

🔑 快照读看快照,当前读看实时。 当一个事务里混用了快照读和当前读,当前读会"突破"快照的边界,把别人新提交的数据拉进来。一旦修改了这些数据,它们就变成了"我自己的修改",后续的快照读就能看到了。

MVCC 搞不定这个场景。它能保证纯快照读的事务内一致性,但管不了当前读的"越界"。


四、救星登场:Next-Key Lock

既然 MVCC 自己搞不定,InnoDB 就派出了另一个大将------Next-Key Lock(临键锁)

4.1 先理解两个基础锁

在讲 Next-Key Lock 之前,咱们先搞清楚它的两个组成部分:

Record Lock(记录锁)
TypeScript 复制代码
表中数据:  id=1  id=5  id=10
​
Record Lock on id=5:
    只锁住 id=5 这一行
    其他行不受影响

就像停车场里,你给 5 号车位放了个"已占"的牌子。

Gap Lock(间隙锁)
TypeScript 复制代码
表中数据:  id=1  id=5  id=10
​
Gap Lock on (1, 5):
    锁住 id=1 和 id=5 之间的"空隙"
    不让任何人在这个区间里插入新数据
    注意:锁的是间隙,不锁已有数据

就像停车场里,3号车位是空的,但你放了锥桶,不让别人停进来

Next-Key Lock(临键锁)= Record Lock + Gap Lock
TypeScript 复制代码
表中数据:  id=1  id=5  id=10
​
Next-Key Lock on id=5(锁定范围 (1, 5]):
    既锁住了 id=5 这条记录(Record Lock)
    又锁住了 (1, 5) 这个间隙(Gap Lock)
    结果:id=5 不能被修改,也不能在 1~5 之间插入新数据

就像你不仅占了 5 号车位,还在 4 号和 5 号之间放了锥桶。车位你的,缝隙也不让别人挤。

4.2 Next-Key Lock 怎么防幻读?

回到刚才的幻读场景,如果事务A 聪明一点:

sql 复制代码
-- 事务A(id=100)
BEGIN;
​
-- 不用普通 SELECT,改用当前读 + 锁
SELECT * FROM account WHERE id >= 1 AND id <= 10 FOR UPDATE;
-- 结果:id=1, id=5, id=10
-- 但!同时加了 Next-Key Lock!
-- 锁定范围:(0,1], (1,5], (5,10], (10, +∞)
-- 整个区间都被锁住了!
此时事务B(id=200):
INSERT INTO account VALUES(7, '赵六', 3000);
-- ❌ 阻塞!被 Gap Lock 挡住了!
-- 因为 id=7 落在 (5, 10) 这个间隙里,而这个间隙被锁了
-- 事务B 只能等事务A 提交/回滚后才能插入

幻读被彻底杜绝了。

TypeScript 复制代码
┌──────────────────────────────────────────────────────────┐
│  id=1       (1,5)       id=5       (5,10)      id=10     │
│  ┌──┐                  ┌──┐                 ┌──┐         │
│  │██│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│██│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│██│         │
│  └──┘                  └──┘                 └──┘         │
│Record Lock  Gap Lock  Record Lock Gap Lock Record Lock   │
│                                                          │
│  整个区间都被 Next-Key Lock 覆盖,蚂蚁都插不进来            │
└──────────────────────────────────────────────────────────┘

4.3 一个精妙的比喻

🅿️ 想象一个停车场:

  • Record Lock:你把车停在了 5 号位,放了"已占"的牌子。

  • Gap Lock:你在 5 号和 6 号位之间的空隙放了锥桶,不让别人挤进来停。

  • Next-Key Lock:你同时做了以上两件事------占了车位,还把旁边的缝隙堵了。

你想停新车进来(INSERT)?先把锥桶挪开(等锁释放)。不好意思,锥桶的主人(事务A)还没走呢。


五、RR 隔离级别下,InnoDB 到底怎么防幻读的?

答案是:MVCC + Next-Key Lock 双管齐下。

操作类型 防幻读靠谁 机制
快照读(普通 SELECT) MVCC 通过 ReadView 快照,看不到别人新插入的行
当前读(SELECT FOR UPDATE / UPDATE / DELETE) Next-Key Lock 通过锁定间隙,阻止别人在范围内插入新行

两个配合起来,RR 级别下基本不会出现幻读。

但请注意这个"基本"------有一个经典的边界情况还是能触发幻读:

5.1 仍然可能幻读的边界场景

sql 复制代码
-- 事务A:先快照读(不加锁)
BEGIN;
SELECT * FROM account WHERE id = 7;
-- 结果:Empty set(id=7 不存在)
​
        -- 事务B:插入 id=7 并提交
        -- BEGIN;
        -- INSERT INTO account VALUES(7, '赵六', 3000);
        -- COMMIT;
​
-- 事务A:想插入 id=7
INSERT INTO account VALUES(7, '赵六', 3000);
-- ❌ Duplicate entry '7' for key 'PRIMARY'!!!
​
-- 事务A:???刚才不是查不到吗??
SELECT * FROM account WHERE id = 7;
-- 现在居然查到了 id=7!!!
-- 因为 INSERT 是当前读,读到了事务B已经提交的 id=7
-- 而你的 INSERT 虽然失败了,但 UPDATE/INSERT 的当前读已经把这行"拉"进来了

为什么? 因为第一步的 SELECT 是快照读,没加任何锁,也就没触发 Gap Lock。如果第一步用的是 SELECT ... FOR UPDATE,事务B 的 INSERT 就会被阻塞,这个幻读就不会发生。

📌 所以最佳实践是:如果你的业务逻辑是"先查后插",请用 SELECT ... FOR UPDATE 而不是普通 SELECT,让 Next-Key Lock 保护你。

5.2 总结表格

场景 纯 MVCC MVCC + Next-Key Lock
快照读 + 别人插入 ✅ 能防 ✅ 能防
当前读 + 别人插入 ❌ 防不住 ✅ 能防
先快照读,再INSERT(先查后插) ❌ 可能报主键冲突 ✅ SELECT FOR UPDATE 加锁防住

六、实战走一遍完整的 SQL

把所有知识点串起来,走一个完整的场景:

sql 复制代码
-- 准备:建表 + 插入初始数据
CREATE TABLE `order_ticket` (
  `id` int PRIMARY KEY,
  `seat_no` varchar(10) UNIQUE,
  `status` varchar(10)
) ENGINE=InnoDB;
​
INSERT INTO order_ticket VALUES
(1, 'A1', 'sold'),
(5, 'A5', 'sold'),
(10, 'A10', 'sold');
​
-- 场景:两个用户同时抢票,票号 A3(id=3)
-- ============ 用户1 的事务 ============
-- 事务A (TRX_ID=100)
BEGIN;
​
-- 想抢 A3,先查一下有没有被卖
SELECT * FROM order_ticket WHERE id = 3;
-- 快照读 → Empty set → "太好了,还没人买!"
-- ⚠️ 但是!这里没加锁,Gap Lock 没生效!
​
-- 用户1 准备下单,耗时较长(模拟业务延迟)...
-- ...
​
-- ============ 用户2 的事务 ============
-- 事务B (TRX_ID=200)
BEGIN;
INSERT INTO order_ticket VALUES(3, 'A3', 'sold');
COMMIT;
-- ✅ 插入成功!A3 已经被用户2 抢走了
​
-- ============ 用户1 继续 ============
-- 用户1 想插入 A3
INSERT INTO order_ticket VALUES(3, 'A3', 'sold');
-- ❌ Duplicate entry '3' for key 'PRIMARY'
-- 💀 用户1:???刚才不是查不到吗???
​
-- 再查一下
SELECT * FROM order_ticket WHERE id = 3;
-- 居然查到了!用户2 的数据出现了!幻读!
COMMIT;

问题出在哪? 第一步用了普通 SELECT(快照读),没加锁,Gap Lock 没生效,挡不住用户2。

正确做法

sql 复制代码
-- ============ 用户1 的正确写法 ============
BEGIN;
​
-- 用 FOR UPDATE 加锁(当前读 + Next-Key Lock)
SELECT * FROM order_ticket WHERE id = 3 FOR UPDATE;
-- 快照读结果:Empty set
-- 但是!InnoDB 加了 Gap Lock,锁住了 id=3 所在的间隙 (1, 5)
-- 任何人想在这个间隙里插入数据,都会被阻塞
​
-- ============ 用户2 的事务 ============
BEGIN;
INSERT INTO order_ticket VALUES(3, 'A3', 'sold');
-- ⏳ 阻塞!被 Gap Lock 挡住了,等待中...
​
-- ============ 用户1 继续 ============
INSERT INTO order_ticket VALUES(3, 'A3', 'sold');
-- ✅ 插入成功!因为锁在自己手里
COMMIT;
-- 用户1 拿到票了 🎉
​
-- ============ 用户2 ============
-- 事务A 提交后,Gap Lock 释放
-- 用户2 的 INSERT 继续执行
-- ❌ Duplicate entry '3' for key 'PRIMARY'
-- 用户2 抢票失败 💔
COMMIT;

🎯 "先查后插"的经典防坑姿势:SELECT ... FOR UPDATE,让 Next-Key Lock 帮你守住间隙。


七、灵魂拷问:面试怎么答?

既然聊到这儿了,顺手整理几个面试高频问题的答案:

Q:MySQL RR 级别下能完全防止幻读吗?

纯靠 MVCC 不能完全防止。MVCC 保证了快照读不会幻读,但当前读需要配合 Next-Key Lock 才能防住。如果事务中先做快照读再做当前读/INSERT,存在幻读的边界情况。

Q:MVCC 解决了什么问题?

解决了读写冲突。在 MVCC 之前,读要加锁,写也要加锁,读写互斥,并发性能很差。MVCC 让读操作去看历史快照,不用加锁,读写互不阻塞。

Q:RC 和 RR 的本质区别是什么?

ReadView 的生成时机不同。RC 每次 SELECT 重新生成,RR 只在第一次 SELECT 时生成一次。

Q:Undo Log 在 MVCC 中的角色?

它是"版本链"的存储介质。每次修改数据时,旧版本存入 Undo Log,通过 ROLL_PTR 指针串成链表。MVCC 沿着这条链找历史版本。


八、最后说两句

两篇文章下来,咱们把 MVCC 从基础到进阶过了一遍:

上篇 本篇
Java事务 vs MySQL事务的关系 快照读 vs 当前读
MVCC 三件套(隐藏字段、Undo Log、ReadView) 幻读问题的根因
RR vs RC 的区别 Next-Key Lock 的原理与实战
"先查后插"的防坑方案

如果上篇是"MVCC 的骨架",那这篇就是"MVCC 的肌肉和关节"。

说实话,InnoDB 的锁机制远不止这些------还有意向锁、插入意向锁、自增锁......但那些属于更偏内核的细节了。咱们做业务开发,把 MVCC + Next-Key Lock 的原理搞清楚,日常排查问题和写 SQL 基本够用。

相关推荐
京韵养生记1 小时前
【无标题】
java·服务器·前端
水木流年追梦1 小时前
agent面试必备31- AI Agent 核心进阶:工具路由(Tool Routing)
数据库·人工智能·oracle·面试·职场和发展·embedding
小强库计算机毕业设计1 小时前
源码分享Spring Boot + Vue3 的校园社团管理系统
java·spring boot·后端·计算机毕业设计
格子软件2 小时前
2026年分布式GEO代理流量调度:源码级状态机防重挂实战
java·vue.js·人工智能·spring boot·分布式·vue
hj2862512 小时前
Docker 容器化技术标准化笔记
java·笔记·docker
我是一颗柠檬2 小时前
【Java项目技术亮点】EXPLAIN深度分析与慢查询治理
android·java·开发语言
xcLeigh2 小时前
KES运维自动化与脚本体系实战
运维·数据库·自动化·脚本·数据迁移·kes
万亿少女的梦1682 小时前
基于Spring Boot的社区管理系统设计与实现
java·spring boot·mysql·vue·系统设计
大气的小蜜蜂2 小时前
领域层的服务
java·前端·数据库