一、上回说到哪了?
上篇文章咱们把 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 就是走到书架前看最新版,还顺手加了个锁。UPDATE 和 DELETE 更不用说了,你得先看到最新数据才能改,所以必然是当前读。
这个区别重要吗?太重要了。 因为接下来要讲的幻读问题,根源就在这里。
三、幻读: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 基本够用。