Skip to content

MVCC

MVCC(多版本并发控制)是 InnoDB 隔离性的另一半答案:锁管"写与写",MVCC 管"读与写"。本章从 undo 版本链、ReadView 可见性算法、RC/RR 的本质差异(ReadView 生成时机)讲到快照读与当前读的分叉,再落到 purge 滞后与长事务的生产治理。版本基线 MySQL 8.4 LTS;前置概念见 05-事务与锁.md(锁)与 07-日志体系.md(undo/redo)。


1. 引子:为什么纯加锁的并发不行

设想只有锁的世界:事务 A 改一行要持 X 锁到提交,那么任何人想读这一行都得等——读写互斥意味着一个长事务更新热点行时,整条只读链路被拖死。反过来给读加 S 锁,读与读虽兼容,写却要等所有读者离开,吞吐同样难看。

MVCC 的思路:修改数据时保留旧版本,读请求按规则各取所需的版本,不用等写。于是:

  • 快照读(一致性读,consistent read):普通 SELECT,读 MVCC 版本链上"对我可见"的那个版本,不加锁;
  • 当前读(locking read):UPDATE / DELETE / SELECT ... FOR UPDATE / FOR SHARE / INSERT / REPLACE / ON DUPLICATE KEY UPDATE,读最新版本并按 05-事务与锁.md 的规则加锁。

一句话定位:读不加锁(快照读),写串行化(当前读 + 锁),两套规则共存的全部学问,就是"可见性"。


2. undo 版本链:旧版本存在哪

2.1 隐藏列

InnoDB 聚簇索引的每条记录除了业务列,还带隐藏列(记录格式细节指 01-核心概念与架构.md / 03-数据类型与建表.md):

隐藏列 作用
DB_TRX_ID 最后一次修改这行的事务 id(6 字节,全局递增分配)
DB_ROLL_PTR 回滚指针,指向这行上一个版本所在的 undo log 记录
DB_ROW_ID 无主键时自动生成的隐藏 row id

想验证"谁最后改的行",可以看活跃事务的 id:

START TRANSACTION;
SELECT trx_id, trx_state, trx_started, trx_rows_modified
FROM information_schema.innodb_trx
WHERE trx_mysql_thread_id = CONNECTION_ID();

事务 id 是接触 InnoDB 表时才惰性分配的(innodb_trx 是"当前世界有哪些活跃事务"的直接窗口,也是第 8 节抓长事务的依据);但记录上的 DB_TRX_ID 本身没有 SQL 接口能直接 SELECT 出来——版本链是引擎内部结构,本章的推演全部靠"行为反推",这也正是 MVCC 调试难的地方。

2.2 版本链的构造

一行被改 3 次,物理上聚簇索引里只有"最新值"一条记录,旧值顺着回滚指针串进 undo:

CREATE TABLE t (id INT PRIMARY KEY, name VARCHAR(16));
INSERT INTO t VALUES (1, '张三');          -- 事务 100 提交,版本 A
UPDATE t SET name='李四' WHERE id=1;        -- 事务 200 提交,版本 B(旧值张三写进 undo)
UPDATE t SET name='王五' WHERE id=1;        -- 事务 300 提交,版本 C
UPDATE t SET name='赵六' WHERE id=1;        -- 事务 400 提交,版本 D
聚簇索引当前记录                undo 段(旧版本依次串成链)
┌────────────────────┐  ROLL_PTR  ┌──────────────────┐        ┌──────────────────┐        ┌──────────────────┐
│ name=赵六          │──────────▶│ name=王五        │──────▶│ name=李四        │──────▶│ name=张三        │
│ TRX_ID=400        │            │ TRX_ID=300       │       │ TRX_ID=200       │       │ TRX_ID=100       │
└────────────────────┘            └──────────────────┘        └──────────────────┘        └──────────────────┘(→ NULL,链尾)

每个版本自带"谁改的(DB_TRX_ID)“和"下一版在哪(DB_ROLL_PTR)"——这就是可见性判断能沿链回溯的资本。

2.3 undo 的双重职责

同一段 undo log 同时喂两套机制:

  1. 回滚:ROLLBACK 沿链把旧值写回(原子性视角,指 07-日志体系.md);
  2. 快照读:MVCC 沿链找可见版本(隔离性视角,本章)。

所以"undo 被回收得太早,快照就没得读”——回收时机由 purge 控制,长事务会卡住它(第 8 节)。

2.4 二级索引上的事务 id

版本链挂在聚簇索引上,但二级索引记录也带一个隐藏的事务 id(标记该索引项最后一次被修改的事务,无回滚指针)。快照读走二级索引时,InnoDB 可先用索引项上这个 id 做一次廉价的可见性预判,避免大量"注定不可见"的条目回表爬版本链。这条优化路径细节(预判失败如何回退到聚簇索引判断、purge 对二级索引项的清理次序)各版本实现有差异,建议以 8.4 实测与官方手册"InnoDB Secondary Indexes and Transaction IDs"一节为准,此处仅作机制认知。


3. ReadView:我的"可见性身份证"

快照读要回答"链上哪个版本我能看"。InnoDB 在生成 ReadView 的那一刻拍下并发快照,四要素:

字段 含义
m_ids 拍快照时仍活跃(已启动未提交)的事务 id 集合
min_trx_id m_ids 中的最小值(当前最早活跃事务)
max_trx_id 系统下一个将分配的事务 id(快照里不存在比它更大的已发生事务)
creator_trx_id 我自己的事务 id

3.1 可见性判断算法

对版本链从当前版本开始,检查每个版本的 DB_TRX_ID = x,按顺序四步:

1. x == creator_trx_id        → 自己改的 → 可见
2. x < min_trx_id             → 生成快照前就已提交的老人 → 可见
3. x >= max_trx_id            → 快照拍下去之后才出生的事务 → 不可见
4. min_trx_id <= x < max_trx_id:
      x ∈ m_ids               → 拍快照时还活跃着 → 当时未提交 → 不可见
      x ∉ m_ids               → 拍快照前已提交 → 可见
不可见 → 沿 DB_ROLL_PTR 取上一版本,回到第 1 步;走到链尾还没有 → 这行对我"不存在"

3.2 拿版本链从头推一遍

沿 2.2 的链,设两个读者(读的时刻链已完整,区别只在各自 ReadView 拍下的瞬间):

读者 A:事务 creator=350,快照拍在事务 100~400 全部提交之后、无新事务活跃时:m_ids=[],min_trx_id=max_trx_id=401,creator=350。

  • 版本 D(x=400):不等于 creator;走规则 2:400 < min(401) → 可见,直接返回"赵六"。

读者 B:事务 creator=250,快照拍在事务 300 正活跃(未提交)、事务 400 尚未出生时:m_ids=[300],min_trx_id=300,max_trx_id=400,creator=250。

  • 版本 D(x=400):走规则 3:400 ≥ max(400) → 不可见,沿链;
  • 版本 C(x=300):300 ≥ max? 否;300 ∈ m_ids → 不可见——哪怕 300 此刻早已提交,它"在拍下瞬间还活跃"这一事实不会更新到视图里,沿链;
  • 版本 B(x=200):200 < min(300) → 可见,返回"李四"。

B 的世界里"王五/赵六"从未发生,它读到的是李四——这就是"读不加锁也能看到过去"的全部机制。两个最容易写错的边界细节:与 max_trx_id 的比较用 >= 而不是 >(x 恰好等于 max 判不可见);事务 id 单调递增分配,凡在快照之后出生的事务,id 必然 ≥ 快照的 max_trx_id,靠规则 3 就能拦住——m_ids 只记录"拍下那一刻"的活跃集,后来才出生的事务永远轮不到它来判断。

3.3 ReadView 看不见,怎么自查

没有 SQL 接口能直接打印一个事务的 ReadView 内容(m_ids 等不对外暴露)。工程上验证"自己到底看到了哪个版本"的方法只有两条:

  1. 行为测试:双客户端跑时序(本章所有双栏例子都可以原样复现),用返回值的差异反推视图生成时机;
  2. 活跃集旁证:information_schema.innodb_trx 看"拍快照瞬间谁还活着",再按 3.1 算法手推一遍。

也就是说:ReadView 是推导出来的,不是查出来的——排查数据不一致时,先画出"谁在哪一刻拍了快照"的时间线,比翻任何视图都管用。


4. RC vs RR 的本质差异:ReadView 什么时候生成

两个隔离级用的版本链、算法完全一样,唯一分歧是 ReadView 生成时机:

  • READ COMMITTED:每条快照读语句重新拍一份 ReadView;
  • REPEATABLE READ:事务内第一条快照读拍一份,整个事务复用。

同一份时间线,两种结局:

-- 初始 t: id=1, v=1
-- 时间   会话 1(事务 A)            会话 2(事务 B)
T1      START TRANSACTION;
T2      SELECT v FROM t
        WHERE id=1;          → 1
        -- RR:此刻建 ReadView 并冻结;RC:每次 SELECT 新建
T3                                START TRANSACTION;
T4                                UPDATE t SET v=2 WHERE id=1;   -- B 的 trx_id=200
T5                                COMMIT;
T6      SELECT v FROM t
        WHERE id=1;          → ?
T7                                -- 又过了几个提交…
T8      SELECT v FROM t
        WHERE id=1;          → ?

T6 的答案:

  • RR:复用 T2 的视图,200 ≥ max(T2 时 max=200,B 还没出生)→ 不可见 → 1;
  • RC:重拍视图,200 < min → 可见 → 2。

RC 把"世界"定义成"每条语句开始时",RR 把"世界"定义成"事务第一条语句开始时"。理解这一句,05-事务与锁.md 第 3 节矩阵里"不可重复读"的允许/禁止就全通了。

两个时机细节,容易在实现和面试里翻车:

  1. RR 的视图拍在"第一次快照读",不是 START TRANSACTION。事务若先跑 UPDATE/SELECT FOR UPDATE(当前读,不需要视图),之后第一条 SELECT 才拍视图——中间别人提交的修改,对这条 SELECT 是可见的。别想当然"BEGIN 即冻结";精确行为可以双客户端实测:A 先 UPDATE、B 插入提交、A 再 SELECT,看新行是否出现。
  2. RC 的"每条语句新建"指语句开始时刻拍一张:语句执行期间别人再提交,本条语句也看不到。所以 RC 仍杜绝脏读(只认"开始时已提交"的世界),RU 才是"连未提交都读"。

5. 快照读与当前读:清单与分叉

语句 读法 读到什么版本 是否加锁
普通 SELECT 快照读 ReadView 里的可见版本 否
SELECT ... FOR UPDATE / FOR SHARE 当前读 最新版本 X / S(RR 下 next-key)
UPDATE / DELETE 当前读 最新版本 是
INSERT / REPLACE / ON DUPLICATE KEY UPDATE 当前读(判定冲突时) 最新版本 是(含 gap/insert intention)

两套读法并行,就存在"分叉时刻"。RR 事务内最常见的现象:

-- 时间   会话 1(RR)                 会话 2
T1      START TRANSACTION;
T2      SELECT * FROM t
        WHERE id=1;          → 旧值 v=1
T3                                UPDATE t SET v=2 WHERE id=1;  COMMIT;
T4      SELECT * FROM t
        WHERE id=1;          → 仍是 1(快照冻结)
T5      SELECT * FROM t
        WHERE id=1 FOR UPDATE;   → 2!当前读无视快照,且加锁
T6      SELECT * FROM t
        WHERE id=1;          → 依旧 1

同事务同条件,T4/T6 说"1",T5 说"2",都不算错:T4/T6 读自己的历史,T5 读现实并上锁。生产上"我刚 SELECT 明明是这个值,怎么 UPDATE 结果不一样"的困惑,根源都是这张表。

反方向的怪事同样常见——“SELECT 明明看得到这行,UPDATE 却影响 0 行”:

-- 时间   会话 1(RR)                 会话 2
T1      START TRANSACTION;
T2      SELECT * FROM t
        WHERE id=1;          → 行存在,v=1(快照里)
T3                                UPDATE t SET v=2 WHERE id=1;  COMMIT;
T4      UPDATE t SET note='done'
        WHERE id=1 AND v=1;  → 影响 0 行
        -- UPDATE 是当前读:看到的已是 v=2,条件 v=1 不匹配
T5      SELECT * FROM t
        WHERE id=1;          → 仍返回 v=1 的旧版本(快照读)

为什么要把 UPDATE/DELETE 设计成当前读?因为写必须基于最新世界:若 UPDATE 拿快照旧值去改,两个并发 UPDATE 会各自基于过期版本覆盖对方——正是 05-事务与锁.md 第 9.1 节的丢失更新。“读最新版本 + 加锁"这一个动作同时完成了正确性校验和写写串行化,这也是第 1 节"写串行化"半句话的落点。


6. RR 防幻读的真相

面试高频,完整答案三段式:

  1. 快照读防幻读靠 MVCC:别的事务 INSERT 并提交的新行,版本链上的 DB_TRX_ID 属于我视图之后出生的事务 → 不可见 → 两次 SELECT 行数一致;
  2. 当前读防幻读靠 next-key lock:我 FOR UPDATE 扫过的范围被记录+间隙锁住,别人 INSERT 不进来;
  3. 但 RR 没有把幻读杀干净——官方文档承认的例外(下面演示)。

6.1 例外时序:快照读 → 别人插入提交 → 我 UPDATE → 快照读"见鬼”

-- 表 t: id INT PRIMARY KEY, c CHAR(1)
-- 时间   会话 1(RR)                会话 2
T1      START TRANSACTION;
T2      SELECT * FROM t
        WHERE id=1;          → 空(此时不存在 id=1)
T3                              START TRANSACTION;
T4                              INSERT INTO t VALUES (1,'a');  COMMIT;   -- trx_id=200
T5      SELECT * FROM t
        WHERE id=1;          → 仍空(快照里 200 不可见)
T6      UPDATE t SET c='b'
        WHERE id=1;          -- 当前读:看得到 200 的行,影响 1 行,并给它加 X 锁;
                             -- 这行的 DB_TRX_ID 从此变成 100(我自己的 id)
T7      SELECT * FROM t
        WHERE id=1;          → ('b')!!! 两次快照读之间"凭空多出"一行——幻读

原理只有一条:T6 把该行改写后,版本属于我自己(x == creator_trx_id → 规则 1 可见)。“我改过的行,永远对我可见”——MVCC 没有(也不该有)办法让一个事务看不见自己刚 UPDATE 过的结果。

6.2 结论怎么表述

  • RR 防的是"别人的写"造成的幻读(通过 ReadView + next-key);
  • RR 不防"我自己的当前读操作(UPDATE/DELETE/FOR UPDATE)把别人的新行拉进我的可见集";
  • 彻底消灭幻读的代价是 SERIALIZABLE(读写全互斥)或让快照读也加锁,工程上不划算。

注意 例外的扳机是写操作,不是"任何当前读"。SELECT ... FOR UPDATE 只给行加锁、不改行,DB_TRX_ID 不变——它锁到的别人新插入的行,后续快照读依旧看不见。6.1 的"见鬼"只发生在 UPDATE/DELETE 把行归属改成自己之后(x == creator_trx_id 命中规则 1)。面试易错点正在这:“当前读"三个字不等于"自改可见”,关键区别是这行有没有被你写过。


7. UPDATE 是"当前读"的两个工程后果

7.1 UPDATE 影响行数比快照读看到的多

接 6.1:T2 的 SELECT 看到 0 行,T6 的 UPDATE 影响 1 行。再给一个更实用的形态——批量补状态:

-- 会话 1(RR)                              会话 2
START TRANSACTION;                          (新插入 300 行 status=0 并提交)
SELECT COUNT(*) FROM task
WHERE status=0;                 → 500
UPDATE task SET status=1
WHERE status=0;                 → 影响 800 行

UPDATE 按最新已提交版本判定匹配(当前读),不查我的快照。“先 SELECT 统计、再 UPDATE 同一条件"在 RR 事务里不是原子检查:两次执行之间世界的增量会被 UPDATE 一并吞掉。业务要"只处理我看见的那批”,必须 SELECT 阶段就 FOR UPDATE 锁定,或改按 id 清单更新。

7.2 为什么"RR 下第一次 UPDATE 之后,前后快照不一致"

6.1 的机制推广:事务里第一次 UPDATE/DELETE(或 FOR UPDATE)一旦执行,被操作行的 DB_TRX_ID 归我所有,之后所有快照读对这些行必然可见——当前读操作相当于把我的 ReadView 边界"按行"向前撕开了口子。被 UPDATE 命中的新行可见、未命中的旧不可见,同一次 SELECT 里混着两个世界。这正是 6.1 现象的一般化;也说明**“把 UPDATE 当作快照读语义"是危险直觉**。


8. purge 与长事务:MVCC 的隐藏账单

8.1 purge 的工作规则

版本链占的是 undo 空间,旧版本何时能删?必须等所有可能用到它的 ReadView 全部消失:purge 线程只清理"活跃视图中最老快照之下"的 undo。规则一句话:只要还有一个 ReadView 活着,它出生之后的所有旧版本都得留着。

8.2 长事务的三笔坏账

一个只 SELECT 不提交的长事务(或 autocommit=0 连接,05 第 2.2 节)会:

  1. undo 膨胀:purge 滞后,undo 表空间持续增长,磁盘告警;
  2. 版本链变长:其他事务读同一行要沿链爬很久(爬到可见版本为止),查询延迟莫名上涨——“这行明明没改,SELECT 越来越慢"多半是这个原因;
  3. 历史残留:SHOW ENGINE INNODB STATUS 里的 History list length(未 purge undo 记录数)持续走高。
-- 监控:history list 长度
SHOW ENGINE INNODB STATUS\G          -- 找 "History list length"
SELECT name, count FROM information_schema.innodb_metrics
WHERE name = 'trx_rseg_history_len'; -- 同源指标,可脚本采集

purge 节奏可调(默认一般不用动):innodb_purge_threads、innodb_purge_batch_size(默认 300)、innodb_max_purge_lag(超过 history 值时给 DML 加 throttle,慎用,宁可处理长事务也别拖慢线上写)。

8.3 “两个长事务"的连锁灾难

单独一个长事务已经难受,读写型 + 更新型叠加时事故会互相放大:

  • 一个报表/巡检长事务持有老 ReadView → purge 冻结;
  • 同时一个大批量 UPDATE 长事务不停产新 undo(本身又持锁到提交,05 性能账);
  • 后果叠加:undo 疯涨磁盘 → 版本链极长全库读变慢 → 基于一致性快照的备份窗口被拖长(FTWRL/--single-transaction,指 11-备份恢复与性能调优.md)→ Online DDL 的行拷贝在长视图上卡住(指 09-Online-DDL与大表变更.md)→ 大事务的 binlog 在从库上放大重放延迟(指 10-主从复制与高可用.md)。

一个事务的"长寿"能污染备份、DDL、复制三条线——MVCC 的问题从来不只是 MVCC 的问题。完整时序长这样:

-- 时间   会话 1(报表,只读长事务)      会话 2(批量更新)             会话 3(无辜的线上查询)
T1      SET autocommit=0;
T2      SELECT ... FROM big JOIN ...
        -- 隐式开事务,ReadView 拍在 T2,
        -- min_trx_id 从此冻结在 T2 时刻
T4                                          START TRANSACTION;
T5                                          UPDATE orders SET ... (10 万行,
                                            跑 5 分钟不提交)
        -- 每行旧版本进 undo
T6                                                                          SELECT * FROM t WHERE id=1;
        -- purge 卡在 T2 视图之后无法推进:
        -- T5 产生的旧版本一个都不能清
T7                                                                          -- id=1 若被 T5 改过,
        -- History list length 持续上涨;                                  需沿链爬到 T2 之前的版本
        -- 版本链越来越长                                                   才可见 → 查询变慢
T8      会话 1 仍未结束 ...

三条线各自咬人:undo 磁盘增长是慢性出血;全库读变慢是隐性中毒;而 T2 那个视图还会连带卡住 purge 影响备份与 DDL(见上文连锁)。

8.4 抓长事务

SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_s,
       trx_mysql_thread_id, trx_rows_modified,
       LEFT(trx_query, 100) AS query_head
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 60 SECOND
ORDER BY trx_started;
-- 处置:KILL <trx_mysql_thread_id>;  (杀连接,事务回滚)

建议配告警阈值(如 60s 且 trx_rows_modified 大者优先处理);只读空闲事务(trx_state=RUNNING、query 为空、started 很早)往往是应用忘 COMMIT 或 autocommit=0 漏网,优先排查连接池配置。

巡检版 SQL:连上会话身份一起看,方便定位是哪个应用、哪台机器:

SELECT t.trx_mysql_thread_id AS pid,
       t.trx_id,
       TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) AS age_s,
       t.trx_rows_modified,
       p.user, p.host, p.db, p.command,
       LEFT(IFNULL(t.trx_query, ''), 80) AS trx_query_head
FROM information_schema.innodb_trx t
LEFT JOIN information_schema.processlist p ON p.id = t.trx_mysql_thread_id
ORDER BY t.trx_rows_modified DESC, age_s DESC;

处置优先级:trx_rows_modified 大的更新型长事务(锁 + undo 双份伤害)> age_s 大的只读事务(钉视图、卡 purge);回滚前者要等 undo 应用完,KILL 之后别指望"立刻恢复”。


9. 生产实践:把 MVCC 的三笔账算在业务层

事务长短直接对应 MVCC 的三条成本线:

  1. 连接占用:事务不结束,连接不还池;高并发下长事务先打爆连接池;
  2. undo 堆积:事务改得越多、活得越久,purge 越追不上;
  3. 锁持有:05 第 11 节——持锁时长决定热点行 TPS 天花板。

由此得到几条工程纪律:

  • 事务里不做 RPC、不发 HTTP、不读大结果集慢慢处理——“查出来再逐条调外部接口最后提交"的写法,三笔账全占;
  • 批量任务分块提交,让 purge 和复制都跟得上(伪代码骨架):
-- 100 万行的历史数据修复,每批 2000 行、批间短暂 sleep:
--   UPDATE t SET ... WHERE status=0 ORDER BY id LIMIT 2000;
--   COMMIT;
--   SELECT SLEEP(0.2);
-- 单语句自带隐式提交时不用显式事务;关键是"一个事务 ≤ 秒级、≤ 几千行"
  • 事务隔离级别按连接/表设置,特殊链路(任务队列)显式 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;,别改全局;
  • 连接归还前保证 COMMIT/ROLLBACK(ORM 的事务管理器确认好),杜绝 autocommit=0 僵尸视图;
  • 业务偶发"数据看起来不对”,九成是事务边界/当前读-快照读分叉(第 5、6、7 节),先画时间线再怀疑 bug。

10. 实战建议

  1. 记住一句话模型:改数据不删旧数据(版本链),每个事务按 ReadView 挑版本(可见性),RC/RR 只差在拍照时机(RC 每语句重拍、RR 首语句冻结)。
  2. 可见性算法四步顺序不要背反:creator → < min → >= max → ∈ m_ids;边界记两个细节:max 用 >=,m_ids 只代表拍下瞬间的活跃集。
  3. 默认 RR + 快照读:绝大多数报表/查询链路什么都不用加,天然无锁读。
  4. 别把 UPDATE 的影响行数当"快照里存在”:影响行数来自当前读;要原子"看多少改多少”,先 FOR UPDATE 或按 id 清单驱动。
  5. RR 防幻读三段答(MVCC 快照 / next-key 锁 / 自改可见例外),能把例外讲清楚才是真懂。
  6. 长事务是 MVCC 头号敌人:history list 监控 + innodb_trx 抓 60 秒以上事务,报表类大查询考虑走从库或调低其隔离级别/只读事务(START TRANSACTION READ ONLY; 也照样有视图,但明确语义便于治理)。
  7. purge 参数先不动:默认值适合绝大多数场景,真正该治的是制造滞后视图的源头——事务边界。
  8. 选型决策(RC 还是 RR):
    • 业务默认:RR。备份/复制/语句 binlog 兼容性最省心,防幻读语义更强;
    • 高并发抢锁链路(队列、热点状态机)、冲突重试敏感:RC(前提 ROW binlog、接受语句级不可重复读、“判空再插"逻辑改走唯一约束);
    • RU/SERIALIZABLE 基本不进生产考虑。
    • 判断线索三连问:读写冲突是否集中在少数热点行?业务能否接受行内容"每语句刷新”?复制链路是否已是 ROW 格式?三问都偏"是/能/是",RC 更合适,否则维持 RR。
  9. 在测试环境亲手跑一遍本章时序:开两个 mysql 客户端,把第 4、5、6.1、7.1 节的例子逐行敲出来,对比返回值和预期——MVCC 这种"看不见摸不着"的机制,只有复现过"快照读说 1、当前读说 2"的瞬间才算真懂。
  10. 面试/排查双向价值:本章 + 05-事务与锁.md 构成完整隔离性叙事;线上任何"锁等待/数据不一致",从"谁持有什么锁、谁的 ReadView 在哪一刻拍下"两个问题出发,基本都能收敛。