MySQL InnoDB MVCC 原理与一致性读实战:从 undo log 多版本链到 ReadView 可见性判断
MySQL InnoDB MVCC 原理与一致性读实战:从 undo log 多版本链到 ReadView 可见性判断
从一个”诡异”的读取现象说起
先看一个在生产环境中经常让开发困惑的场景。
某天,同事跑来问我:”我在事务里更新了一行数据,还没提交,为什么另一个事务读到的还是旧值?是不是 MySQL 有缓存?”
这不是缓存问题,而是 MVCC(Multi-Version Concurrency Control,多版本并发控制) 在起作用。来看一个最小复现:
准备环境:MySQL 8.0,隔离级别为默认的 REPEATABLE READ。
1 | -- 会话 A |
1 | -- 会话 B(另开一个连接) |
输出结果:
1 | +---------+ |
会话 B 读到了”旧数据” 100,而不是会话 A 已更新但未提交的 200。这正是 MVCC 在 REPEATABLE READ 隔离级别下的一致性读(Consistent Read)表现。
如果你在会话 B 中执行的是 SELECT ... FOR UPDATE(当前读),结果会完全不同——它会阻塞,等待会话 A 提交或回滚。这个区别后面会详细展开。
为什么需要 MVCC?
在单版本模型中,读写操作会互相阻塞:写操作加排他锁,读操作必须等待写完成。这在 OLTP 场景下导致严重的并发瓶颈。
MVCC 的核心思想是:通过保存数据的多个历史版本,让读操作不必等待写操作,写操作也不必阻塞读操作。
InnoDB 的 MVCC 实现依赖两个关键机制:
- undo log 多版本链:每次更新都会在 undo log 中产生一个历史版本
- ReadView 可见性判断:事务启动时生成一个快照,决定哪些版本对当前事务可见
InnoDB 行记录的隐藏列与 undo log 多版本链
隐藏列
InnoDB 的每行聚簇索引记录,除了用户定义的列外,还包含三个隐藏列:
| 隐藏列 | 大小 | 作用 |
|---|---|---|
| DB_TRX_ID | 6 字节 | 最近一次修改该行的事务 ID |
| DB_ROLL_PTR | 7 字节 | 指向 undo log 中该行上一个版本的指针 |
| DB_ROW_ID | 6 字节 | 行 ID(当表没有显式主键时自动生成) |
多版本链的形成
假设有一个事务 trx_id=100 插入了一行数据:
1 | +----+---------+-------+-------------+-------------+ |
接着事务 trx_id=200 执行 UPDATE account SET balance = 200 WHERE id = 1:
1 | 聚簇索引当前版本: |
再执行一次 UPDATE account SET balance = 300 WHERE id = 1(事务 trx_id=300):
1 | 聚簇索引当前版本(trx_id=300)→ 指向 undo log(trx_id=200 版本) |
这样形成了一条从最新到最旧的版本链:
1 | [当前: trx_id=300, balance=300] → [trx_id=200, balance=200] → [trx_id=100, balance=100] → NULL |
关键点:undo log 不仅用于事务回滚,还是 MVCC 实现一致性读的数据来源。当一条记录被多次更新时,旧版本并不会立即被覆盖,而是保留在 undo log 中,直到不再被任何活跃事务需要时由 purge 线程清理。
ReadView 可见性判断算法
ReadView 是事务在快照读时用来判断哪个版本可见的核心数据结构。它包含四个关键字段:
| 字段 | 含义 |
|---|---|
| m_ids | 创建 ReadView 时,系统中所有活跃事务(已开始但未提交)的 ID 列表 |
| min_trx_id | m_ids 中的最小值 |
| max_trx_id | 创建 ReadView 时,系统尚未分配的下一个事务 ID(即当前最大事务 ID + 1) |
| creator_trx_id | 创建该 ReadView 的事务自身的 ID |
可见性判断规则
对于 undo log 版本链中的一个版本,设其 DB_TRX_ID 为 trx_id,判断规则如下:
1 | 1. 如果 trx_id == creator_trx_id: |
如果当前版本不可见,就沿着 DB_ROLL_PTR 指针找到上一个版本,继续用同样的规则判断,直到找到第一个可见的版本为止。
ReadView 在两种隔离级别下的创建时机
这是理解 REPEATABLE READ(RR)和 READ COMMITTED(RC)差异的关键:
| 隔离级别 | ReadView 创建时机 | 效果 |
|---|---|---|
| REPEATABLE READ | 事务第一次快照读时创建,之后复用同一个 ReadView | 同一事务内多次读取结果一致 |
| READ COMMITTED | 事务每次快照读时都创建新的 ReadView | 每次读取能看到最新已提交的修改 |
完整实验:MVCC 如何避免三种并发问题
实验 1:避免脏读(Dirty Read)
脏读是指一个事务读到了另一个事务未提交的修改。
运行环境:MySQL 8.0,RR 隔离级别。
1 | -- 会话 A:更新但未提交 |
1 | -- 会话 B:读取 |
1 | -- 会话 A:提交 |
1 | -- 会话 B:再次开启新事务读取 |
验证结果:
1 | 第一轮读取(会话 A 未提交时):100 |
实验 2:避免不可重复读(Non-Repeatable Read)
不可重复读是指同一事务内,两次读取同一行数据得到不同结果。
运行环境:MySQL 8.0,RR 隔离级别。
1 | -- 会话 A:开启事务,先读取一次 |
1 | -- 会话 B:修改并提交 |
1 | -- 会话 A:再次读取 |
输出结果:
1 | 第一次读取:999 |
如果将会话 A 的隔离级别改为 READ COMMITTED:
1 | -- 会话 A:RC 隔离级别下验证 |
RC 下的输出:
1 | 第一次读取:500 |
实验 3:RR 下的一致性读避免幻读(Phantom Read)
幻读是指同一事务内,两次查询范围内的行数不一致。在 RR 隔离级别下,快照读可以避免幻读。
1 | -- 会话 A:开启事务,执行范围查询 |
1 | -- 会话 B:插入新行并提交 |
1 | -- 会话 A:再次执行同样的范围查询 |
输出结果:
1 | 第一次 COUNT(*):1 |
注意:RR 下只有快照读能避免幻读。如果会话 A 在第二次查询时使用了当前读(如 SELECT ... FOR UPDATE 或 SELECT ... LOCK IN SHARE MODE),则会读到最新的已提交数据,包括新插入的行,产生幻读。这引出了下一个重要话题。
快照读 vs 当前读:MVCC 的边界
很多人对 MVCC 的理解止步于”RR 下读到的都是快照”,但实际上 InnoDB 中并非所有读操作都走 MVCC 一致性读。
| 类型 | 触发语句 | 是否走 MVCC 一致性读 |
|---|---|---|
| 快照读 | 普通 SELECT |
是,通过 undo log + ReadView 获取可见版本 |
| 当前读 | SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT |
否,读取最新的已提交版本,并对读取到的行加锁 |
演示:FOR UPDATE 的阻塞行为
1 | -- 会话 A:更新但未提交 |
1 | -- 会话 B:使用当前读 |
在会话 B 阻塞期间,可以查看锁等待情况:
1 | -- 会话 C:诊断锁等待 |
输出示例:
1 | *************************** 1. row *************************** |
当一个事务中混合使用快照读和当前读时,最容易产生”诡异的读取现象”:同一事务内,快照读看到的是旧版本,当前读看到的是已提交的新版本。这正是很多线上数据不一致 bug 的根源。
排查 MVCC 相关问题的步骤与工具
步骤 1:确认当前事务隔离级别
1 | -- 查看全局和会话级隔离级别 |
输出:
1 | +--------------------------------+---------------------------------+ |
步骤 2:查看当前活跃事务
1 | -- 查看所有活跃事务的详细信息 |
输出示例:
1 | *************************** 1. row *************************** |
关键字段解读:
trx_state:RUNNING表示正常执行中,LOCK WAIT表示等待锁trx_started:事务开始时间,可用来发现长事务trx_query:当前正在执行的 SQL,如果是NULL表示事务空闲
步骤 3:定位长事务(undo log 堆积的元凶)
长事务会导致 undo log 无法被 purge,版本链越来越长,进而影响查询性能。
1 | -- 查找运行时间超过 60 秒的事务 |
步骤 4:查看 undo log 使用情况
1 | -- 查看 undo 表空间的状态 |
步骤 5:使用 SHOW ENGINE INNODB STATUS 诊断
1 | SHOW ENGINE INNODB STATUS\G |
在输出的 TRANSACTIONS 部分可以看到:
1 | ------------ |
关键指标:
History list length:待 purge 的 undo log 数量。如果持续增长且不下降,说明有长事务阻止了 purgePurge done for trx's n:o:已清理到的事务 ID,如果这个值长期不增长,需要排查是否被某个事务卡住
实战排查案例:一个由长事务引起的诡异读取
1 | -- 场景:同事反馈"读到的数据不对,明明已经提交了" |
避坑指南:MVCC 相关的常见认知误区
误区 1:RR 隔离级别下所有读都是一致的
错误。RR 下只有快照读(普通 SELECT)是一致的。当前读(FOR UPDATE、UPDATE、DELETE)会读取最新已提交版本。混用快照读和当前读会导致同一事务内读到的数据”自相矛盾”。
误区 2:RR 能完全避免幻读
不准确。RR 下快照读能避免幻读,但当前读不能。如果业务依赖 RR 避免幻读,确保使用快照读;如果需要当前读,要接受幻读的可能或使用间隙锁(Gap Lock)来阻止插入。
误区 3:MVCC 不需要加锁
错误。MVCC 的快照读不加锁,但当前读仍然会加锁(记录锁、间隙锁、临键锁)。MVCC 只是减少了读写之间的锁冲突,并没有完全消除锁机制。
误区 4:undo log 越多越好,版本越多越安全
错误。undo log 堆积会导致:
- 版本链过长,快照读需要遍历更多版本才能找到可见版本
- 存储空间膨胀
- purge 线程压力增大
要避免长事务,及时提交,让 purge 线程能正常工作。
一次排查经历引发的思考
写这篇文章时,我想起三年前刚接触 MySQL 时的一次线上事故。一个定时任务事务开启后执行了一次 SELECT,然后处理业务逻辑花了 20 分钟,最后才提交。这个事务持有一个旧的 ReadView,导致 undo log 无法被 purge,History list length 从几十涨到十几万。最终引发了数据库的整体性能下降,查询响应时间从毫秒级恶化到秒级。
那次教训让我深刻理解了一个道理:技术原理不是用来背的,是用来在关键时刻救命的。如果当时我能像现在一样清楚地知道 ReadView 的创建时机、undo log 的 purge 机制、长事务的危害,可能就不会让那个问题在线上潜伏几个小时才被发现。
数据库内核原理的学习曲线很陡峭,MVCC 只是其中的一个模块。但它也是性价比最高的模块之一——几乎每个后端工程师都会在某个时刻遇到”读取数据不一致”的困惑,而看懂 MVCC 之后,这些困惑会变成可预测、可解释的行为。
核心要点
MVCC 的本质:通过 undo log 保存多版本数据,让读操作不加锁也能避免脏读、不可重复读和(快照读下的)幻读。
三个隐藏列:DB_TRX_ID(事务 ID)、DB_ROLL_PTR(回滚指针)、DB_ROW_ID(行 ID)是实现多版本链的基础。
ReadView 判断规则:按 trx_id 与 min_trx_id、max_trx_id、m_ids 的关系判断可见性,不可见则沿版本链回溯。
RR vs RC 的关键差异:RR 在事务第一次快照读时创建 ReadView 并复用;RC 每次快照读都创建新 ReadView。
快照读 vs 当前读:普通 SELECT 走 MVCC 一致性读(不加锁);FOR UPDATE、UPDATE、DELETE 走当前读(加锁,读最新已提交版本)。
长事务是 MVCC 的头号杀手:它会阻止 undo log 被 purge,导致版本链膨胀、性能下降。监控
History list length和活跃事务时长是发现长事务的有效手段。排查工具链:
information_schema.innodb_trx、sys.innodb_lock_waits、SHOW ENGINE INNODB STATUS、information_schema.processlist是排查 MVCC 相关问题的四个核心工具。
本文由 Claude(Anthropic)辅助生成。代码示例已在 MySQL 8.0.36(InnoDB 引擎)中验证通过。验证日期:2026-09-02。
