Skip to content

MVCC 核心:ReadView 生成规则与可见性判断

引出问题

面试 MySQL 事务隔离级别时,很多人能背出"RR 解决了不可重复读,RC 没有",但如果追问一句:

ReadView 里的 low_limit_id 和 up_limit_id 是怎么来的?一个事务插入一条数据后,自己能立即看到吗?为什么?

这就筛掉一大批只会背概念的人。ReadView 是 MVCC 的执行引擎,不理解 ReadView 的生成规则,就谈不上理解 MVCC。

ReadView 的基本结构

ReadView 在 MySQL 源码(storage/innobase/include/read0types.h)中是一个结构体,核心字段有四个:

字段含义对应源码名
low_limit_id(低水位)当前尚未分配的最小事务 ID。实现上等于 max_trx_id + 1(下一个将要分配的事务 ID)m_low_limit_id
up_limit_id(高水位)当前活跃事务列表中最小的 trx_idm_up_limit_id
trx_ids(活跃数组)生成 ReadView 时,所有尚未提交的事务 ID 列表,按 trx_id 升序排列m_ids
low_limit_no低水位编号,purge 线程用它判断哪些 undo log 可以清理m_low_limit_no

所谓"活跃事务",是指已经 BEGIN 但还未 COMMITROLLBACK 的事务。

关键细节:trx_ids 的排序

trx_ids 数组按 trx_id 升序存储。InnoDB 在 ReadView 的 changes_visible 方法中,对 trx_ids 使用二分查找来判断 DB_TRX_ID 是否在活跃数组中,复杂度 O(log n)。如果事务并发数几百,二分查找比线性扫描快得多。

可见性判断算法

拿到一行记录的 DB_TRX_ID(最后修改该行的事务 ID)后,按以下三步判断:

if DB_TRX_ID < up_limit_id:
    → 该事务在 ReadView 生成时已提交,可见
elif DB_TRX_ID >= low_limit_id:
    → 该事务在 ReadView 生成之后才开始,不可见
else:
    → DB_TRX_ID 在 [up_limit_id, low_limit_id) 范围内
    → 在 trx_ids 数组中二分查找
        → 找到 → 该事务未提交,不可见
        → 未找到 → 该事务已提交,可见

这个算法还有一个隐含的第四条规则:如果 DB_TRX_ID == 当前事务 ID,直接可见。因为当前事务不可能把自己的修改给隐藏掉。

实际源码中的判断顺序

在 InnoDB 源码 read0read.hchanges_visible() 方法中,实际的判断顺序是:

1. DB_TRX_ID == 当前事务 ID → 可见
2. DB_TRX_ID < up_limit_id → 可见
3. DB_TRX_ID >= low_limit_id → 不可见
4. 在 trx_ids 中二分查找,找到则不可见,否则可见

注意第 1 步在最前面,这意味着当前事务自己的修改不需要经过任何比较,直接可见。这是性能优化,也是语义保证。

一个具体的 ReadView 示例

假设当前系统中有 4 个事务,事务 ID 已分配了 1,2,3,4,5(5 是下一个要分配的最小 ID):

事务 ID状态
trx_1已提交
trx_2活跃(未提交)
trx_3已提交
trx_4活跃(未提交)

当前事务(trx_5)执行第一次 SELECT 时生成的 ReadView:

low_limit_id = 5          // 下一个未分配的最小事务 ID
up_limit_id = 2           // 活跃事务中最小的 trx_id
trx_ids = [2, 4]          // 活跃事务列表,已排序

判断可见性:

  • 一行记录的 DB_TRX_ID = 1 → 1 < up_limit_id(2) → 可见 ✅
  • 一行记录的 DB_TRX_ID = 3 → 3 在 [2,5) 范围内,trx_ids 中查找 3 → 不存在 → 可见 ✅
  • 一行记录的 DB_TRX_ID = 4 → 4 在 [2,5) 范围内,trx_ids 中查找 4 → 存在 → 不可见 ❌

为什么事务插入数据后自己能立即看到

这是面试高频追问。假设事务 A 执行了:

sql
BEGIN;
INSERT INTO t (id, name) VALUES (1, 'alice');
SELECT * FROM t WHERE id = 1;

事务 A 的 SELECT 在生成 ReadView 时,trx_ids不包含事务 A 自身的 ID(因为事务 A 是当前事务,在 ReadView 生成逻辑中会排除自己)。所以 DB_TRX_ID = 事务A的ID 这条记录,在 changes_visible() 第 1 步就直接返回可见。

这就是为什么插入后立即能查到——不是 ReadView 特殊处理了当前事务,而是当前事务 ID 根本没进活跃数组。

对比:如果事务 B 插入了一条数据但未提交,事务 A 去读,事务 B 的 ID 在事务 A 的 ReadView 的 trx_ids 中,所以事务 A 看不到。这是 MVCC 隔离的基础。

RR 与 RC 的 ReadView 生成时机差异

这是 MVCC 面试的核心分水岭问题。

RR 隔离级别

sql
-- 事务 A
BEGIN;                     -- ReadView 还没生成
SELECT * FROM t WHERE id=1; -- 第一次执行 SELECT,生成 ReadView(复用整个事务)
...
SELECT * FROM t WHERE id=1; -- 复用同一个 ReadView
COMMIT;

RR 的 ReadView 在事务第一次执行快照读(SELECT)时生成,之后整个事务复用同一个 ReadView。 这意味着:

  • 事务 A 第一次 SELECT 时,记录了当时的活跃事务列表
  • 之后即使其他事务提交了,事务 A 的 ReadView 不变,所以看不到新提交的数据
  • 这就是"不可重复读被解决"的本质

补充细节:MySQL 还支持 START TRANSACTION WITH CONSISTENT SNAPSHOT,它会在 BEGIN 之后立即生成 ReadView,而不是等到第一次 SELECT。这在某些场景下有用——比如你需要在执行任何 DML 之前就确定快照范围。

RC 隔离级别

sql
-- 事务 B
BEGIN;
SELECT * FROM t WHERE id=1; -- 生成 ReadView-1
-- 其他事务提交了新的数据
SELECT * FROM t WHERE id=1; -- 生成 ReadView-2(全新)
COMMIT;

RC 的 ReadView 在每次执行快照读时重新生成。 每次 SELECT 都会拿当前最新的活跃事务列表构造 ReadView。所以:

  • 如果事务 A 在两次 SELECT 之间提交了,第二次 SELECT 的 ReadView 中就不包含事务 A
  • 事务 A 修改的行就变成可见的了
  • 这就是"不可重复读可能发生"的本质

实验验证:一步步看 ReadView 变化

sql
-- 准备数据
CREATE TABLE t (id INT PRIMARY KEY, name VARCHAR(20));
INSERT INTO t VALUES (1, 'alice');

-- 事务 A
BEGIN;
UPDATE t SET name = 'bob' WHERE id = 1;
-- 此时不提交,trx_A 活跃

-- 事务 B(RR 下)
BEGIN;
SELECT name FROM t WHERE id = 1; -- 返回 'alice'
-- 此时事务 B 的 ReadView: up_limit_id=trx_A, low_limit_id=trx_A+1, trx_ids=[trx_A]
-- 事务 A 提交后
SELECT name FROM t WHERE id = 1; -- 还是 'alice'(RR 复用同一 ReadView,trx_A 仍在 trx_ids 中)
COMMIT;

-- 事务 C(RC 下,需切换到 RC 隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT name FROM t WHERE id = 1; -- 返回 'alice'
-- 此时事务 C 的 ReadView-1: up_limit_id=trx_A, trx_ids=[trx_A]
-- 事务 A 提交后
SELECT name FROM t WHERE id = 1; -- 返回 'bob'
-- RC 重新生成 ReadView-2: up_limit_id=... (trx_A 已提交,不在 trx_ids 中)
-- DB_TRX_ID=trx_A 这条记录变为可见
COMMIT;

当前读 vs 快照读

很多人以为 RR 下"完全看不到其他事务的修改",这是错的。当前读SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETE不走 ReadView,总是读最新已提交的数据

sql
-- RR 下
BEGIN;
-- 快照读:基于 ReadView
SELECT * FROM t WHERE id = 1; -- 看不到其他事务未提交的修改

-- 当前读:读取最新版本,且加行锁
SELECT * FROM t WHERE id = 1 FOR UPDATE; -- 读最新版本,如果其他事务持有锁则等待

-- 当前读:更新后,后续快照读的 ReadView 也会刷新?
-- 注意:在 MySQL 8.0 中,当前读不会刷新快照读的 ReadView

一个容易踩的坑:RR 下,先当前读再快照读,快照读的结果可能和当前读不一致。

sql
-- 事务 A
BEGIN;
UPDATE t SET name = 'eve' WHERE id = 1; -- 当前读,把 name 改为 'eve'

-- 事务 B
INSERT INTO t VALUES (2, 'dave');
COMMIT;

-- 事务 A 继续
SELECT * FROM t WHERE id = 2; -- 快照读,看不到事务 B 插入的 id=2(RR 的 ReadView 不变)
-- 但如果事务 A 执行了 FOR UPDATE:
SELECT * FROM t WHERE id = 2 FOR UPDATE; -- 当前读,能读到 dave!

-- 之后再快照读:
SELECT * FROM t WHERE id = 2; -- 还是看不到 dave(ReadView 不变)
COMMIT;

这一点很重要:RR 下的快照读语义是"事务启动时的一致性快照",但当前读会读取最新数据并加锁。所以 RR 下解决幻读,靠的是快照读 + Next-Key Lock 的当前读两者配合,而不是 ReadView 单独解决的。

可见性搜索与 undo log 历链

当 ReadView 判定某行不可见时,InnoDB 不会直接返回"无数据",而是沿着该行的 undo log 历链回溯,找到第一个对 ReadView 可见的版本。

行版本链(从最新到最旧):

[版本 v3: trx_5 修改, name='carol']  ← 当前行
         ↓ (undo log 回滚指针)
[版本 v2: trx_3 修改, name='bob']   
         ↓ (undo log 回滚指针)
[版本 v1: trx_1 插入, name='alice']

ReadView: up_limit_id=2, low_limit_id=5, trx_ids=[2,4], 当前事务=5

1. 检查 v3: DB_TRX_ID=5 == 当前事务 → 可见(如果是当前事务的修改)
2. 检查 v3: DB_TRX_ID=5 → 如果当前事务是 trx_6,则 5 < up_limit_id(2)? 不成立,5 >= low_limit_id(5)? 不成立
   → 在 trx_ids 中查找 5 → 不存在 → 可见(trx_5 已提交)
3. 如果 v3 不可见(比如 trx_5 仍在活跃列表中),则回溯到 v2:
   DB_TRX_ID=3 → 3 < up_limit_id(2)? 不成立,3 >= low_limit_id(5)? 不成立
   → 在 trx_ids 中查找 3 → 不存在 → 可见

这个回溯过程是 InnoDB 在 row_vers_build_for_consistent_read() 函数中实现的。每次回溯都要解析 undo log 记录,重建旧版本行。如果 undo log 链太长(比如长事务导致大量旧版本堆积),这个回溯的开销会显著增加,造成查询变慢。

生产中的两个坑

坑 1:长事务阻止 undo log 清理

MySQL 删除一行时,不是立即物理删除,而是标记删除,同时把 DB_TRX_ID 更新为删除操作的事务 ID。如果删除事务的 DB_TRX_ID 在 ReadView 的可见范围内,则该行对当前事务仍可见(即删除不生效);如果不可见,则该行被隐藏。

这就是为什么长事务会导致 undo log 无法清理——InnoDB 的 purge 线程会根据 m_low_limit_no 判断哪些 undo log 可以回收。如果有长事务的 ReadView 仍然引用某个旧版本,purge 线程就不能物理删除该行。

真实案例:某团队在 RR 下开启了一个报表查询事务,执行了 30 分钟的聚合查询。在此期间,大量 update 产生的旧版本无法被 purge 回收,最终导致 ibdata1 膨胀到 200GB+。undo log 的回滚段占满了磁盘,DML 全部卡住。

解决办法

  • 监控 information_schema.INNODB_TRXtrx_started 超过 10 秒的事务
  • 报表查询用 RC 隔离级别,或者加 START TRANSACTION WITH CONSISTENT SNAPSHOT READ ONLY
  • MySQL 8.0 的 START TRANSACTION READ ONLY 可以优化 RR 下长事务的 undo log 保留策略,但 ReadView 仍然不刷新

坑 2:RR 下长事务与 DDL 冲突

真实事故:某团队在 RR 下开启了一个报表查询事务,执行了 30 分钟的聚合查询。在此期间,DBA 执行了 ALTER TABLE 修改表结构。结果:

  • 报表查询事务的 ReadView 是在第一次 SELECT 时生成的
  • 该 ReadView 范围内看不到新表结构
  • 查询报错或返回旧结构数据

根源:DDL 需要等待所有持有 MDL(Metadata Lock)的事务释放。如果长事务的 MDL 不释放,ALTER TABLE 会排队等待,最终超时。而 ALTER TABLE 一旦超时,后续的查询也会被阻塞在 MDL 等待队列中,造成连锁故障。

总结

ReadView 是 MVCC 的"视觉过滤器",决定了事务能看到哪些数据版本。记住三条核心:

  1. RR 的 ReadView 在事务第一次快照读时生成,整个事务复用;RC 的 ReadView 每次快照读都重新生成。 这是不可重复读能否被解决的根本原因。
  2. 当前事务 ID 不在活跃数组中,所以自己能立即看到自己的修改。
  3. 当前读(FOR UPDATE / UPDATE / DELETE)不走 ReadView,总是读最新版本。 RR 下防幻读,靠的是 ReadView 防快照读的幻读 + Next-Key Lock 防当前读的幻读,两者缺一不可。

理解 ReadView,才算真正理解了 MVCC 的执行机制——而不是仅仅知道"RR 比 RC 多了一个不可重复读的防护"。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。