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 并不会直接覆盖原行,而是:
- 把旧版数据拷贝一份,写入 Undo Log 中。
- 把行的
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)是否对当前事务可见,步骤如下:
- 如果
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:
这个版本的事务 ID 落在活跃区间内,需要进一步判断:
- 如果
trx_id在m_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,自然就能看到后续提交的修改。