MVCC 多版本并发控制
问题
什么是 MVCC?它是如何实现的?Read View 的创建时机与可见性判断规则是什么?
答案
什么是 MVCC
MVCC(Multi-Version Concurrency Control,多版本并发控制) 是 InnoDB 实现事务隔离性的核心机制。它通过为每一行数据维护多个版本,使得读操作不需要加锁就能获取一致性快照,实现了读写不阻塞。
| 操作组合 | 是否冲突 | 说明 |
|---|---|---|
| 读 - 读 | ❌ 不冲突 | 都不加锁 |
| 读 - 写 | ❌ 不冲突 | 读走 MVCC 快照,写走当前版本 |
| 写 - 写 | ✅ 冲突 | 通过行锁串行化 |
- READ UNCOMMITTED 直接读最新值,不使用 MVCC
- SERIALIZABLE 所有读都加锁,不使用 MVCC
MVCC 的三大组件
1. 隐藏字段
InnoDB 为每一行数据自动添加 3 个隐藏字段:
| 字段 | 大小 | 说明 |
|---|---|---|
DB_TRX_ID | 6 字节 | 最近修改该行的事务 ID |
DB_ROLL_PTR | 7 字节 | 回滚指针,指向 undo log 中该行的上一个版本 |
DB_ROW_ID | 6 字节 | 隐含自增 ID(无主键时自动生成) |
2. Undo Log 版本链
每次修改一行数据,旧版本都会被写入 undo log,通过 DB_ROLL_PTR 串成一条版本链:
执行 SELECT 时,InnoDB 沿着版本链从新到旧查找,找到第一个对当前事务可见的版本返回。
3. Read View(读视图)
Read View 是事务在执行快照读时创建的一个数据快照,用于判断版本链中哪个版本对当前事务可见。
Read View 包含 4 个关键字段:
| 字段 | 说明 |
|---|---|
m_ids | 创建 Read View 时活跃的事务 ID 列表(已开始但未提交的事务) |
min_trx_id | m_ids 中的最小值(活跃事务中最小的 ID) |
max_trx_id | 创建 Read View 时系统即将分配的下一个事务 ID(最大活跃 ID + 1) |
creator_trx_id | 创建该 Read View 的事务 ID |
可见性判断规则
对于版本链中的每一个版本,其 trx_id 按以下规则判断可见性:
简化记忆:
- 自己修改的 → 可见
- 事务 ID < min_trx_id → 创建 Read View 前已提交 → 可见
- 事务 ID >= max_trx_id → 创建 Read View 后才开始 → 不可见
- 在 m_ids 中 → 创建 Read View 时还未提交 → 不可见
- 不在 m_ids 中且 < max_trx_id → 创建 Read View 前已提交 → 可见
Read View 创建时机
RC 和 RR 的核心区别就在于 Read View 的创建时机:
| 隔离级别 | Read View 创建时机 | 效果 |
|---|---|---|
| RC(读已提交) | 每次 SELECT 都创建新的 Read View | 每次读都能看到最新提交的数据 |
| RR(可重复读) | 事务第一次 SELECT 时创建,后续复用 | 整个事务看到的都是同一个快照 |
完整示例
假设有一行数据 id=1, name='张三',初始 trx_id=100(已提交)。
如果隔离级别是 RC,第二次 SELECT 时会重新创建 Read View,此时事务 B 已提交,m_ids 中不再包含 300,所以 name='李四' 变为可见,结果为 name='李四'。
MVCC 与锁的配合
| 操作 | 使用机制 | 说明 |
|---|---|---|
普通 SELECT | MVCC(快照读) | 不加锁,读版本链中可见版本 |
SELECT ... FOR UPDATE | 当前读 + X 锁 | 读最新已提交版本,加排他锁 |
SELECT ... FOR SHARE | 当前读 + S 锁 | 读最新已提交版本,加共享锁 |
INSERT / UPDATE / DELETE | 当前读 + X 锁 | 操作最新版本,加排他锁 |
MVCC 解决了快照读的隔离问题,锁解决了当前读的隔离问题。两者配合实现了 InnoDB 的完整事务隔离。
关于锁的详细说明,参见 MySQL 锁机制。
Undo Log 的清理
版本链不能无限增长,InnoDB 有一个 Purge 线程 负责清理不再需要的 undo log:
- 当没有任何活跃事务需要访问某个旧版本时,该版本才能被清理
- 这就是长事务导致 undo log 膨胀的原因:长事务的 Read View 持有较老的 min_trx_id,导致大量旧版本无法清理
常见面试问题
Q1: MVCC 是什么?解决了什么问题?
答案:
MVCC(多版本并发控制)是一种并发控制技术,InnoDB 通过为数据行维护多个版本(版本链),让读操作可以读取历史版本,从而实现:
- 读写不阻塞:读操作不需要加锁,不会被写操作阻塞,提高并发性能
- 一致性读:事务在执行期间看到的数据是一致的快照,不受其他事务影响
MVCC 通过三个组件实现:隐藏字段(trx_id、roll_ptr)、undo log 版本链、Read View。
Q2: RC 和 RR 隔离级别下 MVCC 的区别是什么?
答案:
唯一的区别是 Read View 的创建时机:
- RC(读已提交):每次 SELECT 都创建新的 Read View,所以能看到其他事务在两次读之间提交的数据,导致不可重复读
- RR(可重复读):事务第一次 SELECT 时创建 Read View,整个事务复用同一个 Read View,所以看到的始终是同一个快照,实现了可重复读
这也是为什么说 RR 的性能开销其实不比 RC 大多少——区别仅在于 Read View 的创建频率。
Q3: MVCC 能完全解决幻读吗?
答案:
不能完全解决。MVCC 只能解决快照读场景下的幻读,无法解决当前读的幻读。
在 RR 级别下:
- 快照读(普通 SELECT):通过 MVCC 看不到其他事务新插入的行 → 没有幻读 ✅
- 当前读(SELECT FOR UPDATE / INSERT / UPDATE / DELETE):需要通过 Next-Key Lock(间隙锁) 来阻止其他事务在范围内插入新行 → 通过锁解决幻读 ✅
但如果事务中先快照读后当前读,可能出现逻辑上的幻象。所以严格说,InnoDB 通过 MVCC + Next-Key Lock 组合来解决幻读。
Q4: Read View 的可见性判断规则是什么?
答案:
对版本链中某个版本的 trx_id 进行判断:
trx_id == creator_trx_id→ 可见(自己的修改)trx_id < min_trx_id→ 可见(在 Read View 创建前已提交)trx_id >= max_trx_id→ 不可见(在 Read View 创建后才产生的事务)min_trx_id <= trx_id < max_trx_id:- 在
m_ids中 → 不可见(创建 Read View 时尚未提交) - 不在
m_ids中 → 可见(创建 Read View 前已提交)
- 在
如果当前版本不可见,就顺着 roll_ptr 找版本链中的上一个版本,重复判断直到找到可见版本。
Q5: 为什么长事务会导致 undo log 膨胀?
答案:
长事务持有的 Read View 中 min_trx_id 很小(等于事务开始时的活跃事务最小 ID),这意味着所有 trx_id >= min_trx_id 的旧版本都不能被 Purge 线程清理,因为长事务可能还需要读取这些旧版本。
后果:
- undo log 持续增长,占用大量磁盘空间
- 读性能下降:版本链越长,快照读需要遍历的版本越多
- 回滚段空间不足:可能导致
tread is too old错误
因此生产环境应监控长事务并及时处理:
-- 查找超过 60 秒的长事务
SELECT * FROM information_schema.INNODB_TRX
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;