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 同时喂两套机制:
- 回滚:
ROLLBACK沿链把旧值写回(原子性视角,指07-日志体系.md); - 快照读: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 等不对外暴露)。工程上验证"自己到底看到了哪个版本"的方法只有两条:
- 行为测试:双客户端跑时序(本章所有双栏例子都可以原样复现),用返回值的差异反推视图生成时机;
- 活跃集旁证:
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 节矩阵里"不可重复读"的允许/禁止就全通了。
两个时机细节,容易在实现和面试里翻车:
- RR 的视图拍在"第一次快照读",不是
START TRANSACTION。事务若先跑 UPDATE/SELECT FOR UPDATE(当前读,不需要视图),之后第一条 SELECT 才拍视图——中间别人提交的修改,对这条 SELECT 是可见的。别想当然"BEGIN 即冻结";精确行为可以双客户端实测:A 先 UPDATE、B 插入提交、A 再 SELECT,看新行是否出现。 - 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 防幻读的真相
面试高频,完整答案三段式:
- 快照读防幻读靠 MVCC:别的事务 INSERT 并提交的新行,版本链上的 DB_TRX_ID 属于我视图之后出生的事务 → 不可见 → 两次 SELECT 行数一致;
- 当前读防幻读靠 next-key lock:我
FOR UPDATE扫过的范围被记录+间隙锁住,别人 INSERT 不进来; - 但 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 节)会:
- undo 膨胀:purge 滞后,undo 表空间持续增长,磁盘告警;
- 版本链变长:其他事务读同一行要沿链爬很久(爬到可见版本为止),查询延迟莫名上涨——“这行明明没改,SELECT 越来越慢"多半是这个原因;
- 历史残留:
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 的三条成本线:
- 连接占用:事务不结束,连接不还池;高并发下长事务先打爆连接池;
- undo 堆积:事务改得越多、活得越久,purge 越追不上;
- 锁持有:
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. 实战建议
- 记住一句话模型:改数据不删旧数据(版本链),每个事务按 ReadView 挑版本(可见性),RC/RR 只差在拍照时机(RC 每语句重拍、RR 首语句冻结)。
- 可见性算法四步顺序不要背反:creator →
< min→>= max→∈ m_ids;边界记两个细节:max用>=,m_ids只代表拍下瞬间的活跃集。 - 默认 RR + 快照读:绝大多数报表/查询链路什么都不用加,天然无锁读。
- 别把 UPDATE 的影响行数当"快照里存在”:影响行数来自当前读;要原子"看多少改多少”,先
FOR UPDATE或按 id 清单驱动。 - RR 防幻读三段答(MVCC 快照 / next-key 锁 / 自改可见例外),能把例外讲清楚才是真懂。
- 长事务是 MVCC 头号敌人:history list 监控 + innodb_trx 抓 60 秒以上事务,报表类大查询考虑走从库或调低其隔离级别/只读事务(
START TRANSACTION READ ONLY;也照样有视图,但明确语义便于治理)。 - purge 参数先不动:默认值适合绝大多数场景,真正该治的是制造滞后视图的源头——事务边界。
- 选型决策(RC 还是 RR):
- 业务默认:RR。备份/复制/语句 binlog 兼容性最省心,防幻读语义更强;
- 高并发抢锁链路(队列、热点状态机)、冲突重试敏感:RC(前提 ROW binlog、接受语句级不可重复读、“判空再插"逻辑改走唯一约束);
- RU/SERIALIZABLE 基本不进生产考虑。
- 判断线索三连问:读写冲突是否集中在少数热点行?业务能否接受行内容"每语句刷新”?复制链路是否已是 ROW 格式?三问都偏"是/能/是",RC 更合适,否则维持 RR。
- 在测试环境亲手跑一遍本章时序:开两个 mysql 客户端,把第 4、5、6.1、7.1 节的例子逐行敲出来,对比返回值和预期——MVCC 这种"看不见摸不着"的机制,只有复现过"快照读说 1、当前读说 2"的瞬间才算真懂。
- 面试/排查双向价值:本章 +
05-事务与锁.md构成完整隔离性叙事;线上任何"锁等待/数据不一致",从"谁持有什么锁、谁的 ReadView 在哪一刻拍下"两个问题出发,基本都能收敛。