阅读时间:约 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 查看隐藏列
-- 无法直接通过 SELECT 查看隐藏列
-- 但可以通过 information_schema 查看部分信息
SELECT * FROM information_schema.innodb_trx;

三、undo log 版本链:时间旅行
3.1 版本链的构建
每次 UPDATE 操作都会在 undo log 中记录修改前的值,形成版本链:
当前版本(最新) 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):
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 可见性判断的源码逻辑
// 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 版本,不加锁。
SELECT * FROM t WHERE id = 1; -- 快照读
快照读使用 Read View 判断可见性,可能读取历史版本。
5.2 当前读(Current Read)
加锁的 SELECT、UPDATE、DELETE 是当前读,读取最新版本并加锁。
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
当前读读取最新版本后,如果发现该版本是由未提交的事务创建的:
- 等待该事务提交或回滚
- 如果是回滚,读取 undo 链中的上一个版本
- 对最终读取的版本加锁

六、MVCC 在不同隔离级别下的行为
6.1 READ COMMITTED
每次 SELECT 都创建新的 Read View。
事务 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 复用。
事务 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 查找历史版本变慢。
-- 查找阻碍 Purge 的长事务
SELECT trx_id, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS seconds
FROM information_schema.innodb_trx
ORDER BY trx_started;

八、总结
MVCC 的核心机制:
- 隐藏字段:DB_TRX_ID 记录版本创建者,DB_ROLL_PTR 链接历史版本
- Undo 版本链:每次修改记录旧值,形成单向链表
- Read View:事务的快照,决定哪些版本可见
- 快照读 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 与关系型数据库