Summary:
- 原理:隐藏字段、undo log 版本链、Read View
- 在可重复读和读已提交下的工作区别
- MVCC 能够解决幻读问题吗?
基础概念
数据库里的一行数据,不止有一个版本,而是有一条历史版本链。不同事务在读的时候,不一定读的是当前最新值,而是读「对自己来说合法的那个版本」。
因此,多版本并发控制 MVCC 可以理解为:让不同事务在同一时间看到同一行数据的不同版本,从而避免互相阻塞。
MVCC 用版本的空间和复杂度,交换了读写互斥带来的性能问题。它的重点是并发控制,换言之,在并发读写时保证数据看起来是合理的;其设计起因在于,很多时候读操作并不需要读最新值,只要保持一致性就可以了。
MVCC 的实现机制
MVCC 的整体结构有三样东西在互相配合:
- 行记录里的 2 个隐藏字段
- undo log 版本链
- Read View 的 4 个字段
首先讲行记录里的隐藏字段,即每行数据的 Tag。InnoDB 里每行记录有 2 个隐藏列:
trx_id:最后一次修改这行数据的事务IDroll_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最小活跃事务 IDmax_trx_id创建 Read View 时应该给下一个事务的 IDcreator_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,这样可以避免其他事务插入新记录。