一、从一个面试题说起:为什么 InnoDB 能同时做到高并发与一致性?
在数据库面试和日常开发讨论中,几乎每个人都会遇到类似的问题:MySQL InnoDB 引擎在可重复读隔离级别下,为什么一个普通 SELECT 语句不会阻塞其他事务的写入?为什么事务 A 在开启后读取到的数据永远是同一个快照,即使事务 B 已经提交了新的数据?这些问题的答案,都指向 InnoDB 中最核心的机制之一:MVCC,即多版本并发控制。
MVCC 的全称是 Multi-Version Concurrency Control。它的核心思想非常朴素:读操作不阻塞写操作,写操作也不阻塞读操作。为了做到这一点,InnoDB 并不是简单地在读取时加共享锁,而是为每一行数据维护多个版本。当某个事务想要读取数据时,它读取的不是某一个全局最新的版本,而是「在事务开始的那个时刻,对自己可见的版本」。这样一来,多个事务就可以并发地读取不同的历史版本,而写入事务则可以继续生成新的版本,二者互不干扰。
理解 MVCC 对于深入掌握 MySQL 至关重要。它不仅关系到日常的查询性能,还直接影响事务隔离级别、锁行为、索引设计、慢查询分析和线上事故排查。本文将从基础概念开始,逐步深入到 undo log、隐藏列、ReadView、快照读与当前读、RC 与 RR 的差异,再通过一组可以在本地复现的实验,帮助你真正「看见」MVCC 的行为,最后结合生产实践给出常见问题与优化建议。全文约两万字,建议配合本地 MySQL 8.0 环境边读边做。
二、为什么需要 MVCC:锁的局限与并发读写的矛盾
2.1 如果没有 MVCC,数据库会怎样?
在最早期的数据库实现中,为了保证数据一致性,读写操作通常依赖锁机制。一个事务在读取某一行数据时,需要先加上共享锁;一个事务在修改某一行数据时,则需要加上排他锁。共享锁和共享锁之间兼容,共享锁和排他锁之间互斥。这种机制虽然简单,但在并发场景下会带来明显的性能问题。
想象一个典型场景:一个报表事务需要扫描整张订单表执行聚合统计,整个查询可能持续几秒钟。在这几秒钟内,如果报表事务给所有扫描到的行都加上了共享锁,那么所有试图更新订单状态的事务都会被阻塞,反过来,如果大量更新事务频繁加排他锁,报表查询也可能长时间等待。更糟糕的是,锁的粒度越粗,冲突越严重;即便细化到行锁,长时间的读锁依然会让写入吞吐量大幅下降。
MVCC 的引入正是为了解决这个矛盾。它把「读」和「写」从锁的竞争关系中解放出来。读事务不再需要等待写事务释放排他锁,因为它读取的是旧版本;写事务也不关心读事务是否还在扫描,因为它只需要生成新版本。这样,读写之间的冲突被大幅降低,系统的并发能力得到显著提升。
2.2 隔离级别与 MVCC 的关系
SQL 标准定义了四种事务隔离级别,从低到高依次是:读未提交、读已提交、可重复读、串行化。MySQL InnoDB 默认的隔离级别是可重复读。不同隔离级别对「脏读」「不可重复读」「幻读」的处理要求不同,而 MVCC 是实现这些隔离级别的重要技术基础。
在 InnoDB 中,读未提交级别几乎不使用 MVCC 的快照机制来保证一致性,因为它允许读取其他事务尚未提交的修改;读已提交和可重复读则通过不同的 ReadView 生成时机来区分;串行化级别则更进一步,通过共享锁的加锁方式把并发读也变成串行执行。因此,理解 MVCC 的前提是先理解事务隔离级别,理解隔离级别又反过来帮助我们理解为什么同一个 MVCC 机制在不同隔离级别下会呈现不同行为。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 实现方式 |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | 基本无隔离保证 |
| 读已提交 | 不可能 | 可能 | 可能 | 每次快照读生成新 ReadView |
| 可重复读 | 不可能 | 不可能 | 基本不可能 | 事务内沿用同一 ReadView + 间隙锁 |
| 串行化 | 不可能 | 不可能 | 不可能 | 读操作加共享锁,退化为加锁读 |
需要特别说明的是,在 InnoDB 的可重复读级别下,幻读在大多数场景中已经被抑制,但并未被完全消除,本文后续会通过实验演示这一点。
三、InnoDB 存储结构基础:行记录与隐藏列
3.1 聚簇索引与二级索引
理解 MVCC 之前,必须先理解 InnoDB 的数据存储方式。InnoDB 表的数据是按照主键顺序组织在聚簇索引中的。所谓聚簇索引,指的是叶子节点直接存储完整行数据,而不是存储指向行数据的指针。每张 InnoDB 表有且仅有一个聚簇索引。
如果建表时定义了主键,InnoDB 就用该主键作为聚簇索引;如果没有定义主键,InnoDB 会查找第一个非空唯一索引作为聚簇索引;如果两者都没有,InnoDB 会生成一个 6 字节的隐藏行 ID 作为聚簇索引。除了聚簇索引,用户还可以创建其他索引,这些索引称为二级索引。二级索引的叶子节点存储的是索引键值和对应的主键值,查询时通常先通过二级索引找到主键,再回到聚簇索引获取完整行数据,这个过程称为回表。
3.2 每一行数据背后的隐藏列
InnoDB 在存储每一行数据时,除了用户定义的列之外,还会自动添加若干隐藏列。对于 MVCC 而言,最重要的两个隐藏列是:
- DB_TRX_ID:6 字节,记录最后一次插入或更新该行数据的事务 ID。
- DB_ROLL_PTR:7 字节,回滚指针,指向该行数据上一个版本所在的 undo log 记录地址。
如果表没有主键,还会额外存在一个 6 字节的 DB_ROW_ID 隐藏列。此外,如果行数据中存在删除标记,InnoDB 还会使用一个删除标记位来表示该行是否已删除,但删除标记位通常不被视为独立的隐藏列。
这些隐藏列对于普通用户是不可见的,即使在 SELECT 语句中使用 * 也不会把它们查出来。但它们在 MVCC 的运行过程中扮演着关键角色。可以这样理解:聚簇索引中的每一条记录,都是一条带有事务 ID 和版本链指针的「当前最新版本」,而更早的版本则通过 DB_ROLL_PTR 串联在 undo log 中。
CREATE TABLE mvcc_demo ( id INT PRIMARY KEY, name VARCHAR(32) NOT NULL, balance DECIMAL(10, 2) NOT NULL ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;在上面的表中,实际存储到磁盘上的聚簇索引记录结构大致可以表示为:
[id=1][name='Alice'][balance=100.00][DB_TRX_ID=100][DB_ROLL_PTR=0x... ][删除标记=0]当这行数据被更新时,InnoDB 不会直接在原地覆盖所有字段并丢弃旧值,而是会复制旧值到 undo log,生成新的行版本,并把新版本的 DB_ROLL_PTR 指向上一个版本。这样,一条记录的多个版本就通过回滚指针形成了一条版本链。
四、undo log:版本链的物理载体
4.1 undo log 是什么
undo log 是 InnoDB 中用于支持事务回滚和 MVCC 的一种日志。它记录的是逻辑层面的「逆操作」,而不是物理层面的数据页修改。例如,插入一条记录时,undo log 会记录「需要删除这条记录」的信息;更新一条记录时,undo log 会记录「如何恢复旧值」的信息。
undo log 主要有两种类型:
- 插入 undo log:对应 INSERT 操作,事务提交后即可删除,因为它只需要支持当前事务的回滚,不会被其他事务用来构建 MVCC 快照。
- 更新 undo log:对应 UPDATE 和 DELETE 操作,不仅需要支持当前事务回滚,还需要支持其他事务的 MVCC 快照读取,因此在事务提交后不能立即删除,需要由 purge 线程在合适的时机统一清理。
更新 undo log 之所以不能立即删除,是因为可能存在其他事务的 ReadView 仍然需要读取这些历史版本。如果某个旧版本的 undo log 被过早删除,那么正在进行的快照读可能就无法恢复出自己需要的数据版本,从而破坏一致性。
4.2 版本链如何形成
以一个具体例子说明版本链的形成过程。假设表 mvcc_demo 中已经存在一行 id=1 的记录,初始值由事务 trx_id=100 插入:
当前行:id=1, name='Alice', balance=100.00, DB_TRX_ID=100, DB_ROLL_PTR=NULL事务 trx_id=101 执行 UPDATE,把 balance 改成 200.00。此时 InnoDB 会执行以下逻辑:
当前行:id=1, name='Alice', balance=200.00, DB_TRX_ID=101, DB_ROLL_PTR=指向undo记录A undo记录A:id=1, name='Alice', balance=100.00, DB_TRX_ID=100, DB_ROLL_PTR=NULL接着事务 trx_id=102 执行 UPDATE,把 name 改成 Bob。版本链继续延长:
当前行:id=1, name='Bob', balance=200.00, DB_TRX_ID=102, DB_ROLL_PTR=指向undo记录B undo记录B:id=1, name='Alice', balance=200.00, DB_TRX_ID=101, DB_ROLL_PTR=指向undo记录A undo记录A:id=1, name='Alice', balance=100.00, DB_TRX_ID=100, DB_ROLL_PTR=NULL至此,id=1 的这条记录在逻辑上拥有三个版本,分别对应三个不同的事务 ID。当某个事务开始执行快照读时,它会沿着这条版本链从当前最新版本开始,向前回溯,直到找到一个对自己可见的版本为止。这里的「可见性判断」,就依赖 ReadView。
4.3 undo log 的物理存储位置
在 MySQL 5.6 及之前的版本中,undo log 存储在共享表空间中。从 MySQL 5.7 开始,undo log 默认存储在独立的 undo 表空间中。MySQL 8.0 继续沿用独立 undo 表空间的方案,并支持设置 undo 表空间的数量。可以通过以下命令查看 undo 相关的参数:
SHOW VARIABLES LIKE 'innodb_undo%';+--------------------------+------------+ | Variable_name | Value | +--------------------------+------------+ | innodb_undo_directory | ./ | | innodb_undo_log_encrypt | OFF | | innodb_undo_log_truncate | ON | | innodb_undo_tablespaces | 2 | +--------------------------+------------+undo log 的膨胀通常与长事务、大量更新以及 purge 不及时有关。理解 undo log 的生命周期,有助于后续分析磁盘占用和长事务问题。
五、ReadView:MVCC 可见性判断的核心
5.1 ReadView 的组成
ReadView 是 InnoDB 在执行快照读时生成的一份视图信息,它决定了当前事务能够看到哪些版本的数据。一个 ReadView 主要包含以下关键信息:
- m_ids:生成 ReadView 时,系统中所有活跃事务的 ID 集合,即那些已经开启但尚未提交的事务。
- min_trx_id:活跃事务 ID 集合中的最小值。
- max_trx_id:系统即将分配给下一个事务的 ID 值,表示已经分配过的最大事务 ID 加 1。
- creator_trx_id:创建这个 ReadView 的事务自己的 ID。
需要注意的是,ReadView 并不存储具体的数据行,它只是一组用于可见性判断的元数据。真正被判断的对象,是版本链上每一行版本的 DB_TRX_ID。
5.2 可见性判断规则
假设某个行版本的 DB_TRX_ID 为 trx_id,那么可见性判断的完整过程如下:
- 如果 trx_id 等于 creator_trx_id,说明这个版本是当前事务自己修改的,当然可见。
- 如果 trx_id 小于 min_trx_id,说明修改这个版本的事务在 ReadView 创建之前就已经提交,因此可见。
- 如果 trx_id 大于等于 max_trx_id,说明修改这个版本的事务是在 ReadView 创建之后才开始的,因此不可见。
- 如果 trx_id 介于 min_trx_id 和 max_trx_id 之间,则进一步判断 trx_id 是否在 m_ids 中:如果不在,说明该事务在 ReadView 创建时已经提交,因此可见;如果在,说明该事务当时尚未提交,因此不可见。
当一个版本不可见时,InnoDB 会沿着 DB_ROLL_PTR 指向的 undo log 记录继续向前查找,重复上述判断,直到找到第一个可见版本。如果整条版本链都不可见,那么该行数据对于当前事务就相当于不存在。
5.3 通过伪代码理解可见性
为了更直观地理解上述规则,可以用一段伪代码来表示:
def is_visible(trx_id_of_row, read_view): if trx_id_of_row == read_view.creator_trx_id: return True # 自己改的,可见 if trx_id_of_row < read_view.min_trx_id: return True # 在 ReadView 创建前已提交,可见 if trx_id_of_row >= read_view.max_trx_id: return False # 在 ReadView 创建后才开始,不可见 if trx_id_of_row in read_view.m_ids: return False # 创建 ReadView 时尚未提交,不可见 return True # 不在活跃列表中,说明已提交,可见这段伪代码概括了判断的核心逻辑。需要注意的是,实际实现中还有针对删除标记的处理:如果一个版本被删除且其删除事务对当前 ReadView 可见,那么该行对当前事务就不可见。
六、快照读与当前读:MVCC 的两个入口
6.1 快照读
快照读,也叫一致性非锁定读,是 MVCC 最直接的体现。它读取的是基于 ReadView 判断出来的历史版本,在读取过程中不加锁,因此不会阻塞其他事务的写入。快照读的典型语句是普通的 SELECT。
SELECT * FROM mvcc_demo WHERE id = 1;这条语句执行时,如果当前隔离级别是可重复读,且事务中已经有 ReadView,那么它会直接使用已有的 ReadView 进行可见性判断;如果这是事务中的第一次快照读,则会生成一个新的 ReadView。在可重复读级别下,这个 ReadView 会在整个事务期间保持不变,从而保证多次读取结果一致。
6.2 当前读
当前读读取的是数据的最新版本,并且在读取时会加锁。当前读绕过 MVCC 的版本链,直接读取聚簇索引中的当前记录。当前读的典型语句包括:
- SELECT ... FOR UPDATE
- SELECT ... LOCK IN SHARE MODE,在 MySQL 8.0 中写作 SELECT ... FOR SHARE
- UPDATE 操作本身在定位数据时执行的读操作
- DELETE 操作本身在定位数据时执行的读操作
SELECT * FROM mvcc_demo WHERE id = 1 FOR UPDATE;当前读的存在是必要的。设想一个转账场景:事务 A 读取余额为 100 元,准备扣减 50 元。如果事务 A 只做快照读,那么它看到的可能是一个旧版本的余额。当它基于旧余额执行更新时,必须读到最新余额并加锁,否则就可能出现丢失更新。因此,UPDATE 时的读取一定是当前读,它需要锁定最新记录来保证更新操作的原子性和正确性。
6.3 快照读与当前读的混用陷阱
在一个事务中,如果先执行快照读,再执行当前读,可能会看到不同的数据。这是因为快照读使用 ReadView 判断历史版本,而当前读读取最新版本。这种差异在同事务内是正常现象,但在业务开发中容易造成理解偏差。
START TRANSACTION; -- 快照读,读取到余额 100 元 SELECT balance FROM mvcc_demo WHERE id = 1; -- 当前读,可能读取到最新余额 200 元 SELECT balance FROM mvcc_demo WHERE id = 1 FOR UPDATE; COMMIT;上述场景中,如果其他事务在两次读取之间提交了对余额的更新,那么快照读和当前读的结果就会不同。开发人员需要清楚地知道自己的 SQL 到底属于哪种读,以及并发场景下如何保证业务正确性。
七、事务 ID 的分配与 ReadView 的生成时机
7.1 事务 ID 何时分配
在 InnoDB 中,事务 ID 并不是在 START TRANSACTION 或 BEGIN 语句执行时就立即分配的。一个事务只有在第一次执行写操作时才会真正分配事务 ID。这里的写操作包括 INSERT、UPDATE 和 DELETE,但不包括普通的 SELECT。
这一点的意义在于,纯读事务不会参与写事务的版本链竞争,也不会占用写事务 ID。它只在第一次快照读时生成 ReadView,而 ReadView 中的 creator_trx_id 对于纯读事务而言,使用的是当前事务分配的临时 ID,该临时 ID 不会出现在任何行版本的 DB_TRX_ID 中。
7.2 ReadView 的生成时机:RC 与 RR 的关键区别
读已提交和可重复读的核心区别,就在于 ReadView 的生成时机不同。
- 读已提交:每次执行快照读时,都会生成一个新的 ReadView。这意味着事务内多次执行同一个 SELECT,可能读到不同的结果,也就是可能出现不可重复读。
- 可重复读:只有第一次执行快照读时生成 ReadView,后续所有快照读都沿用这个 ReadView。因此,事务内多次执行同一个 SELECT,读到的快照是一致的,避免了不可重复读。
用一个时间线示例可以更清晰地说明。假设有三个事务:事务 A 和事务 B 同时活跃,事务 C 稍后开始。在可重复读级别下,事务 A 的 ReadView 在第一次 SELECT 时生成,它记录下了当时的活跃事务集合。后续无论事务 B、C 是否提交,事务 A 的快照读都不会看到它们提交的新数据。而在读已提交级别下,事务 A 的每次 SELECT 都重新生成 ReadView,因此如果事务 B 已经提交,事务 A 的下一次 SELECT 就能读到事务 B 提交的数据。
八、MVCC 与二级索引
8.1 二级索引中的版本问题
前文提到,版本链主要存储在聚簇索引和与之关联的 undo log 中。那么二级索引是如何参与 MVCC 的呢?事实上,二级索引的叶子节点并不存储完整的版本链,它存储的是索引键值、主键值,以及行记录的一部分信息。
当通过二级索引查找数据时,InnoDB 先在二级索引中找到匹配的索引记录,再根据主键值回表到聚簇索引中获取完整行记录,并在聚簇索引的版本链上进行可见性判断。如果二级索引记录本身已经带有某些事务版本信息,InnoDB 也可能通过索引条件下推等方式进行部分过滤,但最终的一致性判断仍然在聚簇索引上完成。
8.2 索引条件下推与 MVCC 的配合
索引条件下推是 MySQL 5.6 引入的一项优化。当使用二级索引查询时,MySQL 会尽量把 WHERE 条件中的部分过滤逻辑下推到存储引擎层,在索引扫描阶段就过滤掉不满足条件的记录,从而减少回表次数。在 MVCC 场景下,索引条件下推可以减少不必要的聚簇索引访问,提高查询效率。但需要注意的是,MVCC 的版本可见性判断在二级索引上并不完整,因此真正的一致性保证仍然发生在聚簇索引层。
九、实验一:可重复读下的快照读一致性
9.1 实验准备
为了直观观察 MVCC 行为,建议在本地搭建 MySQL 8.0 环境。首先确认隔离级别:
SELECT @@transaction_isolation;+-------------------------+ | @@transaction_isolation | +-------------------------+ | REPEATABLE-READ | +-------------------------+创建实验表并插入一条数据:
CREATE TABLE mvcc_demo ( id INT PRIMARY KEY, name VARCHAR(32) NOT NULL, balance DECIMAL(10, 2) NOT NULL ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; INSERT INTO mvcc_demo VALUES (1, 'Alice', 100.00);9.2 实验步骤
打开两个会话,分别称为会话 A 和会话 B。在会话 A 中执行:
START TRANSACTION; SELECT * FROM mvcc_demo WHERE id = 1; -- 结果:id=1, name='Alice', balance=100.00保持会话 A 的事务不提交。在会话 B 中执行更新并提交:
UPDATE mvcc_demo SET balance = 200.00 WHERE id = 1; COMMIT;回到会话 A,再次执行同样的查询:
SELECT * FROM mvcc_demo WHERE id = 1; -- 结果仍然是:id=1, name='Alice', balance=100.00可以看到,虽然会话 B 已经提交了修改,但会话 A 在可重复读级别下仍然读取到旧值 100.00。这正是 MVCC 快照读的一致性的体现。
9.3 原理分析
会话 A 在第一次 SELECT 时生成了 ReadView。此时会话 B 尚未提交,其事务 ID 被记录在活跃事务集合 m_ids 中。当会话 A 第二次 SELECT 时,虽然会话 B 已经提交,但会话 A 仍然沿用旧的 ReadView。版本链上最新版本的 DB_TRX_ID 是会话 B 的事务 ID,而该 ID 在旧 ReadView 的 m_ids 中,因此不可见。于是 InnoDB 沿着 DB_ROLL_PTR 回溯到旧版本,旧版本的 DB_TRX_ID 在 ReadView 生成前已提交,因此可见,最终返回 100.00。
十、实验二:读已提交下的不可重复读
10.1 切换隔离级别
在会话 A 和会话 B 中分别将会话级隔离级别设置为 READ-COMMITTED:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;确认隔离级别已切换:
SELECT @@transaction_isolation;+-------------------------+ | @@transaction_isolation | +-------------------------+ | READ-COMMITTED | +-------------------------+10.2 实验步骤
为了确保实验的初始状态一致,先将 balance 恢复为 100.00:
UPDATE mvcc_demo SET balance = 100.00 WHERE id = 1; COMMIT;在会话 A 中开启事务并执行第一次查询:
START TRANSACTION; SELECT * FROM mvcc_demo WHERE id = 1; -- 结果:balance=100.00在会话 B 中更新并提交:
UPDATE mvcc_demo SET balance = 300.00 WHERE id = 1; COMMIT;回到会话 A,再次执行查询:
SELECT * FROM mvcc_demo WHERE id = 1; -- 结果:balance=300.00这次会话 A 读到了会话 B 提交后的新值 300.00,这就是读已提交级别下的不可重复读现象。
10.3 原理分析
在读已提交级别下,每次快照读都会重新生成 ReadView。会话 A 的第二次 SELECT 生成的 ReadView 中,会话 B 已经提交,其事务 ID 不在活跃集合 m_ids 中,因此最新版本的 balance=300.00 对会话 A 可见。这正是读已提交与可重复读在 ReadView 生成时机上的差异所导致的。
十一、实验三:当前读与快照读的对比
11.1 当前读读取最新版本
将会话隔离级别恢复为可重复读:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;确保初始 balance 为 100.00。在会话 A 中执行:
START TRANSACTION; SELECT balance FROM mvcc_demo WHERE id = 1; -- 结果:100.00在会话 B 中更新并提交:
UPDATE mvcc_demo SET balance = 400.00 WHERE id = 1; COMMIT;回到会话 A,分别执行快照读和当前读:
SELECT balance FROM mvcc_demo WHERE id = 1; -- 结果:100.00,快照读使用旧 ReadView SELECT balance FROM mvcc_demo WHERE id = 1 FOR UPDATE; -- 结果:400.00,当前读读取最新版本这个实验清楚地展示了快照读与当前读的差异。对于同一个事务,在同一时刻,两种读取方式得到的结果不同。
11.2 当前读的锁行为
SELECT ... FOR UPDATE 会对读取到的行加排他锁。如果此时其他事务持有该行的排他锁,当前读会阻塞等待。可以再开一个会话 C 验证:先在会话 A 中执行 FOR UPDATE,然后在会话 C 中执行同一行的 FOR UPDATE,观察会话 C 是否挂起,直到会话 A 提交或回滚后才继续执行。
十二、实验四:自己修改的数据永远可见
12.1 实验步骤
在可重复读级别下,事务内自己修改的数据会立即对自己可见,即使 ReadView 已经生成。这是因为可见性判断规则的第一条就是:如果行版本的 DB_TRX_ID 等于当前事务 ID,则直接可见。
在会话 A 中执行:
START TRANSACTION; SELECT balance FROM mvcc_demo WHERE id = 1; -- 假设结果:100.00,此时生成 ReadView UPDATE mvcc_demo SET balance = 500.00 WHERE id = 1; SELECT balance FROM mvcc_demo WHERE id = 1; -- 结果:500.00,自己修改的数据可见虽然会话 A 的 ReadView 是在第一次 SELECT 时生成的,但 UPDATE 后再次查询仍然能读到 500.00。这是因为新版本由会话 A 自己生成,DB_TRX_ID 等于 creator_trx_id,判断规则直接返回可见。
12.2 与其他事务的对比
如果在会话 A 更新但未提交时,会话 B 进行快照读,会话 B 能否看到 500.00?答案是:在读已提交和可重复读级别下都不能。因为会话 B 的 ReadView 会将会话 A 的事务 ID 标记为活跃,所以会话 A 未提交的新版本对会话 B 不可见。只有当会话 A 提交后,读已提交级别的会话 B 在下一次快照读时才能看到,而可重复读级别的会话 B 在整个事务期间都看不到。
十三、删除操作在 MVCC 中的表现
13.1 删除并不等于物理删除
在 MVCC 机制下,DELETE 操作并不会立即从物理页中删除行记录。InnoDB 会在聚簇索引的记录上设置删除标记位,表示该行已被删除。同时,旧版本通过 DB_ROLL_PTR 链接到 undo log。只有当 purge 线程确认没有任何活跃 ReadView 可能引用这个旧版本时,才会真正物理清理。
可以在可重复读级别下验证这一点。会话 A 开启事务并快照读 id=1,会话 B 删除 id=1 并提交。会话 A 再次快照读 id=1,仍然能看到该行数据。这是因为会话 A 的 ReadView 认为删除操作对应的版本不可见,于是沿着版本链找到了删除前的版本。
13.2 删除后当前读的表现
如果会话 A 在执行快照读后,再对 id=1 执行 SELECT ... FOR UPDATE,结果会如何?由于当前读读取最新版本,而最新版本已被标记删除,因此当前读会返回空结果。这也再次说明了快照读和当前读的差异。业务开发中如果需要基于「当前最新状态」做判断,必须使用当前读或对语句加锁。
十四、MVCC 与幻读:可重复读并非万能
14.1 什么是幻读
幻读指的是在同一事务中,两次执行相同条件的范围查询,后一次查询比前一次查询多出了新的行。这些「多出来的行」通常由其他事务的插入导致。与不可重复读不同的是,不可重复读关心的是同一行数据的内容变化,而幻读关心的是结果集的行数变化。
14.2 快照读可以避免幻读
在可重复读级别下,事务内的普通 SELECT 使用同一个 ReadView,因此其他事务新插入的行不会被看到,快照读不会产生幻读。可以通过实验验证:会话 A 开启事务并使用范围查询读取所有 balance 大于 0 的记录,会话 B 插入一条新记录并提交,会话 A 再次执行相同查询,结果集不会多出新行。
14.3 当前读需要间隙锁抑制幻读
然而,当前读的情况不同。如果会话 A 执行 SELECT ... FOR UPDATE 范围查询,其他事务在此范围内插入新行,就可能出现幻读。为了解决这个问题,InnoDB 在可重复读级别下引入了间隙锁和临键锁。当执行范围当前读时,InnoDB 不仅锁定扫描到的行,还会锁定索引记录之间的间隙,阻止其他事务在间隙中插入新行,从而抑制幻读。
START TRANSACTION; SELECT * FROM mvcc_demo WHERE id BETWEEN 1 AND 10 FOR UPDATE; -- 该语句会锁定 id 在 [1, 10] 范围内的记录和间隙 COMMIT;需要注意的是,间隙锁只在可重复读级别下生效。读已提交级别下通常不使用间隙锁。
14.4 可重复读下仍然可能出现的幻读场景
虽然 InnoDB 通过 ReadView 和间隙锁在大多数场景下抑制了幻读,但仍存在一些边界情况。例如,事务 A 先执行快照读,没有读到的行,然后执行 UPDATE 修改某范围内的数据,再执行快照读时,可能会看到其他事务插入的行。这是因为 UPDATE 是当前读,它会看到最新数据,并且可能修改了原本在快照中不可见的行。后续的快照读虽然使用旧 ReadView,但自己修改的行对自己可见,所以可能出现「快照中突然多了几行」的效果。这就是可重复读下仍然可能出现幻读的一个经典反例。
十五、MVCC 与锁机制的关系
15.1 两种并发控制策略的协同
MVCC 和锁机制并不是互相排斥的,而是协同工作的。MVCC 主要解决快照读的并发问题,锁机制主要解决当前读和写操作的并发问题。普通 SELECT 通过 MVCC 实现无锁读取,UPDATE、DELETE、FOR UPDATE 等操作则通过锁机制来保证数据的独占访问。
可以把 InnoDB 的并发控制看作两层:读层使用 MVCC,写层使用锁。读写之间的冲突通过版本链来化解,写写之间的冲突则通过行锁、间隙锁和意向锁来化解。
15.2 常见锁类型回顾
| 锁类型 | 作用对象 | 说明 |
|---|---|---|
| 共享锁 | 行 | 允许其他事务读,不允许写 |
| 排他锁 | 行 | 不允许其他事务读和写 |
| 意向共享锁 | 表 | 表示事务打算在表中加行级共享锁 |
| 意向排他锁 | 表 | 表示事务打算在表中加行级排他锁 |
| 记录锁 | 索引记录 | 锁定单条记录 |
| 间隙锁 | 索引间隙 | 锁定记录之间的范围,防止插入 |
| 临键锁 | 索引记录+间隙 | 记录锁和间隙锁的组合 |
在可重复读级别下,范围当前读会因为临键锁而锁定较大的范围,这是该级别下锁冲突较多的原因之一。读已提交级别则只锁定命中的记录,锁范围更小,并发度更高,但代价是无法完全避免不可重复读。
十六、purge 线程与 undo log 清理
16.1 为什么需要 purge
随着更新操作的不断发生,undo log 中的历史版本会越来越多。如果不加清理,undo log 会无限膨胀,占用大量磁盘空间。purge 线程的作用就是定期扫描 undo log,删除那些不再被任何事务需要的历史版本。
一个 undo log 版本只有在满足以下条件时才能被清理:没有任何 ReadView 可能引用它,即所有可能看见它的快照事务都已经结束。具体来说,InnoDB 会维护一个全局最老的活跃 ReadView,只有比该 ReadView 更老且不再被引用的 undo log 版本才会被清理。
16.2 长事务对 purge 的影响
长事务是 undo log 膨胀的常见原因。如果某个事务长时间不提交,并且它持有 ReadView,那么所有在该 ReadView 之后生成的历史版本都不能被清理,因为当前事务可能还需要读取它们。因此,长事务不仅会占用锁资源,还会导致 undo log 持续增长,进而影响数据库整体性能。
可以通过以下语句查找当前正在运行的事务:
SELECT * FROM information_schema.innodb_trx;重点观察 trx_started 字段,如果某个事务的启动时间非常早,就需要引起注意。对于超过数分钟甚至数小时的长事务,应当及时排查并考虑是否终止。
十七、MVCC 在实际开发中的常见问题
17.1 快照读与业务判断不一致
一个常见问题是:业务代码在事务中先执行普通 SELECT 判断余额是否充足,再执行 UPDATE 扣减余额。由于普通 SELECT 是快照读,它读到的余额可能已经过期,而 UPDATE 的定位读取是当前读,会基于最新数据进行修改。如果判断和修改之间其他事务也执行了扣减,就可能出现余额不足但仍执行扣减的问题。
正确的做法是使用当前读来进行判断,例如 SELECT ... FOR UPDATE,或者使用乐观锁、版本号、条件更新等方式保证判断和修改的一致性。
START TRANSACTION; SELECT balance FROM account WHERE id = 1 FOR UPDATE; -- 基于本次读取的余额进行判断 UPDATE account SET balance = balance - 50 WHERE id = 1; COMMIT;17.2 可重复读下的「读旧数据」误判
在可重复读级别下,事务内读取到旧数据是正常行为,但业务开发人员有时会误以为数据没有更新成功。例如,程序在一个长事务中先读取了某个配置,随后管理员更新了该配置并提交,但程序仍然读取到旧配置。这不是故障,而是隔离级别的语义使然。如果业务需要实时读取最新配置,应该缩短事务时间,或者在必要时使用当前读。
17.3 大事务与 undo log 膨胀
在一个事务中执行大量更新操作,会生成大量 undo log。该事务提交后,这些 undo log 还需要等待 purge 线程清理。在处理大批量数据更新时,建议拆分事务,分批提交,避免单个事务过大,从而减少 undo log 的瞬时膨胀和对 purge 的压力。
17.4 索引与间隙锁带来的锁冲突
在可重复读级别下,如果范围查询使用了不合适的索引,可能会导致较大的间隙锁范围,进而引发锁等待甚至死锁。例如,一个范围更新语句在缺乏合适索引的情况下进行全表扫描,会锁定大量间隙,阻塞其他插入操作。此时需要检查执行计划,为查询条件建立合适的索引,减小锁范围。
EXPLAIN SELECT * FROM orders WHERE create_time > '2026-01-01' FOR UPDATE;十八、MVCC 与 InnoDB 存储引擎的其他机制
18.1 与 redo log 的区别
redo log 和 undo log 是两个容易混淆的概念。redo log 是物理日志,记录的是数据页的物理修改,用于崩溃恢复,保证已提交事务的持久性。undo log 是逻辑日志,记录的是逆操作,用于事务回滚和 MVCC 版本链。
一个更新操作的完整执行过程大致如下:先写 undo log 记录旧版本,再修改内存中的缓冲池数据页,同时写 redo log 记录对数据页的修改,最后在事务提交时根据刷盘策略将 redo log 持久化。崩溃恢复时,redo log 保证已提交的修改不丢失,undo log 则用于回滚未完成的事务。
18.2 与 Buffer Pool 的交互
InnoDB 的 Buffer Pool 用于缓存数据页和索引页。MVCC 读取历史版本时,首先会在聚簇索引的当前版本中查找,如果当前版本不可见,则沿着 undo log 重构历史版本。读取 undo log 的过程可能涉及磁盘访问,因此历史版本过多的行在快照读时性能可能较差。Buffer Pool 也会缓存部分 undo 页,从而加速历史版本的重构。
18.3 与 change buffer 的关系
change buffer 用于缓存二级索引的修改,以延迟二级索引的维护。它与 MVCC 的关系相对间接。二级索引的版本判断依赖于聚簇索引,change buffer 的合并时机也会影响二级索引查询的性能。在大批量更新场景下,change buffer 可以提高写入性能,但可能增加后续读取的延迟。
十九、从源码视角看 MVCC 的实现关键点
19.1 ReadView 的类结构
在 MySQL 源码中,ReadView 类位于 storage/innobase/read/read0read.h 中。其核心成员包括:
class ReadView { // 是否为高水位以上事务创建的 ReadView bool m_ignore_high_water_mark; // 活跃事务 ID 数组 ids_t m_ids; // 当前 ReadView 的创建事务 ID trx_id_t m_creator_trx_id; // 活跃事务最小 ID trx_id_t m_low_limit_id; // 下一个即将分配的事务 ID trx_id_t m_up_limit_id; // 事务 ID 是否回卷 bool m_low_limit_no; bool m_up_limit_no; };源码中的 m_low_limit_id 即前文所述 min_trx_id,m_up_limit_id 即 max_trx_id。通过阅读源码可以发现,可见性判断的公共函数 changes_visible 会在不同场景下被调用,包括聚簇索引、二级索引和回表过程。
19.2 可见性判断函数
源码中的核心可见性判断位于 storage/innobase/row/row0vers.cc 中。其思路与本文第五节的伪代码一致。对于每个行版本,首先判断删除标记,然后通过事务 ID 与 ReadView 的上下水位和活跃集合进行比较。如果版本不可见,则通过 roll_ptr 找到上一个版本继续判断。
对源码感兴趣的同学,可以重点阅读 read0read.cc 中 ReadView 的构造函数和 changes_visible 相关函数,以及 row0vers.cc 中版本遍历逻辑。理解源码可以帮助你更深入地把控 MVCC 的边界行为。
二十、MVCC 的性能影响与优化建议
20.1 历史版本链过长的代价
当一条记录被频繁更新时,其版本链会变得很长。后续事务的快照读需要沿着版本链回溯,直到找到可见版本。如果版本链过长,单次快照读的代价就会上升。虽然 InnoDB 对 undo log 访问做了缓存优化,但极端情况下仍可能出现性能下降。
为了控制版本链长度,应该避免对同一行进行无意义的高频更新。例如,某些业务逻辑会用「先更新时间戳再更新状态」的方式来模拟状态流转,这会生成大量不必要的版本。可以合并更新,减少单行更新次数。
20.2 优化长事务
长事务是 MVCC 场景下最需要警惕的问题。长事务不仅阻塞 purge,还可能导致锁等待和 undo log 膨胀。优化建议包括:
- 缩短事务的执行时间,避免在事务中执行耗时操作。
- 避免在事务中发起远程调用、用户交互等外部依赖。
- 批量处理数据时拆分事务,分批提交。
- 监控 information_schema.innodb_trx,及时发现长事务。
20.3 合理选择隔离级别
隔离级别并非越高越好。可重复读提供了更强的一致性,但带来了更多的间隙锁和锁冲突;读已提交锁范围更小,并发性能更好,但要接受不可重复读。业务应该根据自身需求选择合适的隔离级别。对于大多数读多写少、对一致性要求不是特别严格的业务,读已提交可能更具性能优势;对于金融、订单等强一致性业务,可重复读或更高隔离级别是更稳妥的选择。
20.4 监控与排查工具
以下语句和命令在 MVCC 问题排查中非常有用:
-- 查看当前事务信息 SELECT * FROM information_schema.innodb_trx; -- 查看锁等待情况 SELECT * FROM performance_schema.data_lock_waits; -- 查看当前连接的隔离级别 SELECT @@transaction_isolation; -- 查看 InnoDB 状态 SHOW ENGINE INNODB STATUS;其中 SHOW ENGINE INNODB STATUS 输出的 TRANSACTIONS 段落会包含活跃事务列表、每个事务持有的锁以及等待的锁,是排查锁等待和长事务问题的第一手信息。
二十一、实验五:验证事务 ID 的分配时机
21.1 观察纯读事务
在 MySQL 8.0 中,可以通过 performance_schema 中的一些表间接观察事务状态,但直接查看事务 ID 并不容易。一个可行的方法是观察 information_schema.innodb_trx 表中的 trx_id 字段。开启一个只执行 SELECT 的事务,查看 trx_id 是否为 0:
START TRANSACTION; SELECT * FROM mvcc_demo WHERE id = 1; SELECT trx_id, trx_started, trx_state FROM information_schema.innodb_trx;+--------+---------------------+-----------+ | trx_id | trx_started | trx_state | +--------+---------------------+-----------+ | 0 | 2026-08-29 13:46:07 | RUNNING | +--------+---------------------+-----------+可以看到纯读事务的 trx_id 为 0,说明它还没有分配正式的写事务 ID。这也验证了事务 ID 只在第一次写操作时分配的结论。
21.2 观察写事务
开启一个包含 UPDATE 的事务,再查看 trx_id:
START TRANSACTION; UPDATE mvcc_demo SET balance = balance + 10 WHERE id = 1; SELECT trx_id, trx_started, trx_state FROM information_schema.innodb_trx;+------------------+---------------------+-----------+ | trx_id | trx_started | trx_state | +------------------+---------------------+-----------+ | 283145 | 2026-08-29 13:46:07 | RUNNING | +------------------+---------------------+-----------+此时 trx_id 不再是 0,而是一个具体的数字,说明执行写操作后事务 ID 被真正分配。
二十二、实验六:观察 undo log 与版本链
22.1 查看 undo 相关状态
可以通过 SHOW ENGINE INNODB STATUS 查看 undo 相关的统计信息,重点关注 HISTORY LIST LENGTH,它反映了 purge 待处理的历史版本数量:
SHOW ENGINE INNODB STATUS\G------------ TRANSACTIONS ------------ Trx id counter 283146 Purge done for trx's n:o < 283140 undo n:o < 0 state: running but idle History list length 6 ...History list length 表示待清理的 undo 版本数量。如果该数值持续增长且不下降,可能说明存在长事务阻塞了 purge,需要排查。
22.2 制造版本链并观察
连续执行多次更新,然后观察 History list length 的变化:
UPDATE mvcc_demo SET balance = balance + 1 WHERE id = 1; UPDATE mvcc_demo SET balance = balance + 1 WHERE id = 1; UPDATE mvcc_demo SET balance = balance + 1 WHERE id = 1; COMMIT;如果存在一个未提交的读事务持有较旧 ReadView,这些新产生的版本就不能被立即清理,History list length 会增加。提交读事务后,purge 线程会逐步清理,History list length 随之下降。这个实验可以帮助你直观理解 undo log 的生命周期。
二十三、MVCC 与事务回滚的协同
23.1 回滚依赖 undo log
当一个事务执行 ROLLBACK 时,InnoDB 需要根据 undo log 中的逆操作,把数据恢复到事务开始前的状态。对于 INSERT,回滚时执行删除;对于 UPDATE,回滚时恢复旧值;对于 DELETE,回滚时重新插入记录。
由于 MVCC 的存在,在回滚完成之前,其他事务可能已经读取了该事务未提交的版本。这要求 undo log 中的版本信息在回滚期间保持可用。InnoDB 通过精细的并发控制保证这一点。
23.2 部分回滚与保存点
MySQL 支持 SAVEPOINT 和 ROLLBACK TO SAVEPOINT,实现部分回滚。此时事务不会完全结束,而是回滚到指定保存点。InnoDB 会利用 undo log 撤销保存点之后的修改,同时保留保存点之前的修改。这在复杂事务和存储过程错误处理中非常有用。
START TRANSACTION; INSERT INTO mvcc_demo VALUES (2, 'Bob', 200.00); SAVEPOINT sp1; UPDATE mvcc_demo SET balance = 300.00 WHERE id = 2; ROLLBACK TO SAVEPOINT sp1; SELECT * FROM mvcc_demo WHERE id = 2; -- 结果中 balance 仍为 200.00 COMMIT;二十四、MVCC 在不同 MySQL 版本中的演进
24.1 MySQL 5.6 及以前
MySQL 5.6 及以前版本中,undo log 存储在共享表空间 ibdata 中。共享表空间无法回收已使用的空间,即使 undo log 被清理,磁盘空间也不会被释放,只能通过重建实例来收缩。这是早期版本中让人头疼的一个问题。
24.2 MySQL 5.7 的改进
MySQL 5.7 引入了独立的 undo 表空间,并支持 undo 表空间的截断回收。这解决了共享表空间无法回收的问题。同时,5.7 还对临时表 undo log 进行了优化,使用独立的临时表空间。
24.3 MySQL 8.0 的改进
MySQL 8.0 在 MVCC 和 undo log 方面进一步改进。默认使用两个 undo 表空间,支持在线设置 undo 表空间数量。MySQL 8.0 还优化了临时表 undo log 的管理方式,将其从独立的临时表空间中分离出来,改为存储在临时 undo 表空间,并在正常关闭时清理,避免临时表空间过大。
此外,MySQL 8.0 对索引结构的优化和查询优化器的改进,也间接影响了 MVCC 的性能表现。了解版本演进,有助于在生产环境升级前评估风险。
二十五、与其他数据库 MVCC 实现的对比
25.1 PostgreSQL 的 MVCC
PostgreSQL 也实现了 MVCC,但其实现方式与 InnoDB 不同。PostgreSQL 不采用 undo log 保存历史版本,而是把行的旧版本直接保留在数据页中,新版本作为新行插入。PostgreSQL 通过隐藏列 xmin 和 xmax 记录行的创建事务 ID 和删除事务 ID,可见性判断通过比较事务快照中的活跃事务集合来完成。过期版本由 VACUUM 进程清理。
两种方式各有优劣。InnoDB 的 undo log 方式把历史版本集中管理,便于回滚和回滚段管理,但长事务可能导致 undo 膨胀;PostgreSQL 的堆存储方式实现简单,但数据页膨胀需要 VACUUM 频繁清理,可能出现表膨胀。
25.2 Oracle 的 MVCC
Oracle 也使用 undo 表空间保存历史版本,并通过 System Change Number 实现一致性读。Oracle 的读一致性模型与 InnoDB 有相似之处,但在隔离级别定义和回滚段管理上有所差异。了解不同数据库的实现,可以帮助你从更高层次理解 MVCC 的设计思想。
二十六、常见误解澄清
26.1 MVCC 是否意味着无锁?
MVCC 让快照读无需加锁,但这并不等于 InnoDB 没有锁。写操作、当前读、外键检查、唯一性检查等场景仍然会使用锁。准确地说,MVCC 减少了读操作的锁竞争,而不是消灭了锁。
26.2 可重复读下永远不会读到新数据吗?
不是。可重复读保证的是同一事务内的快照读结果一致。但如果事务中执行了当前读,或者自己修改了数据,那么后续读取会看到新数据。此外,本文第十四节提到的边界场景也可能导致幻读,因此不能把可重复读简单地等同于「事务内数据完全不变」。
26.3 undo log 会被立即删除吗?
不会。更新 undo log 在事务提交后并不能立即删除,必须等待 purge 线程确认没有其他事务需要读取这些旧版本。因此,即使事务已经提交,undo log 可能仍然存在一段时间。
26.4 版本链是无限长的吗?
不是。purge 线程会定期清理不再需要的旧版本,版本链长度通常受到系统并发事务和更新频率的影响。但如果有长事务阻塞,版本链可能会暂时变得很长。
二十七、实践建议与生产排查清单
27.1 设计阶段的建议
- 为表设计合理的主键,尽量避免频繁更新主键值。
- 对于热点行更新频繁的表,评估是否可以通过合并更新、批处理等方式降低更新频率。
- 根据业务一致性要求选择合适的隔离级别,不要盲目使用可重复读。
- 避免在事务中混用快照读和当前读而不自知,必要时统一使用当前读或加锁。
27.2 开发阶段的建议
- 事务尽量短小,不要在事务中执行外部调用或耗时操作。
- 批量更新时拆分事务,分批提交。
- 对于需要「读取后判断再修改」的场景,优先使用 SELECT ... FOR UPDATE 或乐观锁。
- 关注执行计划,避免范围查询因缺失索引而锁定过大间隙。
27.3 运维排查清单
- 定期检查 information_schema.innodb_trx,发现长事务。
- 关注 SHOW ENGINE INNODB STATUS 中的 History list length。
- 关注 performance_schema.data_lock_waits,排查锁等待。
- 监控 undo 表空间大小,防止 undo 膨胀。
- 出现死锁时结合 SHOW ENGINE INNODB STATUS 中的 LATEST DETECTED DEADLOCK 分析。
二十八、总结
MVCC 是 InnoDB 实现高并发和事务一致性的核心机制。它通过隐藏列、undo log 和 ReadView,为每一行数据维护多个可见版本,让快照读在不加锁的情况下也能获得一致性视图,从而大幅减少了读写冲突。同时,InnoDB 还通过当前读、行锁、间隙锁和临键锁,为写操作和范围查询提供了可靠的并发控制。
理解 MVCC 不能只停留在「读不阻塞写」这一句话上。我们需要掌握版本链的形成过程、ReadView 的组成与可见性判断规则、快照读和当前读的区别,以及不同隔离级别下 ReadView 生成时机的差异。只有把这些机制串起来,才能真正解释诸如「可重复读下为什么读不到别人提交的数据」「为什么删除后还能查到」「为什么当前读能看到快照读看不到的数据」等实际问题。
更重要的是,MVCC 并非空中楼阁,它与 undo log 清理、长事务、锁冲突、索引设计、性能优化紧密相关。一个长时间不提交的只读事务可能导致 undo log 膨胀;一个不加索引的范围更新可能锁定大量间隙;一次「读旧数据」的判断错误可能导致资金逻辑出错。掌握 MVCC,不仅是为了通过面试,更是为了在系统设计、业务开发和故障排查中做出正确的决策。
希望本文的讲解和实验能够帮助你建立起对 MVCC 的系统性认知。最好的学习方式仍然是亲手实验:在本地 MySQL 上复现文中的每一个场景,观察实际输出,再试着修改条件、隔离级别和操作顺序,看看结果会如何变化。当你能用 MVCC 的原理解释每一个实验结果时,你才真正理解了它。