跳到主要内容

MVCC 原理

问题

什么是 MVCC?InnoDB 的版本链和 Read View 是如何工作的?快照读和当前读的区别是什么?

答案

什么是 MVCC

MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种 乐观并发控制 机制,通过维护数据的多个版本,实现 读写不冲突

传统锁MVCC
读写互斥读写不冲突
读要等写完读快照,不等待
写要等读完写不影响读
并发度低并发度高
核心思想

MVCC 让每个事务看到数据在某个时间点的 快照,而不是最新数据。这样读操作不需要加锁,极大提高了并发性能。

InnoDB 的 MVCC 实现

InnoDB 通过 三个要素 实现 MVCC:

  1. 隐藏列:每行数据有事务 ID 和回滚指针
  2. undo log 版本链:通过回滚指针串联数据的历史版本
  3. Read View:决定事务能看到哪些版本

隐藏列

InnoDB 每行数据有两个隐藏列(实际有三个,还有隐藏的 ROW_ID):

隐藏列占用说明
DB_TRX_ID6 字节最后修改该行的事务 ID
DB_ROLL_PTR7 字节回滚指针,指向 undo log 中的上一个版本

版本链

每次更新操作,旧版本写入 undo log,新版本的回滚指针指向旧版本,形成一条 版本链

过程

  1. 事务 100 插入 name='张三' → trx_id=100
  2. 事务 200 修改为 name='李四' → 旧值写入 undo log,新行 trx_id=200
  3. 事务 300 修改为 name='王五' → 旧值写入 undo log,新行 trx_id=300

Read View

Read View 是事务在某一时刻创建的"快照",用于判断版本链中哪个版本对当前事务可见。

Read View 包含以下关键字段:

字段含义
m_ids创建 Read View 时所有活跃(未提交)事务的 ID 列表
min_trx_idm_ids 中的最小值
max_trx_id系统下一个要分配的事务 ID(当前最大 trx_id + 1)
creator_trx_id创建该 Read View 的事务自己的 ID

可见性判断算法

对于版本链中某个版本的 trx_id,判断它是否对当前事务可见:

简化规则

  1. 自己修改的 → 可见
  2. 在 Read View 创建前已提交的 → 可见
  3. 在 Read View 创建后才开始的 → 不可见
  4. 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 UPDATE
SELECT ... LOCK IN SHARE MODE
INSERT/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 对比

特性InnoDBPostgreSQL
旧版本存储undo log(独立存储)直接存在数据页中
版本清理purge 线程清理 undo logVACUUM 清理死元组
空间回收undo log 空间自动复用需要 VACUUM 回收空间
更新操作原地更新 + undo log插入新元组 + 标记旧元组

PostgreSQL 的 MVCC 不需要回滚段,但可能导致表膨胀(dead tuples),需要定期 VACUUM。


常见面试问题

Q1: MVCC 是什么?解决了什么问题?

答案

MVCC 是多版本并发控制,通过维护数据的多个历史版本实现 读写不冲突

  • 不用 MVCC:读要加共享锁,写要加排他锁,读写互斥
  • 用 MVCC:读操作读历史快照(不加锁),写操作修改最新数据(加行锁),读写并行

解决的核心问题:提高并发读的性能,让事务能看到一致性快照而不被其他事务的写操作阻塞。

Q2: InnoDB MVCC 的三个核心组件?

答案

  1. 隐藏列DB_TRX_ID(最后修改的事务 ID)和 DB_ROLL_PTR(指向 undo log 的回滚指针)
  2. undo log 版本链:每次修改生成新版本,旧版本通过回滚指针串成链表
  3. 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 线程,它会定期检查:

  1. 找到当前所有活跃 Read View 中最小的 min_trx_id
  2. trx_id 小于该值的 undo log 不再被任何事务需要
  3. 标记为可清理并回收空间

如果有长事务不提交,它的 Read View 一直持有,会导致 undo log 无法清理 → undo 表空间膨胀。

相关链接