图解MySQL InnoDB MVCC实现原理:Undo Log与Read View怎么协作

蓝鲸
蓝鲸
发布时间:2026-09-20 | 3人浏览过
原创

假设一行数据原来的余额是 100,一个事务正在查询它,另一个事务却把余额改成了 120。第一个事务为什么有时还能继续看到 100,而且普通查询通常不需要把这行数据锁住?核心就在 InnoDB 的 MVCC:Undo Log负责保存能够回溯的旧版本,Read View负责判断哪个版本对当前查询可见。

可以先记住一句最重要的话:Read View不保存数据版本,Undo Log也不决定数据是否可见。查询先拿到记录版本,由 Read View判断它能不能看;不能看,就顺着记录中的回滚指针进入 Undo Log寻找更旧版本,再继续判断,直到找到可见版本或确认不存在可见记录。

本文讨论的是 InnoDB 中与 MVCC关系最密切的一致性读(consistent read),重点放在常见的 READ COMMITTED 和 REPEATABLE READ 隔离级别。不同 MySQL 版本的源码字段命名可能略有变化,但版本链和可见性判断的基本思路是一致的。

一、先理解MVCC究竟解决了什么问题

MVCC全称是 Multi-Version Concurrency Control,即多版本并发控制。它解决的问题并不是“让数据库完全不用锁”,而是让很多普通读取可以通过数据版本完成,从而减少读操作和写操作之间不必要的阻塞。

例如现在有一条记录:

id = 1
name = 'Alice'
balance = 100

事务B执行:

UPDATE account
SET balance = 120
WHERE id = 1;

从业务角度看,这行记录从 100 变成了 120。但对于 InnoDB 来说,并不是简单地把旧值彻底丢掉。为了支持事务回滚和一致性读,它还必须保留足够的信息,让系统能够找到修改前的版本。

这就形成了理解MVCC最关键的三个部分:

  • 当前记录:聚簇索引中的最新记录版本。
  • Undo Log:记录修改前的信息,使旧版本可以被重建。
  • Read View:描述创建快照时事务系统的状态,用于判断某个版本是否应该被当前查询看到。

二、一行记录为什么能够形成“版本链”

InnoDB记录内部除了用户定义的列,还保存了一些用于事务处理的隐藏信息。理解MVCC时最重要的是下面两个概念:

  • DB_TRX_ID:最近一次插入或修改该记录版本的事务ID。
  • DB_ROLL_PTR:指向相应 Undo 记录的回滚指针,可用于寻找修改前的信息。

如果表本身没有合适的主键或非空唯一索引,InnoDB还可能生成内部行ID作为聚簇索引键。不过这个内部行ID不是MVCC可见性判断的核心,理解 Undo Log 和 Read View 时重点关注事务ID与回滚指针即可。

SVG 配图 1
图1:当前记录与Undo信息共同构成可回溯的版本链

Undo Log保存的是完整旧记录吗?

理解这里时容易产生一个误区:认为每修改一次,InnoDB都会在 Undo Log 中复制一整行旧数据。

实际实现会根据操作类型保存恢复旧状态所需要的信息。读取历史版本时,InnoDB根据当前版本和相应 Undo 信息逐步构造出较旧版本。因此,“版本链”更适合作为逻辑上的历史版本链来理解,而不是表中真的存放了一排完整副本。

对于 MVCC一致性读,尤其需要关注的是修改产生的历史版本信息。Insert Undo主要用于插入事务自身发生回滚;事务提交后,没有其他事务需要通过 Insert Undo 看到“插入前的那一行”,因此它与 Update Undo在长期历史版本保留上的作用并不相同。

三、Undo Log有了旧版本,为什么还需要Read View

版本链只能回答:

“这条记录以前是什么样?”

它不能单独回答:

“对于我这个事务来说,哪个版本才应该看见?”

比如当前版本由事务110产生,旧版本由事务90产生。事务110到底能不能看,取决于查询建立快照时事务110是什么状态。如果事务110当时还没有提交,那么这个版本通常不应该出现在当前一致性读的快照中。

Read View就是用来描述这一时刻的事务可见性边界。

可以把Read View理解成一张“事务快照名单”

为了便于理解,可以把一个 Read View抽象成下面几项信息:

  • creator_trx_id:创建这个 Read View的事务自身ID。
  • active_trx_ids:创建快照时仍然活跃的事务ID集合。
  • min_active_trx_id:这些活跃事务中较小的事务ID边界。
  • next_trx_id:创建快照时尚未分配给后续事务的事务ID边界,可理解为快照时事务系统的高水位线。

源码中实际字段名称与抽象文章里的命名不一定完全相同。学习MVCC时没必要先死记源码变量名,先理解它们分别代表什么更重要。

SVG 配图 2
图2:Read View记录的是事务状态,而不是某一行数据的副本

四、Read View的可见性规则到底怎么判断

假设从某个记录版本中读到了事务ID row_trx_id,可以用下面的思路理解其可见性。这里是便于学习的逻辑表达,不要求与源码条件分支逐行对应。

情况1:这个版本就是当前事务自己产生的

如果:

row_trx_id == creator_trx_id

那么自己的修改对自己可见。

例如事务A先执行:

UPDATE account
SET balance = 150
WHERE id = 1;

随后在同一个事务里查询这行数据,不可能因为旧快照而让事务连自己刚刚改过的数据都看不到。

情况2:版本来自很早以前已经存在的事务

如果这个版本的事务ID比创建 Read View时最小的活跃事务还小,说明产生这个版本的事务在快照建立之前就已经结束,因此通常可以看到。

用前面的示例:

min_active_trx_id = 110
row_trx_id = 90

事务90早于快照中的活跃事务边界,这个版本属于快照建立前已经存在的历史。

情况3:版本来自快照建立以后才出现的事务

如果:

row_trx_id >= next_trx_id

说明产生这个版本的事务在 Read View创建时还没有进入当前快照所代表的事务范围,因此不能看到。

情况4:事务ID落在两个边界之间

这是最容易混淆的情况。

假设:

min_active_trx_id = 110
next_trx_id       = 120
active_trx_ids    = [110, 115]

现在有版本由事务112产生。

112虽然介于110与120之间,但它不在活跃列表中,这说明在创建 Read View时,事务112已经结束,因此它产生的已提交版本可以进入这个快照。

如果版本来自事务115:

115 in [110, 115]

说明快照创建时事务115仍然活跃,因此它的这个版本对当前一致性读不可见。

所以,不能简单地根据事务ID“大于还是小于当前事务ID”决定可见性。事务并发执行时,开始顺序、事务ID分配和提交顺序不是同一个概念,Read View中的活跃事务集合正是为了解决这种情况。

五、Undo Log与Read View真正协作时发生了什么

现在把两个组件放到同一条查询路径里。

假设当前聚簇索引记录是:

balance = 120
DB_TRX_ID = 110
DB_ROLL_PTR = → 前一个版本

而当前事务的 Read View中,事务110属于创建快照时尚未结束的事务。

那么查询过程并不是直接返回120,而是:

  1. 读取当前记录。
  2. 取出当前版本对应的事务ID 110。
  3. 使用 Read View检查事务110产生的版本是否可见。
  4. 发现110位于活跃事务集合中,因此当前版本不可见。
  5. 根据 DB_ROLL_PTR和 Undo 信息构造前一个版本。
  6. 假设前一个版本由事务90产生,继续使用同一个 Read View判断。
  7. 事务90产生的版本符合可见性规则,于是返回旧值100。
SVG 配图 3
图3:Read View负责“判断”,Undo Log负责“回到过去”

因此可以把两者的职责概括成:

组件 主要职责 不能单独完成什么
Undo Log 保存恢复旧状态所需的信息,支持回滚和历史版本构造 不知道当前查询应该看哪个版本
Read View 描述快照建立时的事务状态,判断记录版本是否可见 本身不保存某行数据的旧值
记录中的事务信息 告诉系统当前版本由哪个事务产生,并提供历史回溯入口 仅凭事务ID不足以完成完整快照读取

六、用两个事务完整走一遍MVCC

下面用一个更具体的场景说明为什么同一个数据库,在 REPEATABLE READ 和 READ COMMITTED 下会得到不同结果。

先准备示例表:

CREATE TABLE account (
    id BIGINT PRIMARY KEY,
    balance INT NOT NULL
) ENGINE=InnoDB;

INSERT INTO account (id, balance)
VALUES (1, 100);

假设初始记录由更早的事务90产生。下面的事务编号只是为了说明机制,并不是要求用户能够在客户端直接控制 InnoDB内部事务ID。

REPEATABLE READ:同一事务通常继续使用第一次一致性读建立的快照

事务A:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

START TRANSACTION;

SELECT balance
FROM account
WHERE id = 1;

示例结果:

balance
-------
100

这次一致性读建立了事务A之后读取所使用的快照。假设此时事务B处于活跃状态,并且事务B的内部事务ID为110。

事务B随后修改:

START TRANSACTION;

UPDATE account
SET balance = 120
WHERE id = 1;

COMMIT;

现在物理上的当前记录已经可以是:

balance = 120
DB_TRX_ID = 110

事务A再次执行:

SELECT balance
FROM account
WHERE id = 1;

在同一个 REPEATABLE READ事务的一致性读语义下,仍然会依据之前建立的 Read View判断。

如果事务110在那个快照创建时仍然处于活跃状态,那么事务110产生的新版本对这张快照不可见。InnoDB因此通过 Undo 信息找到事务90对应的旧版本,事务A仍然得到:

balance
-------
100

这里尤其值得注意:

不是事务A一直读取某个提前复制好的“100”,而是第二次查询仍使用原来的事务快照规则,然后在需要时重新寻找符合该快照的历史版本。

七、READ COMMITTED为什么可能第二次就看到120

READ COMMITTED与 REPEATABLE READ一个非常关键的区别,是一致性读创建和使用 Read View的时机。

隔离级别 一致性读的Read View 典型效果
READ COMMITTED 每次一致性读通常使用新的Read View 同一事务中的两次SELECT可能看到其他事务在期间提交的新数据
REPEATABLE READ 同一事务中的普通一致性读通常复用第一次一致性读建立的快照 后续普通快照读保持相同的历史视角

如果事务A改用 READ COMMITTED:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

START TRANSACTION;

SELECT balance
FROM account
WHERE id = 1;

第一次可能看到100。

此后事务B把余额更新成120并提交。

事务A第二次执行同样的普通查询时,会建立新的 Read View。此时事务B已经提交,不再属于新快照中的活跃事务,所以版本120可以变得可见:

SELECT balance
FROM account
WHERE id = 1;

示例结果:

balance
-------
120

因此,“READ COMMITTED只能读取已经提交的数据”虽然方向没错,但如果想真正理解其实现,还应该补充一句:

每次一致性读重新获取事务视角,是它能够看到期间新提交版本的重要原因。

八、普通SELECT和SELECT FOR UPDATE不能混为一谈

学习MVCC时另一个常见问题,是看到 REPEATABLE READ能够读历史版本后,以为事务里的任何SQL都会一直读老版本。

并不是这样。

普通查询:

SELECT balance
FROM account
WHERE id = 1;

通常属于一致性读,会使用MVCC快照。

而下面这种查询:

SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;

属于锁定读。它的目的通常是读取当前可用于后续修改的数据,并对相应记录或范围加锁,因此不能简单套用“普通快照读一直沿用旧 Read View”的理解方式。

同样,下面这些操作本身也需要处理当前可修改的数据并配合锁机制:

UPDATE account
SET balance = balance + 10
WHERE id = 1;

DELETE FROM account
WHERE id = 1;
如果业务逻辑要求“读取当前值后马上基于这个值修改”,不要因为知道了MVCC就默认使用普通SELECT。是否需要 FOR UPDATE、条件更新、乐观锁或其他并发控制方式,需要根据业务一致性要求决定。

九、删除操作在MVCC里为什么也能保留历史可见性

DELETE同样不能简单理解成“执行后立刻从所有历史视角中物理消失”。

例如事务B删除:

DELETE FROM account
WHERE id = 1;

COMMIT;

如果仍然存在一个较早建立的 Read View,而按照这个快照该记录在当时应该存在,那么 InnoDB仍然需要保留足够的历史信息,让旧快照能够得到符合自身视角的结果。

等到系统确认没有任何仍然需要这些旧版本的事务快照后,相关历史版本才可以逐步由 purge机制清理。

这也解释了为什么:

事务提交,并不意味着所有 Undo历史信息都能立即删除。

是否能清理,要考虑是否还有正在运行的一致性读需要这些历史版本。

十、为什么长事务会让Undo历史版本积累

假设一个 REPEATABLE READ事务建立了很早的 Read View,然后几十分钟甚至更长时间都没有结束。

与此同时,其他事务不断执行:

UPDATE ...
DELETE ...
UPDATE ...
DELETE ...

从这些写事务自己的角度看,它们可能早就已经提交。但旧事务仍然有可能根据自己较早的 Read View访问历史数据。

于是一些历史版本暂时不能被清理,否则旧事务需要沿版本链回溯时就找不到应该看到的数据了。

因此在实际数据库维护中,长期运行的事务值得重点关注。它可能带来的影响包括:

  • 历史版本保留时间增加;
  • Undo相关空间和历史链增长;
  • purge清理进度受到限制;
  • 某些需要寻找较旧版本的查询回溯成本增加。

所以问题并不是“Undo Log越大就一定是MVCC故障”,而应该继续检查是否存在长期不提交的事务、长时间运行的一致性读,或者持续产生大量更新的工作负载。

十一、把Read View判断规则再压缩成一张图

SVG 配图 4
图4:MVCC的核心闭环——判断版本、回溯版本、返回版本、最终清理历史版本

十二、几个特别容易理解错的地方

1. MVCC不是完全不加锁

MVCC主要帮助普通一致性读减少与写操作之间的直接冲突,但数据库中的更新、删除、锁定读、唯一约束检查等操作仍然会使用锁。

因此下面这种说法不准确:

InnoDB用了MVCC,所以读写都不用锁。

更准确的理解是:

很多普通快照读可以通过历史版本完成,而不用等待正在修改记录的事务结束。

2. Read View不是数据快照文件

Read View并没有把查询涉及的数据复制一份。

它更像是一套“可见性规则”和创建快照时的事务状态信息。真正需要旧数据时,系统才结合 Undo信息寻找适合当前视角的历史版本。

3. Undo Log不只是为了ROLLBACK

看到“Undo”这个名字,很容易只想到:

ROLLBACK;

实际上,Undo信息除了支持事务回滚,也是 InnoDB MVCC构造历史版本的重要基础。

如果没有这些历史信息,Read View就算知道“当前版本不能看”,也没有办法获得应该返回的旧版本。

4. REPEATABLE READ也不意味着任何情况下都只能看到事务开始时的数据

需要区分:

  • 普通一致性读;
  • 事务自己的修改;
  • 锁定读;
  • UPDATE、DELETE等当前操作。

事务自己的修改对自己可见,而锁定读和写操作不能简单按照普通一致性读的快照规则理解。

5. Read View通常不是在START TRANSACTION时简单复制整个数据库状态

以常见的 REPEATABLE READ一致性读为例,理解时应关注一致性快照实际建立的时机,而不是把 START TRANSACTION机械理解成“立即复制一份数据库”。

实际需要严格控制快照建立时机时,也应结合具体事务语句和 MySQL行为进行设计,而不是仅凭“事务开始时间”推断所有可见性。

十三、从一条记录重新串起整个MVCC过程

现在回到最开始的例子。

原始版本:

balance = 100
trx_id = 90

事务110将其修改为:

balance = 120
trx_id = 110

与此同时,某个较早建立的 Read View认为事务110对应的版本不可见。

那么整个读取过程就是:

  1. InnoDB从索引中取得最新记录版本120。
  2. 读取这个版本对应的事务ID 110。
  3. Read View检查110在当前快照中的状态。
  4. 发现110的版本对该快照不可见。
  5. 根据回滚指针和 Undo信息寻找旧版本。
  6. 构造出事务90对应的余额100版本。
  7. Read View再次判断事务90对应的版本。
  8. 事务90的版本可见,于是查询返回100。

如果使用 READ COMMITTED,并且下一条查询重新创建 Read View,而事务110此时已经提交,那么新的 Read View可能允许看到事务110产生的版本,于是直接返回120,不必继续回溯到100。

这正是 Undo Log与 Read View协作的完整逻辑。

理解InnoDB MVCC,可以最终压缩成四句话:
  1. 记录中的事务ID告诉InnoDB“这个版本是谁修改的”。
  2. Read View告诉InnoDB“这个版本对当前一致性读能不能看”。
  3. 如果不能看,就利用回滚指针和Undo信息寻找更旧版本,再重新判断。
  4. 当没有任何仍然有效的快照需要旧版本后,Purge才可以逐步清理这些历史信息。

所以,MVCC并不是某一个单独组件实现的功能。记录中的事务信息、Undo历史版本、Read View可见性判断以及Purge清理机制共同组成了InnoDB多版本并发控制的完整链路。其中最需要掌握的关系就是:Undo Log解决“历史版本从哪里来”,Read View解决“当前事务应该看哪个版本”。把这两个职责区分清楚,再理解 READ COMMITTED与 REPEATABLE READ的 Read View创建方式,InnoDB MVCC的大部分核心行为就能串起来了。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表