ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入理解MySQL InnoDB MVCC:原理、实验与实践

深入理解MySQL InnoDB MVCC:原理、实验与实践 一、从一个面试题说起为什么 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_ID6 字节记录最后一次插入或更新该行数据的事务 ID。DB_ROLL_PTR7 字节回滚指针指向该行数据上一个版本所在的 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;在上面的表中实际存储到磁盘上的聚簇索引记录结构大致可以表示为[id1][nameAlice][balance100.00][DB_TRX_ID100][DB_ROLL_PTR0x... ][删除标记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 中已经存在一行 id1 的记录初始值由事务 trx_id100 插入当前行id1, nameAlice, balance100.00, DB_TRX_ID100, DB_ROLL_PTRNULL事务 trx_id101 执行 UPDATE把 balance 改成 200.00。此时 InnoDB 会执行以下逻辑当前行id1, nameAlice, balance200.00, DB_TRX_ID101, DB_ROLL_PTR指向undo记录A undo记录Aid1, nameAlice, balance100.00, DB_TRX_ID100, DB_ROLL_PTRNULL接着事务 trx_id102 执行 UPDATE把 name 改成 Bob。版本链继续延长当前行id1, nameBob, balance200.00, DB_TRX_ID102, DB_ROLL_PTR指向undo记录B undo记录Bid1, nameAlice, balance200.00, DB_TRX_ID101, DB_ROLL_PTR指向undo记录A undo记录Aid1, nameAlice, balance100.00, DB_TRX_ID100, DB_ROLL_PTRNULL至此id1 的这条记录在逻辑上拥有三个版本分别对应三个不同的事务 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 的生命周期有助于后续分析磁盘占用和长事务问题。五、ReadViewMVCC 可见性判断的核心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 UPDATESELECT ... LOCK IN SHARE MODE在 MySQL 8.0 中写作 SELECT ... FOR SHAREUPDATE 操作本身在定位数据时执行的读操作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; -- 结果id1, nameAlice, balance100.00保持会话 A 的事务不提交。在会话 B 中执行更新并提交UPDATE mvcc_demo SET balance 200.00 WHERE id 1; COMMIT;回到会话 A再次执行同样的查询SELECT * FROM mvcc_demo WHERE id 1; -- 结果仍然是id1, nameAlice, balance100.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-COMMITTEDSET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;确认隔离级别已切换SELECT transaction_isolation;------------------------- | transaction_isolation | ------------------------- | READ-COMMITTED | -------------------------10.2 实验步骤为了确保实验的初始状态一致先将 balance 恢复为 100.00UPDATE mvcc_demo SET balance 100.00 WHERE id 1; COMMIT;在会话 A 中开启事务并执行第一次查询START TRANSACTION; SELECT * FROM mvcc_demo WHERE id 1; -- 结果balance100.00在会话 B 中更新并提交UPDATE mvcc_demo SET balance 300.00 WHERE id 1; COMMIT;回到会话 A再次执行查询SELECT * FROM mvcc_demo WHERE id 1; -- 结果balance300.00这次会话 A 读到了会话 B 提交后的新值 300.00这就是读已提交级别下的不可重复读现象。10.3 原理分析在读已提交级别下每次快照读都会重新生成 ReadView。会话 A 的第二次 SELECT 生成的 ReadView 中会话 B 已经提交其事务 ID 不在活跃集合 m_ids 中因此最新版本的 balance300.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 开启事务并快照读 id1会话 B 删除 id1 并提交。会话 A 再次快照读 id1仍然能看到该行数据。这是因为会话 A 的 ReadView 认为删除操作对应的版本不可见于是沿着版本链找到了删除前的版本。13.2 删除后当前读的表现如果会话 A 在执行快照读后再对 id1 执行 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_idm_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 是否为 0START 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_idSTART 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 trxs 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 的 MVCCPostgreSQL 也实现了 MVCC但其实现方式与 InnoDB 不同。PostgreSQL 不采用 undo log 保存历史版本而是把行的旧版本直接保留在数据页中新版本作为新行插入。PostgreSQL 通过隐藏列 xmin 和 xmax 记录行的创建事务 ID 和删除事务 ID可见性判断通过比较事务快照中的活跃事务集合来完成。过期版本由 VACUUM 进程清理。两种方式各有优劣。InnoDB 的 undo log 方式把历史版本集中管理便于回滚和回滚段管理但长事务可能导致 undo 膨胀PostgreSQL 的堆存储方式实现简单但数据页膨胀需要 VACUUM 频繁清理可能出现表膨胀。25.2 Oracle 的 MVCCOracle 也使用 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 的原理解释每一个实验结果时你才真正理解了它。
返回列表