MVCC 原理:Read View、隐藏字段、undo 版本链与快照读(121)

MySQL与关系型数据库约 8 分钟阅读4,457 字符
知识库文档 122 / 251

阅读时间:约 45 分钟 | 难度:中高级 | 编号:121


一、引言:MVCC 是 InnoDB 并发性能的基石

MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现非阻塞读的核心机制。它让读操作不需要加锁,通过读取历史版本实现高并发。

MVCC 的核心思想: 每行数据维护多个版本,读操作读取合适的版本,写操作创建新版本。读写不互斥。

文章配图
文章配图


二、隐藏字段:每行数据的"元数据"

2.1 三个隐藏列

InnoDB 每行数据有三个隐藏列:

列名 大小 说明
DB_ROW_ID 6 字节 行 ID(无主键时作为聚簇索引键)
DB_TRX_ID 6 字节 最近修改该行的事务 ID
DB_ROLL_PTR 7 字节 指向 undo log 中该行上一个版本的指针

DB_TRX_ID 是 MVCC 的核心——它记录了每个版本由哪个事务创建。

2.2 查看隐藏列

sql
-- 无法直接通过 SELECT 查看隐藏列
-- 但可以通过 information_schema 查看部分信息
SELECT * FROM information_schema.innodb_trx;

文章配图
文章配图


三、undo log 版本链:时间旅行

3.1 版本链的构建

每次 UPDATE 操作都会在 undo log 中记录修改前的值,形成版本链:

code
当前版本(最新)        undo 版本1          undo 版本2
┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│ id=1, name=B │    │ id=1, name=A │    │ 初始插入版本  │
│ DB_TRX_ID=103│───→│ DB_TRX_ID=101│───→│ DB_TRX_ID=100│
│ DB_ROLL_PTR──┘    │ DB_ROLL_PTR──┘    │ DB_ROLL_PTR=0│
└──────────────┘    └──────────────┘    └──────────────┘

版本链的方向: 从最新版本通过 DB_ROLL_PTR 指向旧版本,形成单向链表。

3.2 INSERT vs UPDATE vs DELETE 的 undo

操作 undo log 内容 回滚方式
INSERT 主键值 DELETE
UPDATE 修改前的值 恢复旧值
DELETE 整行数据 INSERT 回原行

文章配图
文章配图


四、Read View:事务的"快照"

4.1 Read View 的结构

REPEATABLE READ 下,事务的第一个 SELECT 创建 Read View,后续所有 SELECT 共用。

Read View 包含三个关键字段:

字段 说明
m_ids 创建 Read View 时,当前活跃(未提交)的事务 ID 列表
min_trx_id m_ids 中的最小值
max_trx_id 创建 Read View 时,下一个待分配的事务 ID(全局递增)

4.2 可见性判断规则

对于行版本的 DB_TRX_ID(记为 trx_id):

code
1. trx_id < min_trx_id
   → 该版本由已提交的事务创建,可见

2. trx_id >= max_trx_id
   → 该版本由"未来"的事务创建,不可见

3. min_trx_id <= trx_id < max_trx_id
   a. trx_id 在 m_ids 中
      → 该版本由活跃事务创建,不可见
   b. trx_id 不在 m_ids 中
      → 该版本由已提交的事务创建,可见

4.3 可见性判断的源码逻辑

cpp
// InnoDB 源码简化版
bool changes_visible(trx_id_t trx_id, const ReadView* view) {
    if (trx_id < view->min_trx_id) {
        return true;  // 创建者已提交
    }
    if (trx_id >= view->max_trx_id) {
        return false;  // 创建者是"未来"事务
    }
    if (view->m_ids.contains(trx_id)) {
        return false;  // 创建者仍在活跃
    }
    return true;  // 创建者已提交
}

文章配图
文章配图


五、快照读 vs 当前读

5.1 快照读(Snapshot Read)

普通的 SELECT 语句是快照读,读取的是 MVCC 版本,不加锁。

sql
SELECT * FROM t WHERE id = 1;  -- 快照读

快照读使用 Read View 判断可见性,可能读取历史版本。

5.2 当前读(Current Read)

加锁的 SELECT、UPDATE、DELETE 是当前读,读取最新版本并加锁。

sql
SELECT * FROM t WHERE id = 1 FOR UPDATE;  -- 当前读
UPDATE t SET name = 'Bob' WHERE id = 1;   -- 当前读
DELETE FROM t WHERE id = 1;               -- 当前读

当前读读取最新版本,并在行上加锁,保证读取的数据是最新的。

5.3 当前读也需要 MVCC

当前读读取最新版本后,如果发现该版本是由未提交的事务创建的:

  1. 等待该事务提交或回滚
  2. 如果是回滚,读取 undo 链中的上一个版本
  3. 对最终读取的版本加锁

文章配图
文章配图


六、MVCC 在不同隔离级别下的行为

6.1 READ COMMITTED

每次 SELECT 都创建新的 Read View。

code
事务 A:
  SELECT name FROM t WHERE id = 1;  → 'Alice' (Read View #1)
  -- 事务 B UPDATE name = 'Bob' 并 COMMIT
  SELECT name FROM t WHERE id = 1;  → 'Bob' (Read View #2,看到 B 的提交)

6.2 REPEATABLE READ

事务的第一个 SELECT 创建 Read View,后续 SELECT 复用。

code
事务 A:
  SELECT name FROM t WHERE id = 1;  → 'Alice' (Read View #1)
  -- 事务 B UPDATE name = 'Bob' 并 COMMIT
  SELECT name FROM t WHERE id = 1;  → 'Alice' (复用 Read View #1,看不到 B 的提交)

文章配图
文章配图


七、Purge 线程:清理旧版本

7.1 Purge 的作用

MVCC 会产生大量历史版本,需要定期清理。Purge 线程负责删除不再需要的 undo log。

Purge 的判断条件: 当一个 undo log 版本不再被任何活跃事务的 Read View 需要时,可以被清理。

7.2 Purge 延迟的影响

长事务阻止 Purge: 如果有一个长事务长时间未提交,它的 Read View 会阻止 Purge 清理旧版本。导致 undo 表空间膨胀,版本链变长,MVCC 查找历史版本变慢。

sql
-- 查找阻碍 Purge 的长事务
SELECT trx_id, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS seconds
FROM information_schema.innodb_trx
ORDER BY trx_started;

文章配图
文章配图


八、总结

MVCC 的核心机制:

  1. 隐藏字段:DB_TRX_ID 记录版本创建者,DB_ROLL_PTR 链接历史版本
  2. Undo 版本链:每次修改记录旧值,形成单向链表
  3. Read View:事务的快照,决定哪些版本可见
  4. 快照读 vs 当前读:SELECT 不加锁读快照,FOR UPDATE 加锁读最新

文章配图
文章配图


九、面试官视角提问

Q1:**SELECT * FROM t WHERE id = 1**在 REPEATABLE READ 下,事务 A 第一次 SELECT 后,事务 B 修改了 id=1 并提交。事务 A 再次 SELECT 结果是什么?如果事务 A 先**UPDATE t SET name='X' WHERE id=1**再 SELECT,结果又是什么?

Q2:Read View 的 max_trx_id 为什么是"下一个待分配的事务 ID"而不是"当前最大的事务 ID"?这有什么好处?

Q3:一个事务执行**SELECT * FROM t WHERE id BETWEEN 1 AND 1000**,这 1000 行可能有不同的 DB_TRX_ID。MVCC 会为每一行分别判断可见性吗?为什么?

Q4:MySQL 的 MVCC 和 PostgreSQL 的 MVCC 有什么本质区别?为什么 PostgreSQL 没有 undo log 却有类似的效果?

Q5:**SELECT COUNT(*) FROM t**在 REPEATABLE READ 下是快照读吗?如果另一个事务在 COUNT 期间插入并提交了 100 行,COUNT 会包含这 100 行吗?

Q6:为什么 InnoDB 的 MVCC 不需要在 undo log 中存储完整的历史版本,而只需要存储"修改前的值"?如果一个 UPDATE 修改了 10 个字段,undo log 需要存储什么?


作者注:本文为系列第 121 篇,属于「08 MySQL 与关系型数据库」分类。


十、面试官提问 · 参考答案

Q1:RR 下事务 A 第一次 SELECT id=1 后事务 B 修改并提交,事务 A 再次 SELECT 结果?如果先 UPDATE 再 SELECT 呢?

参考答案:

只 SELECT: 两次 SELECT 结果相同,都是事务 A 开始时的值。因为 RR 下事务 A 使用同一个 Read View,看不到事务 B 的提交。

先 UPDATE 再 SELECT: UPDATE 是当前读,会读取最新版本。如果事务 B 已经提交,UPDATE 会基于事务 B 修改后的值进行更新。之后的 SELECT 是快照读,但会看到自己 UPDATE 的结果(自己的修改总是可见的)。

关键点: 当前读(UPDATE)会打破快照的一致性。事务 A 的 UPDATE 会读到事务 B 的修改,但事务 A 的 SELECT 读不到。


Q2:Read View 的 max_trx_id 为什么是"下一个待分配的事务 ID"?

参考答案:

简化判断逻辑。 如果 max_trx_id 是"当前最大的事务 ID",那么判断 trx_id > max_trx_id 时,需要考虑 trx_id 是否等于 max_trx_id 的情况。

使用"下一个待分配的事务 ID"作为 max_trx_id,所有已分配的事务 ID 都 < max_trx_id。判断简化为:

  • trx_id < min_trx_id → 可见
  • trx_id >= max_trx_id → 不可见(未来事务)
  • 中间的 → 检查 m_ids

这是一个巧妙的工程优化,用"下一个"代替"当前最大",避免了边界判断。


Q3:SELECT 1000 行,每行有不同 DB_TRX_ID,MVCC 会为每一行分别判断可见性吗?

参考答案:

是的,MVCC 为每一行单独判断可见性。 因为不同行可能由不同事务修改,每行的 DB_TRX_ID 不同,需要用同一个 Read View 对每行做可见性判断。

这正是 MVCC 的精妙之处: 同一事务内,一个 SELECT 可能看到"部分旧数据 + 部分新数据"的混合视图。只要每行都满足 Read View 的可见性规则,这个视图就是一致的。

这也解释了为什么 RR 可以防止不可重复读但允许幻读(标准 SQL): 同一行的两次读取结果相同,但范围查询可能因为 INSERT 而产生不同结果。


Q4:MySQL 和 PostgreSQL 的 MVCC 本质区别?

参考答案:

维度 MySQL InnoDB PostgreSQL
旧版本存储 undo log(回滚段) 数据页内(tuple)
版本清理 Purge 线程异步清理 VACUUM 清理
回滚效率 快(undo log 记录旧值) 慢(需要找到旧 tuple)
空间放大 undo 表空间 数据页内膨胀
长事务影响 阻止 Purge 阻止 VACUUM

PostgreSQL 没有 undo log: 每次 UPDATE 直接在数据页中插入新版本,旧版本标记为"已过期"。VACUUM 负责回收过期版本占用的空间。


Q5:RR 下 是快照读吗?另一个事务插入 100 行并提交,COUNT 结果?

参考答案:

是快照读。COUNT 结果不包括新插入的 100 行。

SELECT COUNT(*) 是普通的 SELECT,使用快照读。事务内的 Read View 在第一个 SELECT 时创建,后续的 COUNT 复用同一个 Read View,看不到其他事务的提交。

但如果 COUNT 前有 UPDATE: 如果事务先执行了 UPDATE(当前读),虽然没有直接修改 COUNT 相关的行,但不会改变 Read View。COUNT 仍然是快照读,结果不变。

RR 不能防止幻读的场景:SELECT COUNT(*) 不变,但 SELECT * WHERE id>5 FOR UPDATE 可能看到新插入的行(当前读)。


Q6:为什么 InnoDB 的 undo log 只存储"修改前的值"而不是完整历史版本?

参考答案:

节省空间,通过版本链实现完整历史。

如果 UPDATE 修改了 10 个字段,undo log 只需要存储这 10 个字段的旧值(逻辑日志),而不是整行数据。通过 DB_ROLL_PTR 链接,需要旧版本时沿着版本链逐个应用 undo 记录来重建历史版本。

INSERT 的 undo log 更简单: 只记录主键,回滚时 DELETE 即可。

DELETE 的 undo log 最完整: 记录整行数据,回滚时 INSERT 回去。

这种设计的最小化原则: 只记录"变化的部分",通过版本链按需重建。


全文完 | 编号 121 | 分类 08 MySQL 与关系型数据库