人人都会AI编程

事务版本号、Undo Log 版本链、Read View 可见性判断

更新时间:2026-07-10

MVCC 的名字里虽然带着“多版本”,但本质上是一套“在并发环境下,让每个事务看到合适的数据版本”的机制。它的实现并不复杂,核心就三样东西:事务版本号、Undo Log 版本链、Read View。理解了这三者如何协作,你就抓住了 MVCC 的精髓。

事务版本号:每行数据的“出生证明”和“过期标签”

在 InnoDB 中,每一行数据都有两个隐藏的字段:

  • DB_TRX_ID(事务 ID):记录最后一次修改(包括插入和更新)该行的事务 ID。每个事务在开始时会分配一个严格递增的 ID,你可以把它理解为行版本的“修改者编号”。
  • DB_ROLL_PTR(回滚指针):指向这条行在 Undo Log 中的上一条旧版本,形成一条版本链。

另外,删除操作在 InnoDB 中并不是物理删除,而是打上删除标记。行上还有一个隐藏的 DB_ROW_ID 字段(如果没有主键时作为聚簇索引键),以及一个标志位用来标记行是否被删除。删除时的“版本”同样是靠事务 ID 来记录的。

这些事务 ID(版本号)就是 MVCC 用于判断可见性的关键依据。一个事务能看到哪些行数据,取决于这些行的最新版本或者历史版本的 DB_TRX_ID 与当前事务自身的 ID 之间的关系。

Undo Log 版本链:一行数据的“变更历史”

当你更新一行数据时,InnoDB 并不会直接覆盖原行,而是:

  1. 把旧版数据拷贝一份,写入 Undo Log 中。
  2. 把行的 DB_TRX_ID 更新为当前事务的 ID,并把 DB_ROLL_PTR 指向刚才写入 Undo Log 的那个旧版本。

这样,同一行数据在多次更新后,会形成一条从当前最新版本出发、通过回滚指针串连到各个历史版本的链条。例如:

当前版本 (tx_id=108)  ——>  历史版本1 (tx_id=105)  ——>  历史版本2 (tx_id=101)  ——>  原始版本 (tx_id=99)

这个链条就是 Undo Log 版本链。它存在的价值是:当需要读取一个旧版本时(比如可重复读隔离级别下的快照读),InnoDB 可以根据版本链,沿着指针一路往回找,直到找到一个对当前事务“可见”的版本。

这也解释了为什么长事务会让 Undo Log 膨胀:如果有一个老事务还活跃着,它可能还需要用到那些已被更新的旧版本,InnoDB 就不能清理掉它们,导致 Undo Log 越来越大。

Read View:事务启动时的“全局快照”

Read View 是事务进行快照读时生成的一个数据结构,它决定了在当前事务的视角下,哪些版本的数据是可见的。Read View 的核心内容包含:

  • m_ids:一个列表,记录生成 Read View 时,系统里所有“活跃中”(未提交)的事务 ID。
  • min_trx_id:活跃事务列表中最小的事务 ID。
  • max_trx_id:系统下一次要分配给新事务的 ID,也就是当前全局最大事务 ID + 1。
  • creator_trx_id:创建这个 Read View 的事务自己的 ID。

你可以把 Read View 想象成事务启动那一刻的“数据库状态快照”。它划了一条线:在它生成时就已经提交的事务,所做的修改对当前事务可见;尚未提交的事务,所做的修改不可见。

不同的隔离级别下,Read View 的生成时机不同:

  • 读已提交(Read Committed):每次执行快照读时,都会生成一个新的 Read View。这意味着事务途中如果别的事务提交了,你能看到新的数据。
  • 可重复读(Repeatable Read):在事务第一次执行快照读时生成一个 Read View,之后整个事务都复用这个 Read View。所以查询结果始终一致。

可见性判断规则:这个版本我能看到吗?

有了版本链和 Read View,InnoDB 就可以按照一套简单的规则来判断一个数据版本是否可见。判断一个版本(记为 trx_id)是否对当前事务可见,步骤如下:

  1. 如果 trx_id == creator_trx_id

那就是自己修改的,当然可见。

  1. 如果 trx_id < min_trx_id

说明修改这个版本的事务在生成 Read View 之前就已经提交了,可见。

  1. 如果 trx_id >= max_trx_id

说明修改这个版本的事务是在 Read View 生成之后才开始的,不可见。

  1. 如果 min_trx_id <= trx_id < max_trx_id

这个版本的事务 ID 落在活跃区间内,需要进一步判断:

  • 如果 trx_idm_ids 列表中,说明该事务在 Read View 生成时还是活跃(未提交)的,不可见。
  • 如果 trx_id 不在 m_ids 列表中,说明这个事务在 Read View 生成时已经提交,可见。

用一句最通俗的话来总结:如果这个版本是由“在我启动前就已提交的事务”修改的,我就能看到;如果是由“我启动时还没提交的事务”或者“我启动后才开始的事务”修改的,我就看不到。

当判断当前最新版本不可见时,InnoDB 就会顺着 DB_ROLL_PTR 指针往版本链的上一个版本走,重复上述判断,直到找到一个可见的版本为止。如果一直找到头都没有可见版本(比如行第一次被一个不可见的事务插入),那么这条记录对当前事务来说就不存在。

一个简化的例子

假设有以下事件序列:

  • 事务 A(ID=100)开启并插入一行,提交。
  • 事务 B(ID=101)开启并修改该行,提交。
  • 事务 C(ID=102)开启。

此时,C 生成 Read View:

  • m_ids = [102](因为 C 自己活跃,其他事务在 C 开始时应该加入活跃列表;A、B 已提交,所以不在)。
  • min_trx_id = 102,max_trx_id = 103(下一个事务 ID)。
  • creator_trx_id = 102。

当前行版本 trx_id = 101。判断:

  • 101 < min_trx_id (102)?是,条件成立,101 版本可见。因此事务 C 会看到事务 B 修改后的数据。

如果当时 B 还没提交(即 B 也在活跃列表里),那么 101 >= 102?不满足 < min_trx_id,但 101 < max_trx_id (103) 且 101 在 m_ids 中,就不可见,C 需要沿着版本链找到 trx_id=100 的版本,最终看到 A 插入的数据。

这就是 MVCC 如何让事务 C 在事务 B 尚未提交时,仍旧看到一致的旧版本;而在 B 提交后,又能看到最新数据。背后没有魔法,只是版本号、版本链和一份活跃事务清单的组合运用。

理解这一点后,你再回头看“可重复读为什么能避免不可重复读”就一目了然了:因为 Read View 只生成一次,不管别的事务怎么提交,判断规则得出的结果始终不变。而“读已提交”每次重新生成 Read View,自然就能看到后续提交的修改。