MySQL InnoDB MVCC 原理与一致性读实战:从 undo log 多版本链到 ReadView 可见性判断

从一个”诡异”的读取现象说起

先看一个在生产环境中经常让开发困惑的场景。

某天,同事跑来问我:”我在事务里更新了一行数据,还没提交,为什么另一个事务读到的还是旧值?是不是 MySQL 有缓存?”

这不是缓存问题,而是 MVCC(Multi-Version Concurrency Control,多版本并发控制) 在起作用。来看一个最小复现:

准备环境:MySQL 8.0,隔离级别为默认的 REPEATABLE READ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 会话 A
CREATE DATABASE IF NOT EXISTS mvcc_demo;
USE mvcc_demo;

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

INSERT INTO account VALUES (1, 100, 'Alice');

-- 开启事务 A,更新但**不提交**
START TRANSACTION;
UPDATE account SET balance = 200 WHERE id = 1;
-- 此时不执行 COMMIT
1
2
3
4
5
6
7
8
9
10
-- 会话 B(另开一个连接)
USE mvcc_demo;

-- 设置事务隔离级别为 REPEATABLE READ(MySQL 默认)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

START TRANSACTION;
SELECT balance FROM account WHERE id = 1;
-- 结果:100(而不是 200)
COMMIT;

输出结果

1
2
3
4
5
+---------+
| balance |
+---------+
| 100 |
+---------+

会话 B 读到了”旧数据” 100,而不是会话 A 已更新但未提交的 200。这正是 MVCC 在 REPEATABLE READ 隔离级别下的一致性读(Consistent Read)表现。

如果你在会话 B 中执行的是 SELECT ... FOR UPDATE(当前读),结果会完全不同——它会阻塞,等待会话 A 提交或回滚。这个区别后面会详细展开。

为什么需要 MVCC?

在单版本模型中,读写操作会互相阻塞:写操作加排他锁,读操作必须等待写完成。这在 OLTP 场景下导致严重的并发瓶颈。

MVCC 的核心思想是:通过保存数据的多个历史版本,让读操作不必等待写操作,写操作也不必阻塞读操作

InnoDB 的 MVCC 实现依赖两个关键机制:

  1. undo log 多版本链:每次更新都会在 undo log 中产生一个历史版本
  2. 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
2
3
4
5
+----+---------+-------+-------------+-------------+
| id | balance | name | DB_TRX_ID | DB_ROLL_PTR |
+----+---------+-------+-------------+-------------+
| 1 | 100 | Alice | 100 | NULL |
+----+---------+-------+-------------+-------------+

接着事务 trx_id=200 执行 UPDATE account SET balance = 200 WHERE id = 1

1
2
3
4
5
6
7
8
9
10
11
12
13
聚簇索引当前版本:
+----+---------+-------+-------------+----------------------+
| id | balance | name | DB_TRX_ID | DB_ROLL_PTR |
+----+---------+-------+-------------+----------------------+
| 1 | 200 | Alice | 200 | → undo log 中的旧版本 |
+----+---------+-------+-------------+----------------------+

undo log 历史版本:
+----+---------+-------+-------------+-------------+
| id | balance | name | DB_TRX_ID | DB_ROLL_PTR |
+----+---------+-------+-------------+-------------+
| 1 | 100 | Alice | 100 | NULL |
+----+---------+-------+-------------+-------------+

再执行一次 UPDATE account SET balance = 300 WHERE id = 1(事务 trx_id=300):

1
2
3
聚簇索引当前版本(trx_id=300)→ 指向 undo log(trx_id=200 版本)
undo log(trx_id=200) → 指向 undo log(trx_id=100 版本)
undo log(trx_id=100) → NULL

这样形成了一条从最新到最旧的版本链

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
2
3
4
5
6
7
8
9
10
11
12
13
14
1. 如果 trx_id == creator_trx_id:
→ 可见(这是当前事务自己修改的版本)

2. 如果 trx_id < min_trx_id:
→ 可见(该版本由已提交的事务创建)

3. 如果 trx_id >= max_trx_id:
→ 不可见(该版本由将来才开始的事务创建)

4. 如果 min_trx_id <= trx_id < max_trx_id:
a. 如果 trx_id 在 m_ids 中:
→ 不可见(该版本由活跃但未提交的事务创建)
b. 如果 trx_id 不在 m_ids 中:
→ 可见(创建该版本的事务在 ReadView 创建前已提交)

如果当前版本不可见,就沿着 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
2
3
4
5
6
-- 会话 A:更新但未提交
USE mvcc_demo;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
UPDATE account SET balance = 999 WHERE id = 1;
-- 不提交
1
2
3
4
5
6
7
-- 会话 B:读取
USE mvcc_demo;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM account WHERE id = 1;
-- 输出:100(未读到脏数据 999)
COMMIT;
1
2
-- 会话 A:提交
COMMIT;
1
2
3
4
5
-- 会话 B:再次开启新事务读取
START TRANSACTION;
SELECT balance FROM account WHERE id = 1;
-- 输出:999(此时会话 A 已提交,可以看到)
COMMIT;

验证结果

1
2
第一轮读取(会话 A 未提交时):100
第二轮读取(会话 A 提交后) :999

实验 2:避免不可重复读(Non-Repeatable Read)

不可重复读是指同一事务内,两次读取同一行数据得到不同结果。

运行环境:MySQL 8.0,RR 隔离级别。

1
2
3
4
5
6
-- 会话 A:开启事务,先读取一次
USE mvcc_demo;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM account WHERE id = 1;
-- 第一次读取:999
1
2
3
4
5
-- 会话 B:修改并提交
USE mvcc_demo;
START TRANSACTION;
UPDATE account SET balance = 500 WHERE id = 1;
COMMIT;
1
2
3
4
-- 会话 A:再次读取
SELECT balance FROM account WHERE id = 1;
-- 第二次读取:999(与第一次一致,避免了不可重复读)
COMMIT;

输出结果

1
2
第一次读取:999
第二次读取:999 ← 即使在会话 B 提交后,会话 A 仍读到一致的数据

如果将会话 A 的隔离级别改为 READ COMMITTED:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 会话 A:RC 隔离级别下验证
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE id = 1; -- 第一次:500(此时 B 已提交)

-- 会话 B 再次修改
-- (在另一个连接执行)
START TRANSACTION;
UPDATE account SET balance = 700 WHERE id = 1;
COMMIT;

-- 会话 A 再次读取
SELECT balance FROM account WHERE id = 1; -- 第二次:700(与第一次不同)
COMMIT;

RC 下的输出

1
2
第一次读取:500
第二次读取:700 ← 产生了不可重复读

实验 3:RR 下的一致性读避免幻读(Phantom Read)

幻读是指同一事务内,两次查询范围内的行数不一致。在 RR 隔离级别下,快照读可以避免幻读。

1
2
3
4
5
6
-- 会话 A:开启事务,执行范围查询
USE mvcc_demo;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT COUNT(*) FROM account WHERE id BETWEEN 1 AND 10;
-- 结果:1
1
2
3
4
5
-- 会话 B:插入新行并提交
USE mvcc_demo;
START TRANSACTION;
INSERT INTO account VALUES (2, 50, 'Bob');
COMMIT;
1
2
3
4
-- 会话 A:再次执行同样的范围查询
SELECT COUNT(*) FROM account WHERE id BETWEEN 1 AND 10;
-- 结果:1(没有读到新插入的行,避免了幻读)
COMMIT;

输出结果

1
2
第一次 COUNT(*):1
第二次 COUNT(*):1 ← 新行不可见

注意:RR 下只有快照读能避免幻读。如果会话 A 在第二次查询时使用了当前读(如 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE),则会读到最新的已提交数据,包括新插入的行,产生幻读。这引出了下一个重要话题。

快照读 vs 当前读:MVCC 的边界

很多人对 MVCC 的理解止步于”RR 下读到的都是快照”,但实际上 InnoDB 中并非所有读操作都走 MVCC 一致性读。

类型 触发语句 是否走 MVCC 一致性读
快照读 普通 SELECT 是,通过 undo log + ReadView 获取可见版本
当前读 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETEINSERT 否,读取最新的已提交版本,并对读取到的行加锁

演示:FOR UPDATE 的阻塞行为

1
2
3
4
5
6
-- 会话 A:更新但未提交
USE mvcc_demo;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
UPDATE account SET balance = 200 WHERE id = 1;
-- 不提交
1
2
3
4
5
6
-- 会话 B:使用当前读
USE mvcc_demo;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
-- 此时会阻塞,等待会话 A 提交或回滚

在会话 B 阻塞期间,可以查看锁等待情况:

1
2
3
4
5
6
7
8
9
-- 会话 C:诊断锁等待
SELECT
waiting_trx_id,
waiting_pid,
waiting_query,
blocking_trx_id,
blocking_pid,
blocking_query
FROM sys.innodb_lock_waits;

输出示例

1
2
3
4
5
6
7
*************************** 1. row ***************************
waiting_trx_id: 281479558977896
waiting_pid: 12
waiting_query: SELECT balance FROM account WHERE id = 1 FOR UPDATE
blocking_trx_id: 281479558977544
blocking_pid: 11
blocking_query: UPDATE account SET balance = 200 WHERE id = 1

当一个事务中混合使用快照读和当前读时,最容易产生”诡异的读取现象”:同一事务内,快照读看到的是旧版本,当前读看到的是已提交的新版本。这正是很多线上数据不一致 bug 的根源

排查 MVCC 相关问题的步骤与工具

步骤 1:确认当前事务隔离级别

1
2
-- 查看全局和会话级隔离级别
SELECT @@global.transaction_isolation, @@session.transaction_isolation;

输出

1
2
3
4
5
+--------------------------------+---------------------------------+
| @@global.transaction_isolation | @@session.transaction_isolation |
+--------------------------------+---------------------------------+
| REPEATABLE-READ | REPEATABLE-READ |
+--------------------------------+---------------------------------+

步骤 2:查看当前活跃事务

1
2
3
4
5
6
7
8
9
10
11
-- 查看所有活跃事务的详细信息
SELECT
trx_id,
trx_state,
trx_started,
trx_requested_lock_id,
trx_wait_started,
trx_mysql_thread_id,
trx_query,
trx_isolation_level
FROM information_schema.innodb_trx\G

输出示例

1
2
3
4
5
6
7
8
9
*************************** 1. row ***************************
trx_id: 281479558977896
trx_state: LOCK WAIT
trx_started: 2026-09-02 08:04:00
trx_requested_lock_id: 281479558977896:0:4:1
trx_wait_started: 2026-09-02 08:04:20
trx_mysql_thread_id: 12
trx_query: SELECT balance FROM account WHERE id = 1 FOR UPDATE
trx_isolation_level: REPEATABLE READ

关键字段解读:

  • trx_stateRUNNING 表示正常执行中,LOCK WAIT 表示等待锁
  • trx_started:事务开始时间,可用来发现长事务
  • trx_query:当前正在执行的 SQL,如果是 NULL 表示事务空闲

步骤 3:定位长事务(undo log 堆积的元凶)

长事务会导致 undo log 无法被 purge,版本链越来越长,进而影响查询性能。

1
2
3
4
5
6
7
8
9
-- 查找运行时间超过 60 秒的事务
SELECT
trx_id,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

步骤 4:查看 undo log 使用情况

1
2
3
4
5
6
7
8
-- 查看 undo 表空间的状态
SELECT
tablespace_name,
file_name,
initial_size / 1024 / 1024 AS initial_mb,
autoextend_size / 1024 / 1024 AS autoextend_mb
FROM information_schema.innodb_tablespaces
WHERE tablespace_name LIKE 'undo%';

步骤 5:使用 SHOW ENGINE INNODB STATUS 诊断

1
SHOW ENGINE INNODB STATUS\G

在输出的 TRANSACTIONS 部分可以看到:

1
2
3
4
5
6
------------
TRANSACTIONS
------------
Trx id counter 281479558977897
Purge done for trx's n:o < 281479558977896 undo n:o < 0 state: running but idle
History list length 42

关键指标

  • History list length:待 purge 的 undo log 数量。如果持续增长且不下降,说明有长事务阻止了 purge
  • Purge done for trx's n:o:已清理到的事务 ID,如果这个值长期不增长,需要排查是否被某个事务卡住

实战排查案例:一个由长事务引起的诡异读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 场景:同事反馈"读到的数据不对,明明已经提交了"
-- 排查思路:

-- 1. 先看当前是否有长事务
SELECT trx_id, trx_started, TIMESTAMPDIFF(MINUTE, trx_started, NOW()) AS minutes,
trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

-- 2. 如果发现一个运行了 30 分钟的事务,查看它的连接状态
SELECT * FROM information_schema.processlist WHERE id = <trx_mysql_thread_id>;

-- 3. 检查这个事务是否在事务中间执行过 SELECT(快照读)
-- 如果是,那么在 RR 级别下,它后续所有的快照读都会基于那个旧的 ReadView
-- 即使其他事务已提交,它也看不到新数据

-- 4. 解决方案:
-- a. 让应用缩短事务时间,避免长事务
-- b. 如果业务允许,可考虑改用 READ COMMITTED 隔离级别
-- c. 在事务内需要最新数据时,使用当前读(FOR UPDATE / LOCK IN SHARE MODE)

避坑指南: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 之后,这些困惑会变成可预测、可解释的行为。

核心要点

  1. MVCC 的本质:通过 undo log 保存多版本数据,让读操作不加锁也能避免脏读、不可重复读和(快照读下的)幻读。

  2. 三个隐藏列:DB_TRX_ID(事务 ID)、DB_ROLL_PTR(回滚指针)、DB_ROW_ID(行 ID)是实现多版本链的基础。

  3. ReadView 判断规则:按 trx_id 与 min_trx_id、max_trx_id、m_ids 的关系判断可见性,不可见则沿版本链回溯。

  4. RR vs RC 的关键差异:RR 在事务第一次快照读时创建 ReadView 并复用;RC 每次快照读都创建新 ReadView。

  5. 快照读 vs 当前读:普通 SELECT 走 MVCC 一致性读(不加锁);FOR UPDATE、UPDATE、DELETE 走当前读(加锁,读最新已提交版本)。

  6. 长事务是 MVCC 的头号杀手:它会阻止 undo log 被 purge,导致版本链膨胀、性能下降。监控 History list length 和活跃事务时长是发现长事务的有效手段。

  7. 排查工具链information_schema.innodb_trxsys.innodb_lock_waitsSHOW ENGINE INNODB STATUSinformation_schema.processlist 是排查 MVCC 相关问题的四个核心工具。


本文由 Claude(Anthropic)辅助生成。代码示例已在 MySQL 8.0.36(InnoDB 引擎)中验证通过。验证日期:2026-09-02。