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;

数据库通常需要执行以下步骤:

  1. 定位目标记录;
  2. 获取必要的行锁,检查写写冲突;
  3. 保存旧值,或创建能够重建旧值的版本信息;
  4. 生成 balance = 80 的新版本;
  5. 把新版本与事务 T20 关联;
  6. 写入 WAL、Redo Log 等恢复日志;
  7. 提交事务并更新事务状态。

逻辑版本链变为:

当前版本: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 因此承担两类职责:

  1. 事务失败时撤销修改;
  2. 为 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,至少需要完成以下工作:

  1. 为事务分配 ID、时间戳或提交序列号;
  2. 更新时生成新版本并保留历史版本;
  3. 为读取建立一致性事务快照;
  4. 根据快照执行版本可见性判断;
  5. 使用锁或冲突检测解决并发写入;
  6. 在安全边界推进后回收旧版本。

最终可以用一句话概括:

MVCC 通过“版本链 + 事务快照 + 可见性判断”提高读写并发能力,再通过锁、冲突检测和版本回收保证完整的事务语义与长期运行效率。