面试里问 MVCC,八成的候选人能说出「快照读」「undo log」「ReadView」,但一旦追问「ReadView 里到底存了什么、四个字段怎么比、为什么 RR 和 RC 结果不一样」,回答就开始含糊。这篇把 InnoDB 的 ReadView 拆到字段级别,用可复现的 SQL 走一遍可见性判断的完整分支,最后给出一份面试可直接背的答题骨架,以及几个高频追问的标准答案。
一、先搞清楚 MVCC 到底解决什么问题
InnoDB 默认隔离级别是 REPEATABLE READ(RR)。如果只有行锁,读写之间必须互斥:一个事务在改某行,别的事务读这行就得等,并发度极低。MVCC(Multi-Version Concurrency Control,多版本并发控制)的思路是让读操作不加锁:写操作生成新版本,旧版本留在 undo log 里,读操作根据某种规则挑一个「它应该看到」的版本。
关键点是:MVCC 只服务于快照读(consistent nonlocking read),也就是普通 SELECT。以下语句不受 MVCC 保护,走的是当前读(current read),读最新版本并加锁:
SELECT ... FOR UPDATE(加 X 锁)SELECT ... LOCK IN SHARE MODE/ MySQL 8.0 的FOR SHARE(加 S 锁)UPDATE、DELETE(本质也是当前读,先读最新再改)INSERT ... ON DUPLICATE KEY UPDATE、INSERT INTO ... SELECT中涉及读源表的部分
所以面试第一句就该点明边界:MVCC 管快照读,当前读由锁 + 最新版本管。这句话能把很多含糊的讨论直接拉回正轨。
二、行记录里的三个隐藏字段与 undo log 版本链
InnoDB 每行聚簇索引记录除了业务列,还有三个隐藏列:
| 隐藏字段 | 长度 | 含义 |
|---|---|---|
DB_ROW_ID |
6 字节 | 没有主键且没有唯一非空索引时,InnoDB 自动生成的聚簇索引键 |
DB_TRX_ID |
6 字节 | 最后修改该行的事务 ID(严格说是事务号,见下文说明) |
DB_ROLL_PTR |
7 字节 | 回滚指针,指向 undo log 中该行的上一个版本 |
DB_ROLL_PTR 串起来的就是版本链:最新记录在聚簇索引页里,老版本在 undo log 里,沿着指针一路往回走。每个版本上都带着它被哪个事务写下的 DB_TRX_ID。可见性判断的本质,就是拿 ReadView 去比每个版本的 DB_TRX_ID,从链头开始找第一个「可见」的版本。
注意一个常见误区:
DB_TRX_ID不是trx_id那个不断递增的事务编号。InnoDB 内部区分trx_id(事务启动时分配的编号)和trx_id_t(写入记录的事务号,来自全局max_trx_id分配),二者在只读事务上会分离。面试时说「记录上的事务 ID」即可,不必展开,但如果被追问「只读事务有没有事务号」要能答:只读事务不分配trx_id,DB_TRX_ID用的是另一套编号。三、ReadView 的四个核心字段
在 RR 下,事务第一次快照读时创建 ReadView;在 RC 下,每条
SELECT都重新创建。ReadView 内部结构(以 MySQL 8.0 源码read0types.h的ReadView类为准)关键字段有四个:
字段(源码名) 含义 作用 m_ids创建 ReadView 时,当前系统里活跃(已启动但未提交)的读写事务 ID 列表,升序 判断版本是否由「还没提交的事务」写下 m_up_limit_idmin trx id,即 m_ids中的最小值;m_ids为空时为m_max_trx_id小于它的版本一定已提交,直接可见 m_low_limit_idmax trx id,创建 ReadView 时系统尚未分配的下一个事务 ID 大于等于它的版本一定是在 ReadView 之后才产生的,直接不可见 m_low_limit_no(8.0 中用于 purge 判断的低水位,与可见性判断的 m_low_limit_nr区分)决定 undo log 能否被 purge 回收 m_creator_trx_id创建该 ReadView 的事务自身 ID 自己的修改自己可见 老版本资料里常写成
m_up_limit_id是「最小活跃事务 ID」、m_low_limit_id是「最大活跃事务 ID + 1」。这两种说法在多数场景下结果一致,但语义上更准确的是:m_up_limit_id= min(m_ids),m_low_limit_id= 创建时系统已分配的 max_trx_id + 1(即下一个待分配值)。用后者解释「为什么一个刚启动、还没进m_ids的事务写的数据不可见」会更顺。四、可见性判断的完整流程(面试核心)
对版本链上某个版本的
DB_TRX_ID(记为trx_id),判断顺序如下。顺序不能乱,这是答题的关键:
- 自己写的:
trx_id == m_creator_trx_id→ 可见。否则自己刚UPDATE完再查看不到新值,逻辑就崩了。- 太老的:
trx_id < m_up_limit_id→ 可见。它比所有活跃事务都早,必然已提交。- 太新的:
trx_id >= m_low_limit_id→ 不可见。它在 ReadView 创建之后才启动,属于「未来」的事务。- 夹在中间的:
m_up_limit_id <= trx_id < m_low_limit_id,此时判断trx_id是否在m_ids里:
- 在
m_ids里 → 创建 ReadView 时它还没提交 → 不可见,沿DB_ROLL_PTR找下一个版本- 不在
m_ids里 → 创建 ReadView 时它已经提交 → 可见如果当前版本不可见,就顺着 undo log 版本链往回走,对下一个版本重复上述四步,直到找到可见版本。如果整条链都不可见(比如记录是 ReadView 之后才
INSERT的),本次查询对该行返回空,即「不存在」。源码对应:
ReadView::changes_visible(trx_id_t id, const table_name_t &name)。实现里先判id小于m_up_limit_id返回 true,再判id >= m_low_limit_id返回 false,中间段用二分(std::lower_bound)在m_ids里查。能在面试里补一句「中间段用的是二分查找,不是线性扫描」,是很加分的细节。五、动手验证:两张表的实验
下面用两个 session 复现 RR 下的可重复读,以及版本链回溯。先建表:
CREATE TABLE account ( id INT PRIMARY KEY, balance INT NOT NULL ) ENGINE=InnoDB; INSERT INTO account VALUES (1, 100);实验一:RR 下两次快照读结果一致。
-- Session A SET SESSION transaction_isolation = 'REPEATABLE-READ'; BEGIN; SELECT balance FROM account WHERE id = 1; -- 结果 100,此刻创建 ReadView -- Session B BEGIN; UPDATE account SET balance = 200 WHERE id = 1; COMMIT; -- 回到 Session A SELECT balance FROM account WHERE id = 1; -- 仍然是 100 COMMIT;为什么还是 100?Session A 的 ReadView 在第一次
SELECT时创建,那时 Session B 尚未启动,m_ids为空,m_low_limit_id等于 B 的事务号。B 提交后写下新版本,DB_TRX_ID= B 的事务号 ≥m_low_limit_id,命中第 3 条规则 → 不可见 → 回溯到旧版本(DB_TRX_ID是建表时的事务号,<m_up_limit_id)→ 返回 100。实验二:改成 RC,结果变成 200。
-- Session A SET SESSION transaction_isolation = 'READ-COMMITTED'; BEGIN; SELECT balance FROM account WHERE id = 1; -- 100 -- Session B 执行同样的 UPDATE 并 COMMIT -- 回到 Session A SELECT balance FROM account WHERE id = 1; -- 200 COMMIT;因为 RC 下第二次
SELECT重建了 ReadView,此时 B 已提交、不在m_ids中,且事务号 < 新的m_low_limit_id,落在第 4 步的「不在 m_ids 中」分支 → 可见。这就是「RC 有不可重复读、RR 没有」在 ReadView 层面的机制来源——两者不是加锁差异,而是 ReadView 创建时机的差异。实验三:验证「第一次快照读」这个时机。注意 RR 下 ReadView 不是在
BEGIN时创建,而是在第一次快照读时创建。-- Session A SET SESSION transaction_isolation = 'REPEATABLE-READ'; BEGIN; -- 此处只做一次写操作,不做 SELECT UPDATE account SET balance = balance + 1 WHERE id = 1; -- Session B:插入一条新记录并提交 -- INSERT INTO account VALUES (2, 500); COMMIT; -- Session A 第一次 SELECT SELECT * FROM account; -- 能看到 id=2 吗?答案是能。因为 A 的 ReadView 直到这条
SELECT才创建,此时 B 已提交,B 的事务号落在「已提交」区间。这个现象常被拿来做面试陷阱题,很多背「RR 事务开始就快照」的人会答错。顺带一个实操层面的验证手段:
-- 查看当前活跃事务 SELECT trx_id, trx_state, trx_started, trx_is_read_only FROM information_schema.INNODB_TRX\G -- 持续观察长事务(ReadView 迟迟不释放的元凶) SHOW ENGINE INNODB STATUS\G如果线上遇到 undo log 膨胀、
History list length持续上涨,基本都是长事务让 ReadView 一直存活、旧版本无法被 purge 回收。定位手段就是上面两条,外加sys.innodb_lock_waits或performance_schema.events_transactions_current。六、几个必须能接住的追问
1. 为什么 RR 不能完全避免幻读?
快照读靠 ReadView,天然规避了幻读(看不到新插入的行)。但当前读(
SELECT ... FOR UPDATE、UPDATE、DELETE)读的是最新版本,需要用 Next-Key Lock(记录锁 + 间隙锁)来阻止其他事务在区间内插入。如果事务里先快照读、再当前读,或先当前读、再快照读,仍可能看到「多出来的行」,这就是 RR 下幻读的残留场景。标准答法:RR 的快照读通过 MVCC 避免幻读,当前读通过 Next-Key Lock 避免幻读,二者组合才基本消除幻读,但混合使用时仍可能出现。2. 事务 ID 什么时候分配?
RR/RC 下,事务第一次执行写操作(或第一次快照读?——准确地说,读写事务在第一次修改数据时分配
trx_id;纯只读事务不分配)时才拿到事务号,而不是BEGIN那一刻。这解释了为什么「先BEGIN再让别人提交,然后自己UPDATE」的事务号可能比想象中大。3. ReadView 什么时候释放?
事务提交或回滚时释放。更准确地说,ReadView 挂在事务对象上,事务结束时从
rw_trx_list中摘除,purge 线程据此推进低水位、回收 undo。所以「长事务 = undo 不回收 = 表空间膨胀」的因果链是:ReadView 存活 →m_low_limit_no卡住 → purge 无法清理 → history list 变长。4.
m_ids为空意味着什么?创建 ReadView 的瞬间没有任何活跃读写事务。此时
m_up_limit_id = m_low_limit_id = m_max_trx_id(下一个待分配值),任何已提交的版本都< m_low_limit_id,全部可见。这是系统空闲时的常见状态,也解释了为什么低并发压测下很难复现隔离性问题。5. 二级索引上的可见性判断一样吗?
不一样,这是深水区。二级索引记录里也有
DB_TRX_ID和DB_ROLL_PTR,但二级索引的版本链不完整(只有索引列和被修改列的信息不足以重建整行)。所以 InnoDB 在二级索引上先做 MVCC 判断,若判定可见,还要回表到聚簇索引再做一次可见性判断(因为回表读到的可能是更新的版本)。如果回表后发现聚簇索引版本不可见,就得放弃这条二级索引记录。这就是为什么覆盖索引查询在某些更新频繁的场景下更「稳」——它不回表,不需要二次判断。小结
把 ReadView 的可见性判断压缩成一句话:自己的修改直接可见;比最小活跃事务还老的可见;比下一个待分配事务号还新的不可见;夹在中间的看是否在活跃列表里,在就不可见、不在就可见;不可见就沿 undo 版本链往回走。
再记住三个对照关系:RR 在首次快照读建 ReadView、RC 每条 SELECT 重建;快照读靠 ReadView、当前读靠锁;ReadView 存活时间决定 undo 能否回收。这三点答出来,MVCC 这道题基本就闭环了。
FAQ
Q1:RR 下,同一个事务里两次 SELECT 中间自己改了数据,第二次能看到自己的修改吗?
能。自己的事务号等于
m_creator_trx_id,命中判断流程第 1 步直接可见。而且这条新版本的DB_TRX_ID是自己的事务号,即使它 ≥m_low_limit_id也不影响,因为第 1 步在最前面。Q2:为什么有时候 RR 下会出现「同一个事务两次查询结果不同」?
常见三种原因:一是事务里混用了当前读(如
FOR UPDATE),当前读不走 ReadView;二是自动提交模式下每条语句都是独立事务,隔离级别形同虚设;三是查询走了不同的执行计划(如索引变更导致回表路径不同),在二级索引 + 回表的二次判断里出现差异。排查时先SELECT @@transaction_isolation和SELECT @@autocommit确认前提。Q3:
m_up_limit_id到底是最小活跃事务 ID,还是「已提交事务的上界」?两者在实现上等价:源码里
m_up_limit_id取自m_ids的最小值,而m_ids之外更小的事务号必然已提交,所以「小于它的都可见」成立。面试时给出「最小活跃事务 ID」即可,若被追问可补充「m_ids为空时等于m_max_trx_id」。Q4:RC 下 ReadView 每条 SELECT 重建,代价大吗?
主要是拷贝活跃事务数组的成本。活跃事务多时
m_ids数组长,判断时二分查找的 log 因子变大,同时高频重建会带来额外开销。但相比 RR 长时间持有 ReadView 导致 undo 无法回收,RC 在长事务场景下对空间更友好。这也是很多互联网业务选 RC 的原因之一,代价是业务层要自己处理不可重复读。Q5:能不能通过参数直接观察到 ReadView?
不能直接查。可间接观察的有
information_schema.INNODB_TRX(活跃事务)、SHOW ENGINE INNODB STATUS的 TRANSACTIONS 段(含 history list length)、INNODB_METRICS里的trx_rseg_history_len。要做精确验证,用两个 session 手工构造版本链是最可靠的办法。