MVCC 原理
问题
什么是 MVCC?InnoDB 的版本链和 Read View 是如何工作的?快照读和当前读的区别是什么?
答案
什么是 MVCC
MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种 乐观并发控制 机制,通过维护数据的多个版本,实现 读写不冲突。
| 传统锁 | MVCC |
|---|---|
| 读写互斥 | 读写不冲突 |
| 读要等写完 | 读快照,不等待 |
| 写要等读完 | 写不影响读 |
| 并发度低 | 并发度高 |
MVCC 让每个事务看到数据在某个时间点的 快照,而不是最新数据。这样读操作不需要加锁,极大提高了并发性能。
InnoDB 的 MVCC 实现
InnoDB 通过 三个要素 实现 MVCC:
- 隐藏列:每行数据有事务 ID 和回滚指针
- undo log 版本链:通过回滚指针串联数据的历史版本
- Read View:决定事务能看到哪些版本
隐藏列
InnoDB 每行数据有两个隐藏列(实际有三个,还有隐藏的 ROW_ID):
| 隐藏列 | 占用 | 说明 |
|---|---|---|
DB_TRX_ID | 6 字节 | 最后修改该行的事务 ID |
DB_ROLL_PTR | 7 字节 | 回滚指针,指向 undo log 中的上一个版本 |
版本链
每次更新操作,旧版本写入 undo log,新版本的回滚指针指向旧版本,形成一条 版本链。
过程:
- 事务 100 插入 name='张三' → trx_id=100
- 事务 200 修改为 name='李四' → 旧值写入 undo log,新行 trx_id=200
- 事务 300 修改为 name='王五' → 旧值写入 undo log,新行 trx_id=300
Read View
Read View 是事务在某一时刻创建的"快照",用于判断版本链中哪个版本对当前事务可见。
Read View 包含以下关键字段:
| 字段 | 含义 |
|---|---|
m_ids | 创建 Read View 时所有活跃(未提交)事务的 ID 列表 |
min_trx_id | m_ids 中的最小值 |
max_trx_id | 系统下一个要分配的事务 ID(当前最大 trx_id + 1) |
creator_trx_id | 创建该 Read View 的事务自己的 ID |
可见性判断算法
对于版本链中某个版本的 trx_id,判断它是否对当前事务可见:
简化规则:
- 自己修改的 → 可见
- 在 Read View 创建前已提交的 → 可见
- 在 Read View 创建后才开始的 → 不可见
- Read View 创建时还在活跃的 → 不可见
完整示例
初始数据:id=1, name='张三', trx_id=10
时间线:
1. 事务A (trx_id=20) 开始
2. 事务B (trx_id=30) 开始
3. 事务B: UPDATE name='李四' → trx_id=30
4. 事务A: SELECT(创建 Read View)
5. 事务B: COMMIT
6. 事务C (trx_id=40) 开始
7. 事务C: UPDATE name='王五' → trx_id=40, COMMIT
8. 事务A: SELECT(RR 复用 Read View)
事务A 在第 4 步创建 Read View:
- m_ids = [20, 30](A 和 B 都活跃)
- min_trx_id = 20
- max_trx_id = 31
- creator_trx_id = 20
版本链(第 4 步时):name='李四'(trx_id=30) → name='张三'(trx_id=10)
判断:
- trx_id=30 → 在 m_ids 中 → ❌ 不可见
- trx_id=10 → < min_trx_id(20) → ✅ 可见
- 结果:事务A 读到 name='张三'
第 8 步再次 SELECT(RR 复用 Read View):
- 版本链:name='王五'(trx_id=40) → name='李四'(trx_id=30) → name='张三'(trx_id=10)
- trx_id=40 → >= max_trx_id(31) → ❌ 不可见
- trx_id=30 → 在 m_ids 中 → ❌ 不可见
- trx_id=10 → < min_trx_id(20) → ✅ 可见
- 结果:事务A 仍然读到 name='张三'(可重复读!)
快照读 vs 当前读
| 类型 | 读取内容 | SQL 示例 | 是否加锁 |
|---|---|---|---|
| 快照读 | 读 MVCC 版本链的历史快照 | SELECT ... | 不加锁 |
| 当前读 | 读数据的最新版本 | SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEINSERT/UPDATE/DELETE | 加锁 |
-- 快照读(不加锁,读 MVCC 快照)
SELECT * FROM users WHERE id = 1;
-- 当前读(加共享锁,读最新数据)
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- 当前读(加排他锁,读最新数据)
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 当前读(写操作总是读最新数据 + 加锁)
UPDATE users SET name = '新名字' WHERE id = 1;
DELETE FROM users WHERE id = 1;
INSERT INTO users VALUES (...);
在 RR 级别下,快照读看的是事务开始时的快照,当前读看的是最新数据。如果在同一个事务中混用:
BEGIN;
SELECT * FROM t WHERE age = 25; -- 快照读:2 行
-- 此时另一个事务插入一行 age=25 并提交
UPDATE t SET name='x' WHERE age = 25; -- 当前读:影响 3 行!
SELECT * FROM t WHERE age = 25; -- 现在能看到 3 行了
UPDATE 是当前读,它会看到并修改最新数据,修改后的行对当前事务的快照读也变成可见。
RC vs RR 中 Read View 的区别
| 隔离级别 | Read View 创建时机 | 效果 |
|---|---|---|
| RC | 每次 SELECT 都创建新的 | 能看到查询时刻已提交的最新数据 |
| RR | 第一次 SELECT 时创建,后续复用 | 始终看到事务开始时的数据快照 |
这是 RC 和 RR 在 MVCC 实现上的 唯一区别。
PostgreSQL 的 MVCC 对比
| 特性 | InnoDB | PostgreSQL |
|---|---|---|
| 旧版本存储 | undo log(独立存储) | 直接存在数据页中 |
| 版本清理 | purge 线程清理 undo log | VACUUM 清理死元组 |
| 空间回收 | undo log 空间自动复用 | 需要 VACUUM 回收空间 |
| 更新操作 | 原地更新 + undo log | 插入新元组 + 标记旧元组 |
PostgreSQL 的 MVCC 不需要回滚段,但可能导致表膨胀(dead tuples),需要定期 VACUUM。
常见面试问题
Q1: MVCC 是什么?解决了什么问题?
答案:
MVCC 是多版本并发控制,通过维护数据的多个历史版本实现 读写不冲突。
- 不用 MVCC:读要加共享锁,写要加排他锁,读写互斥
- 用 MVCC:读操作读历史快照(不加锁),写操作修改最新数据(加行锁),读写并行
解决的核心问题:提高并发读的性能,让事务能看到一致性快照而不被其他事务的写操作阻塞。
Q2: InnoDB MVCC 的三个核心组件?
答案:
- 隐藏列:
DB_TRX_ID(最后修改的事务 ID)和DB_ROLL_PTR(指向 undo log 的回滚指针) - undo log 版本链:每次修改生成新版本,旧版本通过回滚指针串成链表
- Read View:记录创建时的活跃事务列表,用于判断版本的可见性
Q3: RC 和 RR 在 MVCC 层面的区别?
答案:
唯一区别是 Read View 的创建时机:
- RC:每次 SELECT 创建新的 Read View → 能看到其他事务已提交的最新数据
- RR:第一次 SELECT 创建 Read View,后续复用 → 始终看到事务开始时的快照
Q4: MVCC 能完全替代锁吗?
答案:
不能。MVCC 解决的是 读写并发 问题(读不阻塞写,写不阻塞读)。但 写写冲突 仍然需要锁来解决:
- 两个事务同时 UPDATE 同一行 → 需要排他行锁
- 防止幻读(当前读场景)→ 需要 Next-Key Lock
- SELECT FOR UPDATE → 需要排他锁
MVCC 和锁是配合使用的关系,不是替代关系。
Q5: undo log 什么时候可以被清理?
答案:
当没有任何 Read View 还需要读取某个 undo log 版本时,该 undo log 可以被清理。
具体来说,InnoDB 有个后台 purge 线程,它会定期检查:
- 找到当前所有活跃 Read View 中最小的
min_trx_id - trx_id 小于该值的 undo log 不再被任何事务需要
- 标记为可清理并回收空间
如果有长事务不提交,它的 Read View 一直持有,会导致 undo log 无法清理 → undo 表空间膨胀。