数据库笔记03:MVCC

Posted by LiYixian on Thursday, March 26, 2026 | 阅读 | ,阅读约 4 分钟

Summary:

  • 原理:隐藏字段、undo log 版本链、Read View
  • 在可重复读和读已提交下的工作区别
  • MVCC 能够解决幻读问题吗?

基础概念

数据库里的一行数据,不止有一个版本,而是有一条历史版本链。不同事务在读的时候,不一定读的是当前最新值,而是读「对自己来说合法的那个版本」。
因此,多版本并发控制 MVCC 可以理解为:让不同事务在同一时间看到同一行数据的不同版本,从而避免互相阻塞。
MVCC 用版本的空间和复杂度,交换了读写互斥带来的性能问题。它的重点是并发控制,换言之,在并发读写时保证数据看起来是合理的;其设计起因在于,很多时候读操作并不需要读最新值,只要保持一致性就可以了。

MVCC 的实现机制

MVCC 的整体结构有三样东西在互相配合:

  • 行记录里的 2 个隐藏字段
  • undo log 版本链
  • Read View 的 4 个字段

首先讲行记录里的隐藏字段,即每行数据的 Tag。InnoDB 里每行记录有 2 个隐藏列:

  • trx_id:最后一次修改这行数据的事务ID
  • roll_pointer:指向上一版本(undo log)

然后是第二个部分 undo log 版本链,这是核心结构。想象这样一个过程:

  • 初始状态 value = 10
  • 事务 A 修改 value = 20
  • 事务 B 再次修改 value = 30

此时,数据库不会单纯覆盖掉 10 -> 20 -> 30,而是变成:

当前行:value = 30 (trx_id = B)
   ↓ roll_ptr
undo log:value = 20 (trx_id = A)
   ↓
undo log:value = 10

这就是版本链。从中可以看出,undo log 不是单纯用来回滚的,它同时承担了存储历史版本的作用。

最后一部分是 Read View,可以将其理解为「在创建它的时刻,系统里正在活跃的事务快照」。其中核心有 4 个字段:

  • m_ids 活跃(且未提交的)事务列表
  • min_trx_id 最小活跃事务 ID
  • max_trx_id 创建 Read View 时应该给下一个事务的 ID
  • creator_trx_id 创建这个 Read View 的当前事务 ID

当我们执行一个 SELECT 时,数据库会沿着版本链,从新往旧判断每个版本对当前事务是否可见。简单地说,只看已经提交的版本,而且这个提交必须发生在 Read View 创建之前。

  • min_trx_id 还小的,已经提交了,可见
  • max_trx_id 还大的,在未来,不可见
  • 处在 m_ids 里的,还没提交,不可见
  • 不在 m_ids 里的,已经提交了,可见

把这三者串起来,就可以完整描述一个事务执行 SELECT 的流程了:

  • 生成一个 Read View
  • 拿到当前行的最新版本
  • 判断这个版本的 trx_id 是否可见,可见就返回,不可见就顺着 roll_ptr 找上一个版本
  • 一直找到最近的可见版本为止

读已提交、可重复读

读已提交和可重复读的区别,只在于生成 Read View 的时机:
在读已提交里,每次普通的查询语句,都会重新生成一个 Read View;
在可重复读里,第一次执行 select 时生成 Read View,之后整个事务都复用这个 Read View。

MVCC 能否解决幻读?

InnoDB 引擎的默认级别虽然是可重复读,但它很大程度上避免了幻读(并不是完全解决了)。
幻读可以分为两种情况,快照读和当前读:

对于普通 SELECT 语句,即快照读(Snapshot Read),它不会去读数据库最新的数据,而是基于 MVCC,读一个“对自己事务可见的版本”,所以看不到后来插入的数据,不会产生幻读。

但对于当前读(Current Read)语句(SELECT .... FOR UPDATE, UPDATE / DELETE / INSERT),它要读最新的数据,而且要对数据加锁,此时 MVCC 就没用了。为了解决这个幻读问题,引入了两种锁:

  • 间隙锁(Gap Lock)锁住两个值之间的空隙,如区间 (10, 20),别的事务不能往这个区间(如 id = 15)插入数据
  • 临键锁(Next-Key Lock)相当于行锁 + 间隙锁,比如对 id: 10, 20, 30,加的锁是 (10, 20], (20, 30], (30, +∞],这样其他事务既不能插入新数据、也不能修改已有行。

可重复读级别虽然能很大程度避免幻读,但还是没能完全解决。举两个例子:

  • 事务 A 查询 id = 5 的记录,不存在;
  • 事务 B 插入 id = 5 的记录;
  • 事务 A 更新 id = 5 的记录,可以成功更新;
  • 事务 A 再次查询 id = 5 的记录,就可以看到了。

除此之外,如果事务 A 先执行快照读语句,事务 B 插入记录,事务 A 再执行当前读语句,也会发生幻读。
如果要避免此类特殊场景,就尽量在开启事务之后,马上执行当前读的语句,对记录加一个 Next-Key Lock,这样可以避免其他事务插入新记录。