聊 InnoDB 事务,最绕不开的就是 undo log 和 MVCC 这对组合。很多同学背了一堆八股文,知道“MVCC 是多版本并发控制”“undo log 用来回滚”,但真被问到“一条 UPDATE 执行后版本链里多了什么”“ReadView 到底怎么判断可见性”,立刻卡壳。说白了,就是没把这两者的配合机制在脑子里跑通。
这篇文章我就用流程图加伪代码的方式,把 InnoDB 事务、undo log、MVCC 这三件事彻底拆开。适合正在准备 MySQL 面试的人、被线上事务问题折磨的后端开发,以及那些看完官方文档依然觉得隔了一层纱的同学。我尽量用大白话讲原理,再配一套可以在本地 MySQL 里直接跑的验证实验,让每一个判断规则都有据可查。
1. 先搞清楚 undo log 到底在解决什么问题
1.1 事务回滚只是 undo 的起点
很多人对 undo log 的第一印象就是“用来回滚的”。这个理解没错,但它只讲对了一小半。当你执行了一条 UPDATE,将某一行从旧值改成新值,InnoDB 会把旧值写成一条 undo log 记录。如果事务执行到一半需要回滚,InnoDB 就拿着这条 undo log 把数据改回去,恢复成事务开始前的样子。
但问题来了:如果 undo log 只是给回滚用的,那事务一旦提交,这些日志是不是就可以立刻删掉?答案是不行。因为在 MVCC 机制里,其他事务可能还需要通过 undo log 看到这个事务修改之前的旧版本数据。这就像家里装修拆了一面墙,拆下来的砖头不能马上扔,因为楼上邻居可能还要拿这些砖头确认原来的户型。
所以 undo log 的真实使命有两层:第一层是保证事务回滚,第二层是为 MVCC 提供历史版本。事务提交后,insert undo log 可以立即释放,但 update undo log 不能立刻清理,必须等所有可能用到它的 ReadView 都失效后,再由后台 purge 线程回收。
1.2 每一行记录里藏着版本链
要理解 undo log 如何支撑 MVCC,必须先从 InnoDB 表行的隐藏列说起。每一行物理记录上,除了你定义的业务字段,还有三个隐藏列,这是 InnoDB 自己维护的:
- DB_TRX_ID:最近一次修改这行记录的事务 ID。
- DB_ROLL_PTR:回滚指针,指向该记录上一个版本的 undo log 记录。
- DB_ROW_ID:行 ID,如果表没有显式主键,InnoDB 会用它作为聚簇索引。
DB_ROLL_PTR 非常关键。它把同一行数据的所有历史版本串成了一个链表,链表的头是最新值,往尾部走是越来越老的版本。每次 UPDATE 产生新值的时候,新值记录里的 DB_ROLL_PTR 指向上一个版本对应的 undo log,这个 undo log 里又保存着再上一个版本的指针。这串链条就是我们常说的“版本链”。
code复制最新值 (name='Bob', DB_TRX_ID=102, DB_ROLL_PTR=ptr2)
|
| ptr2 指向
v
undo log 2 (name='Alice', DB_TRX_ID=101, DB_ROLL_PTR=ptr1)
|
| ptr1 指向
v
undo log 1 (name='Tom', DB_TRX_ID=100, DB_ROLL_PTR=NULL)
这个结构看起来简单,但它就是 MVCC 的心脏。一个事务在读取数据的时候,不是简单地把最新值拿回来,而是沿着 DB_ROLL_PTR 一路往回找,直到找到那个“对当前事务可见”的版本。判断哪个版本可见,靠的就是 ReadView 机制,这正是下一节的核心内容。
code复制-- 查看事务当前状态和隐藏列(在 MySQL 8.0 中信息更丰富)
SHOW ENGINE INNODB STATUS\G
如果是在开发环境想快速造出版本链,可以用下面的表结构和初始化语句:
sql复制CREATE TABLE t_account (
id INT PRIMARY KEY,
name VARCHAR(32),
balance INT
) ENGINE = InnoDB;
INSERT INTO t_account VALUES (1, 'Tom', 1000);
这个表后面所有实验都用得上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ReadView:MVCC 的“时光机”是怎么工作的
2.1 ReadView 四要素
ReadView 本质上就是一个事务在某个时间点对数据库“全局快照”的描述。InnoDB 在生成 ReadView 的时候会记录四个重要的属性:
- m_ids:生成 ReadView 时当前系统中所有“活跃事务”(还没提交的事务)的 ID 列表。
- min_trx_id:m_ids 中的最小值。
- max_trx_id:生成 ReadView 时系统即将分配给下一个事务的 ID 值,注意不是当前最大事务 ID,而是“下一个要用的”。
- creator_trx_id:生成这个 ReadView 的事务自己的事务 ID。
这四个属性就是判断一个数据版本可见性的全部依据。可以把 ReadView 想象成一张入场券,上面标着“哪些人的操作我能看到,哪些人的我看不到”。这里有个很容易踩的误区:max_trx_id 不是当前最大事务 ID,而是下一个待分配 ID,也就是说所有事务 ID 大于等于 max_trx_id 的,当前 ReadView 完全不知道它们的存在。
还需要明确一点:不同隔离级别下,ReadView 的生成时机不同。REPEATABLE READ(可重复读)下,一个事务只在第一次执行快照读时生成 ReadView,之后整个事务都复用它,所以事务期间看到的数据是一致的。READ COMMITTED(读已提交)下,每条 SELECT 语句都会生成新的 ReadView,所以同一个事务里两次查询可能看到不同的数据。
2.2 可见性判断规则与伪代码
有了 ReadView,接下来判断某个数据版本是否可见就成了一套固定规则。假设当前事务拿到了 ReadView,现在要判断某个版本记录的 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 之间,则需要进一步看它是否在 m_ids 列表里:如果在,说明事务还没提交,不可见;如果不在,说明事务已经提交,可见。
这套规则用伪代码表达非常清晰,我习惯用类 Python 风格写,方便直接对应代码逻辑:
python复制def is_version_visible(trx_id, read_view):
# 当前事务自己产生的修改,必然可见
if trx_id == read_view.creator_trx_id:
return True
# 该版本事务早于 ReadView 创建时所有活跃事务,已提交,可见
if trx_id < read_view.min_trx_id:
return True
# 该版本事务在 ReadView 创建之后才开启,不可见
if trx_id >= read_view.max_trx_id:
return False
# trx_id 在 [min_trx_id, max_trx_id) 区间
# 如果还在活跃事务列表里,说明未提交,不可见
if trx_id in read_view.m_ids:
return False
# 不在活跃列表里,说明事务已提交,可见
return True
规则本身不难,真正难的是把它和版本链结合起来用。一次快照读的完整逻辑是:从版本链头部开始,逐个检查记录的 trx_id 是否可见,如果可见就返回这条记录的数据,如果不可见就顺着 DB_ROLL_PTR 往下找旧版本,直到找到可见版本或链表结束。
python复制def snapshot_read(head_record, read_view):
record = head_record
while record is not None:
if is_version_visible(record.trx_id, read_view):
return record.data
record = record.roll_pointer
return None # 找不到可见版本,返回空
这个 while 循环就是 MVCC 快照读的本质。在可重复读隔离级别下,同一个事务第一次 SELECT 生成的 ReadView 会被一直复用,所以后续所有查询看到的都是同一个快照;在读已提交隔离级别下,每条 SELECT 都会重新执行一次这个过程。这也解释了为什么可重复读能保证事务内多次查询结果一致,而读已提交不行。
3. 可视化流程:INSERT、UPDATE、SELECT 在 InnoDB 里走了一遍
3.1 INSERT 与 insert undo log 的处理
先看最简单的情况:INSERT 语句。执行 INSERT 时,InnoDB 会为这一行生成一个隐藏的 DB_TRX_ID,记录当前事务 ID,然后生成一条 insert undo log。这种 undo log 里保存的是插入行的主键值,类型上它属于“只用于回滚”的日志。
为什么 insert undo log 不参与 MVCC?因为这条记录刚插入,其他事务在生成 ReadView 的时候,如果这个插入事务还没提交,那么这条记录对其他事务就是完全不可见的。而如果插入事务已经提交,其他事务又能直接看到最新值。所以对 insert 这条记录来说,历史版本链条没有意义,不需要给其他事务提供“插入前的样子”。
这就带来一个性能优化点:insert undo log 在事务提交后可以立即清理,不需要等 purge 线程。因为它只服务于回滚,一旦事务提交,回滚需求消失,日志就可以释放。所以线上业务如果出现大量 INSERT,undo 膨胀的速度通常比大量 UPDATE 要慢得多。
INSERT 的流程可以画成下面这样:
code复制事务T101:
INSERT INTO t_account VALUES (1, 'Tom', 1000)
记录行 (1, 'Tom', 1000, DB_TRX_ID=101)
|
+-- DB_ROLL_PTR --> insert undo log (记录主键信息)
事务T101 COMMIT 之后:
insert undo log 直接标记可清理
事务T102 做快照读:
新 ReadView 判断 trx_id=101 已提交 -> 直接返回 (1, 'Tom', 1000)
3.2 UPDATE 时版本链如何生长
UPDATE 才是产生版本链的主力。执行 UPDATE 时,InnoDB 做的事情比大多数人想象的要多:
- 将当前数据行的旧值写入一条 update undo log。
- 新数据行的 DB_TRX_ID 改成当前事务 ID。
- 新数据行的 DB_ROLL_PTR 指向刚才写入的 undo log 记录。
- 该 undo log 记录里保存了旧行的 DB_TRX_ID 和更早的 DB_ROLL_PTR,这样版本链才能串起来。
也就是说,每次 UPDATE 都会让版本链增加一个节点。假设表里一开始有一条记录 (1, 'Tom', 1000),事务 A(ID=100)将 name 从 Tom 改成 Alice,事务 B(ID=101)再把 name 从 Alice 改成 Bob,最终版本链就变成了之前画的那个结构。
code复制初始: (1, 'Tom', 1000, trx_id=99, roll_ptr=null)
|
事务A: UPDATE name='Alice'
v
最新: (1, 'Alice', 1000, trx_id=100, roll_ptr=ptr2)
|
v
undo: (1, 'Tom', 1000, trx_id=99, roll_ptr=null)
这里有一个很多人没注意的细节:UPDATE 在 InnoDB 内部可能被拆分成 DELETE + INSERT,或者说,InnoDB 会同时写 delete mark 相关的 undo 和 insert 相关的 undo。从 MVCC 的角度看,旧版本通过 delete mark 标记,新版本通过新插入的记录承载,但版本链依然由 DB_ROLL_PTR 串联。对于理解原理来说,我们只需要记住“每次修改都会往版本链头插入一个新版本”这个结论。
code复制事务A (ID=100):
UPDATE t_account SET name='Alice' WHERE id=1;
事务B (ID=101):
UPDATE t_account SET name='Bob' WHERE id=1;
版本链最终形态:
(1, 'Bob', 1000, trx_id=101, roll_ptr=ptrB)
|
v
(1, 'Alice', 1000, trx_id=100, roll_ptr=ptrA)
|
v
(1, 'Tom', 1000, trx_id=99, roll_ptr=null)
这条链就是 MVCC 的数据基础。事务再大、嵌套再深,最终落到磁盘上,就是这一串由 roll_pointer 串起来的版本队列。
3.3 SELECT 如何沿版本链找到可见版本
现在看 SELECT 怎么处理版本链。假设现在有三个事务:
- 事务T100:UPDATE 将 Tom 改为 Alice,未提交。
- 事务T101:执行 SELECT,生成 ReadView。
- 事务T102:UPDATE 将 Alice 改为 Bob,未提交或已提交。
当事务 T101 执行 SELECT 时,生成的 ReadView 是:m_ids = [100, 102](假设 100 和 102 都还活跃),min_trx_id = 100,max_trx_id = 103,creator_trx_id = 101。然后从版本链头开始找:
- 查到最新记录 trx_id=102,大于 min_trx_id,且在 m_ids 中,未提交,不可见。
- 顺着 DB_ROLL_PTR 找到第二条记录 trx_id=100,也在 m_ids 中,未提交,不可见。
- 再往下找,trx_id=99,小于 min_trx_id,已提交,可见。
- 返回 (1, 'Tom', 1000)。
这个过程用流程图表达:
code复制SELECT * FROM t_account WHERE id=1;
当前版本链:
head --> (Bob, trx_id=102)
|
v
(Alice, trx_id=100)
|
v
(Tom, trx_id=99)
ReadView: m_ids=[100,102], min_trx_id=100,
max_trx_id=103, creator_trx_id=101
判断:
trx_id=102: 在 m_ids 中, 不可见 --> 往下
trx_id=100: 在 m_ids 中, 不可见 --> 往下
trx_id=99: 小于 min_trx_id, 可见 --> 返回 'Tom'
这个流程也解释了为什么可重复读能够在一个事务内看到一致的快照:因为 ReadView 是复用的,两次 SELECT 都从版本链头开始找,判断规则完全一致,结果自然一致。而读已提交下,每条 SELECT 会生成新的 ReadView,把已提交事务从 m_ids 里去掉,所以可能看到更新的数据。
4. 实操验证:两个会话复现 MVCC 全过程
4.1 准备环境与数据
原理讲再多,不如亲手验证一遍。下面这套实验在任何 MySQL 8.0 环境都能跑,只需要两个终端会话。先确认隔离级别,然后建表造数据:
sql复制-- 查看当前隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
-- 确保使用可重复读
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 建表(如果之前建过就忽略)
CREATE TABLE IF NOT EXISTS t_account (
id INT PRIMARY KEY,
name VARCHAR(32),
balance INT
) ENGINE = InnoDB;
INSERT INTO t_account VALUES (1, 'Tom', 1000);
为了更清楚地观察事务 ID 和锁信息,建议开一个通用日志或者用 performance_schema 监控,不过最简单的方式是直接查 information_schema.innodb_trx 表:
sql复制SELECT trx_id, trx_state, trx_started
FROM information_schema.innodb_trx;
注意,MySQL 8.0 里事务 ID 需要在事务真正执行写操作后才会分配,所以光 BEGIN 是看不到 trx_id 的,必须先执行一条 SELECT ... FOR UPDATE 或 UPDATE。
4.2 RR 下一个完整实验
现在开两个会话,按下面的顺序操作:
会话A:
sql复制BEGIN;
UPDATE t_account SET name = 'Alice' WHERE id = 1;
此时会话A修改了数据但未提交,版本链上就多了一个新版本 (1, 'Alice', 1000, trx_id=100)。
会话B:
sql复制BEGIN;
SELECT name FROM t_account WHERE id = 1;
按 MVCC 规则,会话B的 SELECT 会生成 ReadView,m_ids 里包含事务 100,所以看不到 'Alice',返回 'Tom'。
接着回会话A提交:
sql复制COMMIT;
再回到会话B执行同样的查询:
sql复制SELECT name FROM t_account WHERE id = 1;
在 REPEATABLE READ 下,你依然会看到 'Tom'。因为会话B的 ReadView 在第一次 SELECT 时已经生成并被复用,即使事务 100 已经提交,ReadView 的 m_ids 还保留着它。
这个实验可以直观地验证快照读的“一致性”效果。如果把会话B的隔离级别改成 READ COMMITTED,再执行一次同样操作,第二次 SELECT 就会返回 'Alice',因为每条语句都会重新生成 ReadView。
code复制操作时间线:
会话A 会话B
BEGIN
UPDATE name='Alice'
BEGIN
SELECT -> 'Tom'
COMMIT
SELECT -> 'Tom' (RR)
SELECT -> 'Alice' (RC)
4.3 改用 RC 再看差异
把隔离级别切换成 READ COMMITTED 后,重跑上面的实验,你会在最后一步看到不同结果:
sql复制-- 会话B执行
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT name FROM t_account WHERE id = 1; -- 'Tom'
-- 会话A提交后
SELECT name FROM t_account WHERE id = 1; -- 'Alice'
原因就在于 RC 的 ReadView 生成策略和 RR 不同。RC 是语句级快照,每次 SELECT 都创建一个新的 ReadView,新 ReadView 的 m_ids 里已经不含事务 100,min_trx_id 也会更新,所以能看到最新已提交数据。RR 是事务级快照,整个事务只创建一次 ReadView,后续全部复用。
这个差异直接影响了业务行为。比如在分账系统里,同一个事务内要多次读取账户余额,RR 能保证读取到的金额一致;如果误用 RC,同一个事务里两次读取可能拿到不同金额,就会导致统计对不上。反过来,如果业务需要看到最新数据,又不想用当前读,RC 反而更合适。所以隔离级别的选择必须结合业务场景。
5. 常见问题与排查实录
5.1 undo 膨胀导致磁盘暴涨
接触过线上问题的人应该都遇到过这个场景:某个大事务运行了几个小时,undo 表空间从几百 MB 涨到几十 GB,最后磁盘告警。根本原因就是长事务持有旧 ReadView 不释放,导致 undo log 无法被 purge 清理。
排查步骤如下:
sql复制-- 查看是否有长事务
SELECT *
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 60 SECOND;
-- 查看 history list length,值越大说明未清理的 undo 越多
SHOW ENGINE INNODB STATUS\G
在 SHOW ENGINE INNODB STATUS 输出里找 “History list length” 这一项。正常情况下这个值很低,持续增长说明有长事务或者 read view 没有被释放。
最常见的根因有两类:一是业务代码里开启了事务后,在事务中做了耗时极长的外部调用,比如 RPC、HTTP,甚至用户输入等待;二是连接池里存在长期不归还的连接,并且这个连接执行过 SELECT,形成了一个活着的快照。第二类尤其隐蔽,很多开发以为 SELECT 不会产生问题,但在 RR 下,一次普通 SELECT 也会让 ReadView 保持到事务结束,如果事务一直不提交,undo 就永远清不掉。
规避方案:
- 事务内禁止做任何网络调用、等待操作。
- 大事务拆小,分批提交。
- 短事务优先,避免同一连接长时间挂着事务。
- 监控 innodb_trx 表,对超过阈值的会话主动报警。
5.2 长事务拖垮性能
除了磁盘,长事务还会引发另一个连锁问题:历史版本越来越长,每次 SELECT 的版本链遍历越来越深,CPU 消耗上升。更要命的是,长事务持有的 ReadView 会导致大量旧版本不能被 purge,间接让其他事务产生更多 undo,形成恶性循环。
我排查过一个问题:某个报表服务每天固定时间出现 CPU 100%,持续时间二十分钟,然后自行恢复。查了很久才发现,是另一个后台任务在每天固定时间启动一个长事务,大量 UPDATE 后迟迟不提交,导致报表服务的普通 SELECT 从版本链头一路扫到底,每条记录都要判断若干次可见性,性能自然雪崩。
解决方案是给业务层加事务超时提醒,同时 DBA 监控层面设置长事务告警阈值。对于已经存在的问题,优先确认是什么操作长时间占用了事务,再考虑 kill 会话释放资源。
5.3 MVCC 高频面试题与回答思路
这块内容面试几乎必考,总结了几个典型问题:
Q1:可重复读能防幻读吗?
回答思路:快照读可以,当前读不行。MVCC 保证在 RR 隔离级别下,同一事务内多次快照读结果一致。但如果用 SELECT ... FOR UPDATE 或 UPDATE 这类当前读,InnoDB 需要借助 next-key lock 防止幻读。所以面试时说“RR 靠 MVCC + next-key lock 解决幻读”更准确。
Q2:RU(读未提交)为什么会有脏读?MVCC 为什么不能解决脏读?
回答思路:读未提交下不需要 ReadView,直接读最新版本,所以可能读到未提交数据。MVCC 的 ReadView 机制本质上是为了隔离已提交和未提交事务的影响,但 RU 没有使用这套机制,所以出现脏读。
Q3:undo log 为什么不能像 redo log 那样直接 IO 优化?
回答思路:redo log 是物理逻辑日志,主要用于崩溃恢复,必须尽快落盘;undo log 是逻辑日志,记录的是反向操作和历史版本,服务的是回滚和 MVCC。前者需要持久性,后者有复杂的生命周期管理,清理时机依赖 purge 线程和活跃事务状态,没法简单套用 redo 的写入策略。
Q4:RR 下,同一个事务先 SELECT 再 UPDATE,能修改到其他事务已提交的数据吗?
回答思路:如果 UPDATE 的条件列在 RR 下走的是当前读,它会读取最新已提交版本,而不是快照版本。所以即使前面的 SELECT 看到的是旧值,UPDATE 也可能基于新值操作。这也解释了为什么 UPDATE 会造成“你看到的数据和你改的数据不一致”的错觉。
Q5:分布式事务里 MVCC 还有用吗?
回答思路:MVCC 解决的是单库多事务并发读写可见性,分布式事务更多关注跨库一致性和提交协调。两者解决的问题域不同,但底层数据库仍然需要 MVCC 来降低本地事务的读写阻塞。所以分布式事务场景下,理解 MVCC 同样重要。
Q6:purge 线程到底做了什么?
回答思路:purge 线程负责清理两类数据:已提交事务的 insert undo log,以及被删除记录的历史版本(delete mark)。它需要保证清理时没有任何活跃 ReadView 引用这些旧版本,所以它根据系统中最早活跃 ReadView 的 m_ids 来判断哪些历史版本可以安全回收。
最后分享一个我自己在排查事务问题时的习惯:遇到奇怪的数据不一致现象,先不要急着改代码,而是把两个会话的隔离级别、事务开始时间、innodb_trx 里的活跃事务列表都拉出来看一眼,再用本文的伪代码逻辑把版本链推演一遍。大多数 MVCC 相关问题,只要把 ReadView 的生成时机和 m_ids 集合搞清楚,都能在纸上找到答案。这套方法我用了很久,每次都能快速定位问题,你也可以试试。
