
一、上回说到哪了Java事务与MySQL事务的关系及MVCC通俗解析上篇文章咱们把 MVCC 的基本盘聊透了——隐藏字段、Undo Log 版本链、ReadView 快照机制。用一句话概括就是事务开始时拍张照之后只看照片别人怎么改都不影响我。如果你还没看过上篇建议先去补一下不然这篇可能会有点跳跃。好咱们继续。今天要聊的是 MVCC 的进阶话题也是面试高频区快照读和当前读到底有啥区别MVCC 能不能防幻读Next-Key Lock 又是什么东西这几个问题我曾经被面试官连环追问过当时答得稀碎。后来花了很长时间才理清楚今天把我的理解分享出来。二、快照读 vs 当前读一个被严重低估的区别很多人包括曾经的我以为SELECT就是读数据嘛有什么好区分的区别大了。InnoDB 里有两种完全不同的读快照读Snapshot Read-- 这就是快照读 SELECT * FROM account WHERE name 张三;特点读的是历史版本快照不加锁。上篇文章讲的 MVCC 机制就是服务于快照读的。你看到的数据是事务开始那一刻的照片别人改没改、提交没提交都不影响你。当前读Current Read-- 以下全是当前读 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 一个场景-- 初始数据account 表有 id1, id5, id10 三条记录 -- 事务Aid100 BEGIN; SELECT * FROM account WHERE id 1 AND id 10; -- 结果id1, id5, id10 ✅ 三条 -- 此时事务Bid200插了一条 -- BEGIN; -- INSERT INTO account VALUES(7, 赵六, 3000); -- COMMIT; -- 事务A 继续执行 SELECT * FROM account WHERE id 1 AND id 10; -- 快照读结果还是id1, id5, id10 ✅ MVCC 保护了我没有幻读到这里为止MVCC 表现完美对吧快照读确实防住了幻读。但是别高兴太早。看下面这个场景-- 事务Aid100 BEGIN; SELECT * FROM account WHERE id 1 AND id 10; -- 结果id1, id5, id10 三条 -- 事务Bid200 -- BEGIN; -- INSERT INTO account VALUES(7, 赵六, 3000); -- COMMIT; -- 事务A 接下来执行了一个 UPDATE当前读 UPDATE account SET balance 0 WHERE id 1 AND id 10; -- 影响了几行 4 行它把 id7 也改了 -- 事务A 再次 SELECT快照读 SELECT * FROM account WHERE id 1 AND id 10; -- 结果id1, id5, id7, id10 四条 -- id7 凭空出现了 -- 幻读等等为什么 MVCC 没防住3.3 为什么会幻读拆解每一步咱们一步一步走T1事务A 做了快照读 → ReadView 生成 → 看到 id1, 5, 10 此时版本链 id1 TRX_ID50 (已提交) id5 TRX_ID50 (已提交) id10 TRX_ID50 (已提交) id7 还不存在 T2事务B (TRX_ID200) 插入 id7 并提交 T3事务A 执行 UPDATE ... WHERE id 1 AND id 10 关键点UPDATE 是当前读它不看快照它读的是最新版本 所以它看到了事务B刚插入的 id7 然后它修改了 id7 这行把这行的 DB_TRX_ID 改成了 100事务A自己的ID T4事务A 再做快照读 看到 id7 这行的 DB_TRX_ID 100 我自己的ID 根据 ReadView 规则自己的修改当然可见 ✅ 所以 id7 出现在结果集中 → 幻读根因找到了快照读看快照当前读看实时。当一个事务里混用了快照读和当前读当前读会突破快照的边界把别人新提交的数据拉进来。一旦修改了这些数据它们就变成了我自己的修改后续的快照读就能看到了。MVCC 搞不定这个场景。它能保证纯快照读的事务内一致性但管不了当前读的越界。四、救星登场Next-Key Lock既然 MVCC 自己搞不定InnoDB 就派出了另一个大将——Next-Key Lock临键锁。4.1 先理解两个基础锁在讲 Next-Key Lock 之前咱们先搞清楚它的两个组成部分Record Lock记录锁表中数据 id1 id5 id10 Record Lock on id5 只锁住 id5 这一行 其他行不受影响就像停车场里你给 5 号车位放了个已占的牌子。Gap Lock间隙锁表中数据 id1 id5 id10 Gap Lock on (1, 5) 锁住 id1 和 id5 之间的空隙 不让任何人在这个区间里插入新数据 注意锁的是间隙不锁已有数据就像停车场里3号车位是空的但你放了锥桶不让别人停进来。Next-Key Lock临键锁 Record Lock Gap Lock表中数据 id1 id5 id10 Next-Key Lock on id5锁定范围 (1, 5] 既锁住了 id5 这条记录Record Lock 又锁住了 (1, 5) 这个间隙Gap Lock 结果id5 不能被修改也不能在 1~5 之间插入新数据就像你不仅占了 5 号车位还在 4 号和 5 号之间放了锥桶。车位你的缝隙也不让别人挤。4.2 Next-Key Lock 怎么防幻读回到刚才的幻读场景如果事务A 聪明一点-- 事务Aid100 BEGIN; -- 不用普通 SELECT改用当前读 锁 SELECT * FROM account WHERE id 1 AND id 10 FOR UPDATE; -- 结果id1, id5, id10 -- 但同时加了 Next-Key Lock -- 锁定范围(0,1], (1,5], (5,10], (10, ∞) -- 整个区间都被锁住了 此时事务Bid200 INSERT INTO account VALUES(7, 赵六, 3000); -- ❌ 阻塞被 Gap Lock 挡住了 -- 因为 id7 落在 (5, 10) 这个间隙里而这个间隙被锁了 -- 事务B 只能等事务A 提交/回滚后才能插入幻读被彻底杜绝了。┌──────────────────────────────────────────────────────────┐ │ id1 (1,5) id5 (5,10) id10 │ │ ┌──┐ ┌──┐ ┌──┐ │ │ │██│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│██│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│██│ │ │ └──┘ └──┘ └──┘ │ │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 双管齐下。操作类型防幻读靠谁机制快照读普通 SELECTMVCC通过 ReadView 快照看不到别人新插入的行当前读SELECT FOR UPDATE / UPDATE / DELETENext-Key Lock通过锁定间隙阻止别人在范围内插入新行两个配合起来RR 级别下基本不会出现幻读。但请注意这个基本——有一个经典的边界情况还是能触发幻读5.1 仍然可能幻读的边界场景-- 事务A先快照读不加锁 BEGIN; SELECT * FROM account WHERE id 7; -- 结果Empty setid7 不存在 -- 事务B插入 id7 并提交 -- BEGIN; -- INSERT INTO account VALUES(7, 赵六, 3000); -- COMMIT; -- 事务A想插入 id7 INSERT INTO account VALUES(7, 赵六, 3000); -- ❌ Duplicate entry 7 for key PRIMARY -- 事务A刚才不是查不到吗 SELECT * FROM account WHERE id 7; -- 现在居然查到了 id7 -- 因为 INSERT 是当前读读到了事务B已经提交的 id7 -- 而你的 INSERT 虽然失败了但 UPDATE/INSERT 的当前读已经把这行拉进来了为什么因为第一步的SELECT是快照读没加任何锁也就没触发 Gap Lock。如果第一步用的是SELECT ... FOR UPDATE事务B 的 INSERT 就会被阻塞这个幻读就不会发生。所以最佳实践是如果你的业务逻辑是先查后插请用SELECT ... FOR UPDATE而不是普通 SELECT让 Next-Key Lock 保护你。5.2 总结表格场景纯 MVCCMVCC Next-Key Lock快照读 别人插入✅ 能防✅ 能防当前读 别人插入❌ 防不住✅ 能防先快照读再INSERT先查后插❌ 可能报主键冲突✅ SELECT FOR UPDATE 加锁防住六、实战走一遍完整的 SQL把所有知识点串起来走一个完整的场景-- 准备建表 插入初始数据 CREATE TABLE order_ticket ( id int PRIMARY KEY, seat_no varchar(10) UNIQUE, status varchar(10) ) ENGINEInnoDB; INSERT INTO order_ticket VALUES (1, A1, sold), (5, A5, sold), (10, A10, sold); -- 场景两个用户同时抢票票号 A3id3 -- 用户1 的事务 -- 事务A (TRX_ID100) BEGIN; -- 想抢 A3先查一下有没有被卖 SELECT * FROM order_ticket WHERE id 3; -- 快照读 → Empty set → 太好了还没人买 -- ⚠️ 但是这里没加锁Gap Lock 没生效 -- 用户1 准备下单耗时较长模拟业务延迟... -- ... -- 用户2 的事务 -- 事务B (TRX_ID200) 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。正确做法-- 用户1 的正确写法 BEGIN; -- 用 FOR UPDATE 加锁当前读 Next-Key Lock SELECT * FROM order_ticket WHERE id 3 FOR UPDATE; -- 快照读结果Empty set -- 但是InnoDB 加了 Gap Lock锁住了 id3 所在的间隙 (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 帮你守住间隙。七、灵魂拷问面试怎么答既然聊到这儿了顺手整理几个面试高频问题的答案QMySQL RR 级别下能完全防止幻读吗纯靠 MVCC 不能完全防止。MVCC 保证了快照读不会幻读但当前读需要配合 Next-Key Lock 才能防住。如果事务中先做快照读再做当前读/INSERT存在幻读的边界情况。QMVCC 解决了什么问题解决了读写冲突。在 MVCC 之前读要加锁写也要加锁读写互斥并发性能很差。MVCC 让读操作去看历史快照不用加锁读写互不阻塞。QRC 和 RR 的本质区别是什么ReadView 的生成时机不同。RC 每次 SELECT 重新生成RR 只在第一次 SELECT 时生成一次。QUndo 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 基本够用。