人人都会AI编程

4.5 MVCC(多版本并发控制)实现原理

更新时间:2026-07-10

如果你曾经好奇“为什么我在一个事务里反复查同一条数据,即使别的线程正在修改它,我看到的还是原来的值”,那 MVCC 就是答案。它是 InnoDB 在高并发下实现读写不互斥的核心武器,也是“可重复读”隔离级别的基石。

4.5.1 MVCC 要解决什么问题

在没有 MVCC 之前,数据库处理并发读写有两种极端:要么加锁(读和写互相阻塞,性能差),要么不加锁(可能读到未提交的脏数据)。MVCC 的巧妙之处在于:读操作不阻塞写,写操作也不阻塞读,因为每一次写不是直接覆盖原数据,而是产生一个新版本,读操作根据自己的“视野”选择合适的版本。

对开发者来说,这就意味着:

  • 一个正在运行的报表查询(长事务)不会被新来的插入更新阻塞。
  • 一个正在更新库存的事务,不会让其他用户查询库存时看到中间状态。
  • 你不需要在查询时主动加共享锁(LOCK IN SHARE MODE)来避免脏读,默认的普通 SELECT 就能返回一致的数据。

4.5.2 三个关键组件:版本号、Undo Log 版本链、Read View

MVCC 实现依赖三个紧密配合的组件,把它们串起来,你就理解了整个流程。

(1)事务版本号

每一个事务在开始的时候都会从数据库获取一个单调递增的系统版本号(称为 transaction id)。这个 id 并不是 AUTO_INCREMENT,而是 InnoDB 内部维护的一个全局变量,每开始一个新事务就递增。关键特征:

  • 事务 ID 越小,说明事务开始的越早。
  • 每一行数据都有两个隐藏列:trx_id(最近一次修改该行的事务 ID)和 roll_pointer(指向该行的上一个版本在 Undo Log 中的位置)。

(2)Undo Log 版本链

当一行数据被更新时,InnoDB 不会直接覆盖磁盘上的老数据,而是:

  1. 将修改前的旧数据(即该行的上一个版本)写入 Undo Log(回滚段),并记录当时的事务 ID。
  2. 将新数据写入缓冲池(然后刷盘),新行上的 roll_pointer 指向上一步写入 Undo Log 的旧版本记录。
  3. 如果这行数据再次被修改,又会继续生成一个新版本,串成一条单向链表。

这条链表就是 版本链,可以通过 roll_pointer 从最新版本一直追溯到最老的版本。即使多个事务同时修改同一行,每个事务都会产生自己的一条新版本挂在链上。这样,任何事务都能沿着链找到“在它开始那一刻”之前已经提交的最新数据。

(3)Read View 可见性判断

Read View 是一个“快照”,它记录了一个事务在某个时刻能看到哪些已提交的数据。具体包含:

  • m_ids:生成该 Read View 时,当前系统中所有活跃(未提交)的事务 ID 列表。
  • min_trx_id:活跃事务中的最小事务 ID。
  • max_trx_id:下一个即将分配的事务 ID(也就是当前全局事务 ID 的最大值加一)。
  • creator_trx_id:创建该 Read View 的事务自身的 ID(对当前事务可见性判断有用,例如自己修改的自己必须能看到)。

当一条 SQL 需要读取一行数据时,它拿到该行的最新版本,然后按顺序做判断:

  1. 如果该行的 trx_id 等于当前事务自己的 creator_trx_id,那么这一行就是自己修改的,可见。
  2. 如果该行的 trx_id 小于 min_trx_id,说明这个版本是 Read View 生成之前已经提交的事务修改的,可见。
  3. 如果该行的 trx_id 大于等于 max_trx_id,说明这个版本是在 Read View 生成之后才创建的事务修改的,不可见。
  4. 如果该行的 trx_id 介于 min_trx_idmax_trx_id 之间,那就看 trx_id 是否在 m_ids 列表中。如果在,说明该事务在 Read View 创建时还没提交,不可见;如果不在,说明当时已经提交,可见。

如果当前版本不可见,InnoDB 会沿着 Undo Log 版本链往前找,对每一个历史版本执行同样的判断,直到找到一个“可见”的版本返回。如果遍历到链尾仍不可见,就当作这行数据不存在。

这套流程保证了:在一个事务的生命周期内,你能看到的永远是在事务启动(或语句开始,取决于隔离级别)之前就已经提交的行版本。

4.5.3 不同隔离级别下的行为差异

MVCC 这个“快照”的特性,在不同隔离级别下区别很大——主要在于 Read View 的生成时机

  • 读未提交(READ UNCOMMITTED)

完全不使用 MVCC。它直接读取最新版本的数据,即使那个版本对应的修改事务还没提交。这种隔离级别会导致脏读,一般不用于生产。虽然 InnoDB 用 MVCC 也可以实现读未提交,但实际上它直接读最新行,因为无需保证一致性。

  • 读已提交(READ COMMITTED)

每次执行一条 SELECT 语句时,都会重新生成一个新的 Read View。这意味着即使你在同一个事务里先后执行相同的 SELECT,由于两条语句之间可能有其他事务提交了修改,第二个 SELECT 看到的数据可能和第一个不一样——这就是不可重复读。但好处是不会脏读,因为每次只读已提交的版本。

实例:事务 A 读某行,值为100;事务 B 提交将值改为200;事务 A 再读一次,得到200。这就是不可重复读。

  • 可重复读(REPEATABLE READ)

这是 MySQL InnoDB 的默认隔离级别,也是最常用的。它的特点是:只有在事务执行第一条 SELECT 语句时才生成一个 Read View,整个事务期间一直复用这个 Read View。因此,不论别的交易怎么提交修改,你在同一个事务里读到的结果都是一致的。

实例:同样的场景,事务 A 先读(生成 Read View),事务 B 提交修改,事务 A 再读,由于仍然使用旧的 Read View,它看到的还是100。这解决了不可重复读问题。

  • 串行化(SERIALIZABLE)

这种级别下,所有的 SELECT 语句会隐式转换为 SELECT ... LOCK IN SHARE MODE,也就是全部加共享锁。MVCC 实际上退化为基于锁的并发控制,避免了任何脏读、不可重复读和幻读,但并发性极差,极少使用。

对于幻读,MVCC 只能部分解决。在可重复读下,普通的 SELECT 因为使用同一个快照,不会看到其他事务插入的新行(所以不幻读);但是如果当前事务执行了 SELECT ... FOR UPDATE(加锁查询),则会使用“当前读”去读取最新数据,并配合间隙锁来阻止其他事务插入符合条件的行,从而真正防止幻读。这一点在实际开发中非常重要,切勿认为可重复读级别下所有操作都不会幻读。

4.5.4 实际开发中的常见现象与注意点

理解 MVCC 的原理后,很多线上诡异现象就变得可解释了:

  • 为什么长事务特别危险? 一个事务如果一直不提交,它产生的 Read View 就不会销毁,Undo Log 中那些被它“看到”的旧版本也就不能被清理(purge)。时间一长,Undo Log 膨胀,可能引起性能下降甚至磁盘空间爆满。所以线上应该密切关注持续时间超长的读事务。
  • 为什么更新操作总是“当前读”? UPDATEDELETE 本身并不是基于快照,而总是读取最新提交的行版本,然后再应用修改。如果更新条件依赖一个基于快照读出的数据,可能会造成丢失更新问题(例如你先 SELECTUPDATE,但 UPDATE 时数据已经被别人改过)。解决办法是通过 SELECT ... FOR UPDATE 做当前读并加锁,或者使用乐观锁版本号来检测冲突。
  • 回滚段的性能开销:虽然 MVCC 好处很多,但它不是没有代价。更新频繁的表,Undo Log 写入和版本链查找都有成本。所以盲目使用长事务 + 频繁更新需要慎重评估。

总之,MVCC 是 InnoDB 并发能力的关键支撑,它用一种精巧的“多版本并发控制”机制,实现了读写分离,让现代互联网应用既能保持较好的数据一致性,又不至于在并发访问时互相阻塞。理解了它,你就能更好地设计事务边界、排查并发问题,并在合适的场景下选择恰当的隔离级别。