MVCC(Multi-Version Concurrency Control,多版本并发控制)是现代数据库实现高并发事务的重要机制。它的核心思想并不复杂:数据被修改时,不立即丢弃旧值,而是保留多个版本;事务读取数据时,根据自己的快照选择可见版本。
因此,读事务可以继续访问旧版本,写事务则创建新版本。普通读取通常不需要等待并发写入完成,写入也不必等待读事务释放共享锁。
本文从 MVCC 要解决的问题出发,依次说明版本存储、事务快照、可见性判断、写冲突控制和旧版本回收,并比较 InnoDB 与 PostgreSQL 的典型实现。
一、为什么需要 MVCC
假设数据库中存在一条记录:
账户 A 的余额 = 100
事务 T1 正在读取这条记录,与此同时,事务 T2 希望把余额修改为 80。
如果所有读取都依赖共享锁,那么 T2 可能必须等待 T1 结束;如果 T2 已经持有排他锁,T1 又可能必须等待 T2。锁能够保证正确性,但大量读写互相等待会降低系统并发能力,并增加死锁概率。
MVCC 使用另一种思路处理普通读取:
新版本:balance = 80,由 T2 创建
↓
旧版本:balance = 100
如果 T1 的事务快照不允许它看到 T2 的修改,T1 就读取旧版本 100;在 T2 提交后启动的新事务,则可以读取新版本 80。
于是,同一时刻可以出现:
旧事务看到 100
新事务看到 80
这不是数据不一致,而是两个事务观察到了数据库在不同逻辑时刻的一致状态。
二、MVCC 的五个组成部分
一个完整的 MVCC 实现通常包含五个部分:
MVCC = 多版本存储
+ 事务快照
+ 可见性判断
+ 写冲突控制
+ 旧版本回收
只保存历史版本还不够。数据库还必须知道版本由谁创建、事务能够看到哪些版本、两个写事务冲突时如何处理,以及历史版本何时可以安全删除。
1. 多版本存储
每次更新都会产生一个逻辑上的新版本:
V3:balance = 80 创建事务 = T30
↓
V2:balance = 120 创建事务 = T20
↓
V1:balance = 100 创建事务 = T10
数据库可以把多个版本直接保存在表中,也可以只在数据页保存当前版本,再通过 Undo 记录重建历史版本。
2. 事务标识
数据库通常为事务分配单调递增的事务 ID、提交序列号或逻辑时间戳。行版本需要记录足够的元数据,例如:
- 哪个事务创建了该版本;
- 哪个事务删除或替代了该版本;
- 创建事务是否已经提交;
- 如何找到前一个版本。
这些元数据是判断版本可见性的基础。
3. 事务快照
事务执行一致性读取时,数据库会创建一个快照。快照描述某个逻辑时刻的事务状态,例如:
- 创建快照时哪些事务仍然活跃;
- 哪些事务已经提交;
- 当前事务自己的标识;
- 快照能够观察到的事务 ID 边界。
在不同系统中,它可能被称为 Read View、Snapshot 或 Consistent View。
4. 可见性判断
数据库把行版本的事务元数据与当前快照进行比较,决定该版本是否可见。如果最新版本不可见,就沿版本链寻找更旧的版本。
5. 版本回收
历史版本不能永久保留。当数据库确认没有任何活跃事务还可能读取某个旧版本时,就可以通过 Purge、VACUUM 或类似机制回收空间。
三、事务快照如何判断版本是否可见
一种常见的 Read View 可以抽象为:
creator_id:创建快照的事务 ID
min_active:当前最小活跃事务 ID
max_id:下一个待分配事务 ID
active_ids:创建快照时未提交的事务集合
对于某个由 version_id 创建的数据版本,可见性判断可以简化为:
version_id == creator_id
当前事务自己的修改,通常可见
version_id < min_active
创建快照前通常已经提交,可见
version_id >= max_id
创建快照后才产生,不可见
version_id 位于 active_ids 中
创建快照时仍未提交,不可见
其他情况
创建快照时通常已经提交,可见
查找过程可以表示为:
function findVisibleVersion(row, snapshot):
version = row.latestVersion
while version != null:
if isVisible(version, snapshot):
return version
version = version.previousVersion
return null
真实实现还会处理事务提交状态、删除标记、当前事务自身操作和异常恢复等情况,但核心始终是:
使用事务快照筛选版本,而不是无条件读取最新值。
四、一次 UPDATE 如何产生新版本
假设事务 T20 执行:
UPDATE account
SET balance = 80
WHERE id = 1;
数据库通常需要执行以下步骤:
- 定位目标记录;
- 获取必要的行锁,检查写写冲突;
- 保存旧值,或创建能够重建旧值的版本信息;
- 生成
balance = 80的新版本; - 把新版本与事务 T20 关联;
- 写入 WAL、Redo Log 等恢复日志;
- 提交事务并更新事务状态。
逻辑版本链变为:
当前版本:balance = 80,创建者 T20
↓
历史版本:balance = 100,创建者 T10
如果另一个事务的快照不能看到 T20,就继续读取历史版本。
这里必须强调:MVCC 没有消灭锁。 两个事务同时修改同一行时,仍然需要行锁、乐观冲突检测或其他机制决定谁先提交。MVCC 主要降低的是普通读取与写入之间的阻塞。
五、快照读和当前读
理解 MVCC 时,很容易误以为所有查询都会读取历史版本。实际上,数据库通常区分快照读和当前读。
快照读
普通查询一般属于快照读:
SELECT balance
FROM account
WHERE id = 1;
数据库根据事务快照选择可见版本,通常不需要给目标行添加阻塞式共享锁。
当前读
下面这些操作需要基于最新的可用版本执行:
SELECT *
FROM account
WHERE id = 1
FOR UPDATE;
UPDATE account
SET balance = 80
WHERE id = 1;
DELETE FROM account
WHERE id = 1;
它们通常需要读取较新的已提交版本,并使用行锁、范围锁或冲突检测处理并发修改。
因此可以概括为:
快照读:根据快照读取合适的历史版本
当前读:读取当前可操作版本,并参与锁与冲突控制
六、隔离级别如何影响快照
MVCC 的行为与事务隔离级别密切相关。
Read Committed:每条语句看到新的已提交状态
在常见的 Read Committed 实现中,每条查询语句都获取新的快照:
T1 第一次查询 → 100
T2 修改为 200 并提交
T1 第二次查询 → 200
它可以避免脏读,但同一事务重复读取时可能得到不同结果。
Repeatable Read:事务内复用快照
在常见的 Repeatable Read 实现中,事务内的快照读复用同一个快照:
T1 第一次查询 → 100
T2 修改为 200 并提交
T1 第二次查询 → 仍然是 100
因此同一事务中的重复快照读能够保持一致。
不过,隔离级别的具体语义由数据库实现决定。MVCC 本身也不能自动解决所有序列化异常。例如两个事务分别读取不同记录,再基于读取结果更新另一条记录,可能发生写偏差。
要实现 Serializable,数据库通常还需要:
- 谓词锁或范围锁;
- 序列化快照隔离;
- 读写依赖检测;
- 冲突事务回滚与重试。
七、InnoDB 如何实现 MVCC
InnoDB 采用“当前记录加 Undo 历史”的实现方式。聚簇索引记录中包含隐藏的事务信息,概念上包括:
DB_TRX_ID:最后修改该记录的事务 ID
DB_ROLL_PTR:指向对应的 Undo 记录
更新一行时,InnoDB 会把恢复旧值所需的信息写入 Undo Log,再修改数据页中的当前记录,并通过回滚指针把当前版本连接到历史版本。
数据页中的当前版本
↓ DB_ROLL_PTR
Undo 中的上一版本
↓
更旧的 Undo 版本
一致性读取发现当前版本不可见时,会沿着 Undo 版本链回溯,重建对当前 Read View 可见的旧版本。
Undo Log 因此承担两类职责:
- 事务失败时撤销修改;
- 为 MVCC 一致性读取提供历史版本。
当所有可能读取旧版本的事务都已经结束后,Purge 线程才能清理不再需要的 Undo 和删除记录。
八、PostgreSQL 如何实现 MVCC
PostgreSQL 采用多元组版本方式。更新一行时,通常会创建新的行元组,旧元组暂时保留在表中。
每个元组都带有事务可见性信息,概念上包括:
xmin:创建该版本的事务
xmax:删除或替代该版本的事务
更新后的状态可以表示为:
旧元组:xmin=T10, xmax=T20, balance=100
新元组:xmin=T20, xmax=0, balance=80
查询根据快照以及 T10、T20 的提交状态,判断哪个元组对当前事务可见。
旧元组不会立即删除,因为老事务可能仍然需要它。等旧版本不再对任何事务可见后,VACUUM 才能回收死亡元组占用的空间。
两种实现的差异可以简化为:
InnoDB:当前版本主要在数据页,历史版本通过 Undo 重建
PostgreSQL:新旧行版本可以同时存在于表的数据页中
它们的数据组织不同,但都依赖事务快照、版本元数据和垃圾回收。
九、为什么长事务会伤害 MVCC
数据库删除旧版本之前,必须确认最老的活跃快照也不再需要它。如果一个事务长时间不提交,它持有的旧快照就会阻止版本回收。
可能造成的后果包括:
- Undo 空间持续增长;
- PostgreSQL 表和索引膨胀;
- Purge 或 VACUUM 无法推进;
- 版本链变长,查询需要回溯更多历史记录;
- 事务 ID 回收压力增加;
- 存储空间和查询延迟上升。
因此,生产系统应避免在事务中执行长时间计算、等待网络请求或人工操作,也应监控长事务和最老活跃快照。
十、一个完整的并发示例
初始值为:
x = 100
事务执行顺序如下:
1. T1 开始并建立快照
2. T1 查询 x,得到 100
3. T2 开始
4. T2 将 x 更新为 200
5. T2 提交
6. T1 再次查询 x
此时版本链为:
V2:x = 200,由 T2 创建
↓
V1:x = 100,历史版本
如果 T1 在 Repeatable Read 下复用原快照:
检查 V2 → 对 T1 不可见
检查 V1 → 对 T1 可见
返回 100
而在 T2 提交后启动的事务 T3 可以看到 V2:
T1 看到 100
T3 看到 200
与此同时,如果 T1 试图更新这条记录,它就不能仅凭旧快照直接覆盖数据,而需要进入数据库的当前读和写冲突处理流程。
十一、MVCC 的优点与代价
优点
- 普通读取通常不会阻塞写入;
- 写入通常不会阻塞普通快照读;
- 可以提供一致性快照;
- 适合读多写多的高并发事务系统;
- 降低共享锁使用频率和读写锁竞争。
代价
- 需要额外空间保存历史版本;
- 可见性判断增加读取成本;
- 版本链过长会影响性能;
- 需要复杂的垃圾回收机制;
- 长事务会阻碍旧版本清理;
- 写写冲突仍需要锁或冲突检测。
总结
MVCC 的本质不是“完全无锁”,而是把普通读取从“等待最新值”改为“读取对当前快照可见的版本”。
一个数据库要实现 MVCC,至少需要完成以下工作:
- 为事务分配 ID、时间戳或提交序列号;
- 更新时生成新版本并保留历史版本;
- 为读取建立一致性事务快照;
- 根据快照执行版本可见性判断;
- 使用锁或冲突检测解决并发写入;
- 在安全边界推进后回收旧版本。
最终可以用一句话概括:
MVCC 通过“版本链 + 事务快照 + 可见性判断”提高读写并发能力,再通过锁、冲突检测和版本回收保证完整的事务语义与长期运行效率。