MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种通过在数据行上维护多个版本来实现高并发的数据库控制技术。对于像 InnoDB 这样的事务型存储引擎而言,MVCC 是保证在特定事务隔离级别下实现一致性读的关键。简单来说,它允许一个事务读取到另一个正在更新的事务在更新之前的数据版本,从而避免了读操作因等待写锁而阻塞。
需要注意的是,MVCC 并没有统一的标准,不同数据库管理系统的实现方式各不相同。本文将聚焦于 MySQL InnoDB 存储引擎中 MVCC 的具体实现机制。
快照读与当前读
MVCC 在 InnoDB 中的主要目标是为了提升数据库的并发性能,用更高效的方式处理“读-写”冲突,实现不加锁的非阻塞读。这种非阻塞读被称为“快照读”。与之相对的“当前读”则是一种加锁的读操作,属于悲观锁的实现。从这个角度看,MVCC 本质上是乐观锁思想的一种应用。
快照读
快照读,也称为一致性读,读取的是数据的某个快照版本。不加锁的简单 SELECT 语句都属于快照读。
SELECT * FROM player WHERE ...
快照读的实现正是基于 MVCC。它并非总是读取数据的最新版本,而可能读取到某个历史版本。快照读有效的前提是事务隔离级别不能是 SERIALIZABLE(串行化),因为在串行化级别下,快照读会退化为当前读。
当前读
当前读读取的是数据记录的最新版本。为了保证在读取过程中记录不被其他事务修改,当前读会对记录进行加锁。所有加锁的读操作,以及数据的增、删、改操作,都会触发当前读。
SELECT * FROM student LOCK IN SHARE MODE; -- 共享锁
SELECT * FROM student FOR UPDATE; -- 排他锁
INSERT INTO student VALUES ...; -- 排他锁
DELETE FROM student WHERE ...; -- 排他锁
UPDATE student SET ...; -- 排他锁
MVCC 实现原理详解
MVCC 的实现依赖于几个核心组件:隐藏字段、Undo Log 版本链以及 ReadView。
隐藏字段
InnoDB 的聚簇索引记录中,除了用户定义的列,还包含两个对 MVCC 至关重要的隐藏列:
- trx_id:当一个事务对某条记录进行修改时,都会将自己的事务 ID 赋值给这个隐藏列。
- roll_pointer:每次对记录进行修改时,都会将旧版本的数据写入
Undo Log。这个隐藏列就是一个指针,用于定位该记录修改前的信息(即指向对应的 Undo Log 记录)。
Undo Log 版本链
让我们通过一个例子来理解版本链的形成过程。假设 student 表中有一条初始数据:
SELECT * FROM student ;
/*
+----+--------+--------+
| id | name | class |
+----+--------+--------+
| 1 | 张三 | 一班 |
+----+--------+--------+
1 row in set (0.07 sec)
*/
假设插入这条记录的事务 ID 为 8,那么该记录在数据库中的初始状态如下图所示。这里的 insert undo 日志仅在事务回滚时发挥作用,事务提交后,其所占用的空间就可能被系统回收。

接下来,事务 ID 分别为 10 和 20 的两个事务,按以下顺序对这条记录进行更新:
| 发生时间顺序 |
事务10 |
事务20 |
| 1 |
BEGIN; |
|
| 2 |
|
BEGIN; |
| 3 |
UPDATE student SET name=“李四” WHERE id=1; |
|
| 4 |
UPDATE student SET name=“王五” WHERE id=1; |
|
| 5 |
COMMIT; |
|
| 6 |
|
UPDATE student SET name=“钱七” WHERE id=1; |
| 7 |
|
UPDATE student SET name=“宋八” WHERE id=1; |
| 8 |
|
COMMIT; |
你可能会问,两个事务能交叉更新同一条记录吗?答案是不能,因为这会导致“脏写”。InnoDB 通过锁机制来防止这种情况:第一个事务更新记录后会对其加锁,第二个事务必须等待第一个事务提交并释放锁后才能继续更新。
每一次更新记录,都会产生一条对应的 Undo Log。每条 Undo Log 都有一个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有此属性,因为它没有更早的版本),将这些 Undo Log 通过 roll_pointer 连接起来,就形成了一个链表,我们称之为 版本链。

每次更新后,旧值都被存入一条 Undo Log,代表该记录的一个历史版本。随着更新次数的增加,所有版本通过 roll_pointer 连成链表,链表的头节点就是当前记录的最新值。每个版本中都保存着生成该版本时的事务 ID (trx_id)。
ReadView:可见性的裁判
当多个事务更新同一行记录,产生了多个历史快照(保存在 Undo Log 中)时,一个进行快照读的事务应该读取哪个版本呢?这就需要 ReadView 来决定了,它解决了行的可见性问题。
ReadView 是事务在执行快照读操作时生成的一个“读视图”,可以理解为数据库在某个时刻的一个快照。InnoDB 会为每个事务构造一个数组,用来记录并维护系统当前 活跃事务(已启动但未提交)的 ID。
设计思路
READ UNCOMMITTED 级别:可以直接读到未提交的数据,所以总是读取最新版本。
SERIALIZABLE 级别:采用加锁方式访问记录,不依赖 MVCC。
READ COMMITTED 和 REPEATABLE READ 级别:都必须保证只能读取到 已提交事务 修改过的记录。核心问题就是判断版本链中哪个版本对当前事务是可见的,这正是 ReadView 的职责。
一个 ReadView 主要包含以下 4 个重要内容:
- creator_trx_id:创建这个 ReadView 的事务 ID。
注意:只有执行 INSERT、DELETE、UPDATE 等写操作的事务才会被分配唯一的事务 ID,只读事务的 ID 默认为 0。
- trx_ids:生成 ReadView 时,系统中所有活跃的读写事务的事务 ID 列表。
- up_limit_id:活跃事务 ID 列表
trx_ids 中最小的事务 ID。
- low_limit_id:生成 ReadView 时,系统应该分配给下一个事务的 ID 值。可以理解为“当前系统最大事务ID + 1”。
重要提示:low_limit_id 并非 trx_ids 中的最大值。事务 ID 是递增分配的。例如,现有 ID 为 1,2,3 的事务,ID 为 3 的事务提交后,一个新事务生成 ReadView 时,trx_ids 包含 1 和 2,up_limit_id 是 1,low_limit_id 则是 4。

可见性规则
有了 ReadView,判断版本链中某个版本是否对当前事务可见的规则如下:
- 如果被访问版本的
trx_id 等于 ReadView 中的 creator_trx_id,说明当前事务在访问它自己修改过的记录,该版本可见。
- 如果被访问版本的
trx_id 小于 ReadView 中的 up_limit_id,说明生成该版本的事务在当前事务生成 ReadView 前已经提交,该版本可见。
- 如果被访问版本的
trx_id 大于或等于 ReadView 中的 low_limit_id,说明生成该版本的事务在当前事务生成 ReadView 后才开启,该版本不可见。
- 如果被访问版本的
trx_id 在 up_limit_id 和 low_limit_id 之间,则需要判断 trx_id 是否在活跃事务列表 trx_ids 中:
- 如果在,说明创建 ReadView 时,生成该版本的事务仍然活跃(未提交),该版本不可见。
- 如果不在,说明创建 ReadView 时,生成该版本的事务已经提交,该版本可见。
如果某个版本对当前事务不可见,就顺着版本链的 roll_pointer 找到上一个版本,并重复上述判断步骤,直到找到可见的版本或遍历完版本链。
MVCC 工作流程
当一个事务执行快照读查询时,InnoDB 会遵循以下流程来定位它应该看到的数据:
- 获取事务自身的版本号(事务 ID)。
- 生成(或获取)ReadView。
- 查询数据,获取版本链中的最新记录。
- 将最新记录的
trx_id 与 ReadView 中的规则进行比较。
- 如果不符合可见性规则,则通过
roll_pointer 从 Undo Log 中获取历史快照(上一个版本),并再次比较,直至找到符合规则的数据版本。
- 返回最终符合可见性规则的数据。
简而言之,InnoDB 通过 Undo Log 保存历史快照,通过 ReadView 的规则判断当前事务应该看到哪个快照。
这里的关键区别在于 ReadView 的生成时机:
- 在 读已提交(READ COMMITTED) 隔离级别下,一个事务中的每一次
SELECT 查询都会重新生成一次 ReadView。
- 在 可重复读(REPEATABLE READ) 隔离级别下,一个事务只在第一次
SELECT 查询时生成 ReadView,后续所有的 SELECT 查询都复用这个 ReadView。
正是 ReadView 生成时机的不同,导致了这两个隔离级别下“不可重复读”现象的差异。
MVCC 如何工作:实例分析
假设 student 表中只有一条由事务 ID 为 8 的事务插入的记录。
SELECT * FROM student ;
/*
+----+--------+--------+
| id | name | class |
+----+--------+--------+
| 1 | 张三 | 一班 |
+----+--------+--------+
1 row in set (0.07 sec)
*/
场景一:READ COMMITTED 级别
在此级别下,每次读取数据前都会生成一个新的 ReadView。
假设事务10和事务20正在执行:
# 事务 10 (trx_id = 10)
BEGIN;
UPDATE student SET name="李四" WHERE id=1;
UPDATE student SET name="王五" WHERE id=1;
# 事务 20 (trx_id = 20)
BEGIN;
-- 更新一些别的表,以便分配事务id
...
此时,id=1 记录的版本链如下(为简洁,后续示例图可能省略 class 字段):

现在,一个处于 READ COMMITTED 级别的事务(只读事务,trx_id=0)开始执行第一次查询:
BEGIN;
-- SELECT1:事务10、20均未提交
SELECT * FROM student WHERE id = 1; -- 得到的列 name 的值为‘张三’
SELECT1 执行过程分析:
- 生成 ReadView:
trx_ids = [10, 20], up_limit_id=10, low_limit_id=21, creator_trx_id=0。
- 查看最新版本
name=‘王五’,其 trx_id=10,在 trx_ids 列表中,不可见。
- 跳至上一版本
name=‘李四’,其 trx_id=10,也在列表中,不可见。
- 跳至上一版本
name=‘张三’,其 trx_id=8,小于 up_limit_id(10),可见。
- 返回结果
name=‘张三’。
随后,事务10提交,事务20继续更新记录:
-- 事务10提交
COMMIT;
-- 事务20继续
UPDATE student SET name="钱七" WHERE id=1;
UPDATE student SET name="宋八" WHERE id=1;
此时版本链变为:

READ COMMITTED 级别的事务执行第二次查询:
-- SELECT2:事务10已提交,事务20未提交
SELECT * FROM student WHERE id = 1; -- 得到的列 name 的值为‘王五’
SELECT2 执行过程分析:
- 重新生成 ReadView: 此时事务10已提交,不在活跃列表中,新 ReadView 的
trx_ids = [20], up_limit_id=20, low_limit_id=21, creator_trx_id=0。
- 查看最新版本
name=‘宋八’,其 trx_id=20,在 trx_ids 列表中,不可见。
- 跳至
name=‘钱七’,其 trx_id=20,也在列表中,不可见。
- 跳至
name=‘王五’,其 trx_id=10,小于 up_limit_id(20),可见。
- 返回结果
name=‘王五’。
可以看到,在 READ COMMITTED 级别下,同一个事务内的两次查询得到了不同的结果(张三 -> 王五),这就是“不可重复读”。
场景二:REPEATABLE READ 级别
在此级别下,只在第一次查询时生成 ReadView,之后复用。
同样从事务10、20未提交的状态开始,版本链与之前相同:

一个处于 REPEATABLE READ 级别的事务执行第一次查询:
BEGIN;
-- SELECT1:事务10、20未提交
SELECT * FROM student WHERE id = 1; -- 得到的列 name 的值为‘张三’
其判断过程与 READ COMMITTED 下 SELECT1 完全一样,生成 ReadView: trx_ids = [10, 20], up_limit_id=10, ...,最终找到可见版本 name=‘张三’。
随后,事务10提交,事务20更新记录,版本链变为:

该 REPEATABLE READ 级别的事务执行第二次查询:
-- SELECT2:事务10已提交,事务20未提交
SELECT * FROM student WHERE id = 1; -- 得到的列 name 的值仍为‘张三’
SELECT2 执行过程分析:
- 复用 SELECT1 时生成的 ReadView:
trx_ids = [10, 20], up_limit_id=10, low_limit_id=21, creator_trx_id=0。(注意:虽然事务10已提交,但旧 ReadView 中的记录未变)
- 查看最新版本
name=‘宋八’,trx_id=20 在 trx_ids 中,不可见。
- 查看
name=‘钱七’,trx_id=20 在列表中,不可见。
- 查看
name=‘王五’,trx_id=10 同样在 trx_ids 列表中(因为 ReadView 是旧的),不可见。
- 查看
name=‘李四’,trx_id=10 也在列表中,不可见。
- 查看
name=‘张三’,trx_id=8,小于 up_limit_id(10),可见。
- 返回结果
name=‘张三’。
由于复用了同一个 ReadView,两次查询结果一致,实现了“可重复读”。
场景三:MVCC 如何解决幻读
幻读是指一个事务在前后两次查询同一个范围时,后一次查询看到了前一次查询没有看到的新插入的行。InnoDB 的 REPEATABLE READ 级别通过 MVCC 在很大程度上避免了幻读。
假设初始只有一条数据:id=1, name=‘张三’, trx_id=10。

事务A (trx_id=20) 和事务B (trx_id=30) 并发执行。
-
事务A第一次查询 id>=1 的记录。
- 生成 ReadView:
trx_ids=[20,30], up_limit_id=20。
- 查到
id=1 的记录,其 trx_id=10 < up_limit_id(20),可见。
- 结果:看到 id=1。
-
事务B插入两条新记录并提交。
INSERT INTO student(id,name) VALUES(2,'李四');
INSERT INTO student(id,name) VALUES(3,'王五');
COMMIT;

-
事务A第二次查询 id>=1 的记录。
- 复用第一次查询时生成的 ReadView:
trx_ids=[20,30], up_limit_id=20。
- 现在表中有三条记录(id=1,2,3)。
- 判断
id=1:trx_id=10 < up_limit_id(20),可见。
- 判断
id=2:trx_id=30,其值在 up_limit_id(20) 和 low_limit_id 之间,且 存在于 trx_ids 数组中,不可见。
- 判断
id=3:trx_id=30,同理不可见。

结论:在 REPEATABLE READ 级别下,由于事务A的整个生命周期中使用的是同一个“静态”的 ReadView,这个视图定格在了事务开始的那一刻。因此,在事务开始之后才提交的其他事务(如事务B)所插入的新记录,对于事务A是不可见的,从而有效防止了幻读。需要注意的是,MVCC 主要解决的是快照读(普通 SELECT)的幻读问题,对于当前读(SELECT ... FOR UPDATE)的幻读,InnoDB 通过 Next-Key Lock(间隙锁)来保证。
总结
MVCC 是 InnoDB 实现高并发事务的核心机制之一,它通过 Undo Log 构建数据版本链,并通过 ReadView 来控制事务间数据的可见性。READ COMMITTED 和 REPEATABLE READ 这两个隔离级别的关键区别就在于 ReadView 的生成时机:前者每次读都生成新视图,后者则复用首次生成的视图。
MVCC 带来了诸多优势:
- 读写无阻塞:读操作不阻塞写,写操作也不阻塞读,极大提升了并发处理能力。
- 降低死锁概率:读操作无需加锁,写操作也只锁定必要的行。
- 实现一致性快照读:可以为查询提供某个时间点的一致性数据视图。
理解 MVCC 和 ReadView 的工作原理,对于深入掌握 MySQL 事务机制、进行性能调优和排查疑难问题都至关重要。如果想了解更多关于数据库或分布式系统的底层原理,欢迎在云栈社区与其他开发者交流探讨。