InnoDB 事务 undo log 与 MVCC 可视化讲解(画流程图+伪代码)
InnoDB 的 undo log 和 MVCC,这俩概念就像一对双胞胎,看着有关联,但真要你讲清楚谁是谁,很多人就开始绕了。我在面试候选人的时候,十个里面有八个能说出“MVCC 是靠 undo log 实现的”,但再往下问一句“怎么实现的?ReadView 是怎么用 undo log 判断可见性的?”就答不出来了。
这篇我打算换个讲法,不直接甩概念,而是从一条 UPDATE 语句的完整旅程入手,把行数据在磁盘里怎么变、undo log 怎么记录旧版本、ReadView 怎么借助版本链判断哪个版本可见,一路画出来。全程配流程图和伪代码,争取你看完就能自己手撕这道面试题,也能在项目里真的去排查长事务、大版本链的问题。
1. 整体设计与思路拆解:为什么这三件事必须放在一起学
先说个我观察到的现象。很多人学 InnoDB 事务,是分开学的:先学 ACID,再学事务隔离级别,再单独背 undo log 的定义,再单独背 MVCC 的定义。结果就是,每个名词都知道,但一旦遇到“为什么 RR 级别下,事务开始后第一次查询和第二次查询结果一样”这种综合题,就傻眼了。
1.1 核心难点:undo log 不是“日志”那么简单
大多数人对 undo log 的理解停留在“回滚日志”这层:事务出错了,用来把数据恢复原样。这个理解没错,但只覆盖了 undo log 的一半功能。它的另一半,是给 MVCC 提供“历史版本数据”。
举个例子,你把一行数据的 age 从 18 改成了 28。这时候 undo log 里会记录“这一行之前长什么样”。如果这时候有另一个事务来读这行数据,它不能直接读当前值 28,因为可能在隔离级别规则下它看不到未提交的修改。那怎么办?它就顺着 undo log 往前翻,找到自己“可见”的那个版本,可能是 18,也可能是更早的值。
所以,undo log 在这里的作用,不是“回滚”,而是“回溯”。它像一个时光机,让每个事务都能看到自己该看的时间点。
1.2 可视化讲解的核心思路:一条 SQL 的旅程
我这次的设计思路,是抛开教科书式的定义,用“一条 UPDATE 语句的完整旅程”把整个链路串起来。这条 SQL 会经历以下节点:
- 事务开始,获得事务 ID
- 执行 UPDATE,修改缓冲池中的数据页
- 生成 undo log,记录修改前的数据
- 更新行上的隐藏列(DB_TRX_ID、DB_ROLL_PTR)
- 其他事务查询时,生成 ReadView,遍历版本链判断可见性
你看,这一条链路走完,事务、undo log、MVCC 全齐了。比分开学效率高得多。
1.3 为什么还要配伪代码
说实话,纯文字讲这个过程,读者很容易在版本链的跳转上晕掉。配伪代码是为了把“判断可见性”这个逻辑变成一步步的流程,你可以拿着它去比对自己的理解,甚至可以照着写一个简化版的 MVCC 判断逻辑。
注意:文中的伪代码是教学简化版,和 InnoDB 真正的 C++ 实现有差异,但逻辑框架是吻合的。目的不是复刻源码,而是让你在脑子里建立一个正确的执行模型。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:undo log 到底在硬盘里存了什么
在讨论 MVCC 之前,必须先弄清楚两个底层结构:行上的隐藏列,和 undo log 本身的内容格式。这俩是版本链的两个端点。
2.1 行格式里的三个隐藏列:版本链的锚点
InnoDB 的表数据,聚簇索引(主键索引)的每一行记录里,除了你定义的字段,还带了几个隐藏列。其中三个和 MVCC 强相关:
- DB_TRX_ID(6字节):记录最近一次修改这一行的事务 ID。不管你执行的是 UPDATE 还是 DELETE,InnoDB 内部都认为是“修改”,这个值都会被更新。
- DB_ROLL_PTR(7字节):回滚指针,指向该行上一个版本在 undo log 中的位置。这是版本链的“链子”。
- DB_ROW_ID(6字节):如果表没有显式主键,InnoDB 会用它作为隐藏主键,和 MVCC 关系不大。
这就有意思了。你表面看到的是一行数据,实际上在 InnoDB 里,它是多个版本串成的一条链。每次 UPDATE 或者 DELETE,不是直接改掉原来的行,而是在原行的基础上生成一个新版本,并把新版本的 DB_ROLL_PTR 指向上一个版本。
2.2 undo log 的类型与内容结构
undo log 分为两大类:
| 类型 | 触发操作 | 记录内容 | 用途 |
|---|---|---|---|
| insert undo log | INSERT | 记录插入的主键值 | 事务回滚时删除该行;事务提交后直接可清理 |
| update undo log | UPDATE / DELETE | 记录被修改列的旧值、旧事务ID、旧回滚指针等 | 事务回滚 + MVCC 版本回溯 |
注意一个细节:DELETE 在 InnoDB 里并不真的物理删除。它会把这行标记为“已删除”,同时在 undo log 里记录删除前的完整行数据。真正物理删除的动作,交给后台 purge 线程异步完成。这也是 MVCC 能读到旧版本的关键——在 purge 之前,历史版本都还在 undo log 里躺着。
2.3 undo log 是物理日志还是逻辑日志
很多人纠结这个问题。undo log 严格来说,介于两者之间。InnoDB 是行级存储引擎,undo log 记录的核心内容,是一条行记录的“变化前状态”。所以它不是像 redo log 那样记录“哪个页的哪个偏移量改成了什么”,而是记录“这一行有哪些列,值是多少,事务ID是什么,回滚指针指向哪”。
这个设计是故意的。因为 undo log 要同时服务两个诉求:
- 回滚时要能恢复出一整行数据,所以必须记录完整旧值
- MVCC 遍历时要快速拿到旧版本,所以必须存着事务ID和回滚指针
2.4 版本链到底是怎么串起来的
用一张图描述版本链的形态:
- 最新版本(当前值):DB_TRX_ID=100,DB_ROLL_PTR=undo_3
- 记录内容:age=28,name=张三
- 上一个版本:DB_TRX_ID=90,DB_ROLL_PTR=undo_2
- 记录内容:(之前的值)age=18,name=张三
- 再上一个版本:DB_TRX_ID=80,DB_ROLL_PTR=undo_1
- 记录内容:(最初的值)age=10,name=张三
最新版本在聚簇索引的数据页里,旧版本通过 DB_ROLL_PTR 一层层指回去,最终形成一个链表。这个链表的每个节点,就是 undo log 里的一条记录。
实操心得:在排查问题的时候,如果你怀疑某张表的版本链太长(典型症状是查询变慢、undo 表空间暴涨),可以直接去查 performance_schema 里的 data_lock_waits 和 innodb_trx,找到长时间不提交的事务。版本链太长,十有八九是有只“乌龟事务”一直开着,导致 purge 线程没法清理历史版本。
3. 核心细节解析:MVCC 是如何利用版本链判断可见性的
MVCC 全称是 Multi-Version Concurrency Control,多版本并发控制。这个词的核心是“多版本”,也就是我上面讲的版本链。但只有版本链还不够,还需要一把“尺子”去量每个版本是否可见。这把尺子就是 ReadView。
3.1 ReadView 的四个关键字段
ReadView 是事务执行快照读(普通的 SELECT)时生成的一个“视图快照”,它主要记录四个信息:
- m_ids:生成 ReadView 时,当前系统中所有“活跃”的读写事务 ID 列表
- min_trx_id:m_ids 中最小的那个事务 ID
- max_trx_id:生成 ReadView 时,系统要分配给下一个事务的 ID,注意这个值不是“当前最大事务ID+1”,而是“下一个待分配的事务ID”
- creator_trx_id:生成这个 ReadView 的事务自己的 ID
这四个字段,规则背后的核心问题就一个:当前这条记录的版本事务ID,对我这个查询来说,是不是“已经提交且可见”的?
3.2 可见性判断规则
拿到某个版本的事务 ID(记作 trx_id)后,判断逻辑按顺序走:
text复制规则一:如果 trx_id == creator_trx_id
-> 这个版本是当前事务自己改的,可见
规则二:如果 trx_id < min_trx_id
-> 这个版本在 ReadView 生成前就已经提交了,可见
规则三:如果 trx_id >= max_trx_id
-> 这个版本是 ReadView 生成后,才开启的事务生成的,不可见
规则四:如果 min_trx_id <= trx_id < max_trx_id
-> 分两种情况:
a) trx_id 在 m_ids 中:事务还没提交,不可见
b) trx_id 不在 m_ids 中:事务已提交,可见
这套规则看着复杂,本质就一句话:只看“生成 ReadView 的那个瞬间”,哪些事务是活跃的,哪些是已提交的。活跃事务的修改,我一律不看;已提交事务的修改,我一律可见;我自己改的,永远可见。
3.3 典型场景演示:RC 与 RR 的差异
这套逻辑在 READ COMMITTED(RC)和 REPEATABLE READ(RR)两种隔离级别下,最大的区别是 ReadView 生成的时机:
- RC 级别:每次执行 SELECT 都会生成一个新的 ReadView
- RR 级别:事务里第一次执行 SELECT 时生成 ReadView,之后整个事务都复用这一个 ReadView
这就是为什么 RR 能解决“不可重复读”——同一个事务里,第二次 SELECT 用的还是第一次的 ReadView,后面事务提交的新数据,对我这个 ReadView 来说是不可见的。
而 RC 级别下,同一个事务里第二次 SELECT 会生成新的 ReadView,之前刚提交的事务,这次就变成“已提交”了,所以能看到新的值。这也就是“不可重复读”现象的本质。
注意:这里说的 ReadView 机制,只针对普通的快照读(SELECT)。对于“当前读”(SELECT ... FOR UPDATE、UPDATE、DELETE),走的是另一套逻辑,它们永远读最新版本并加锁,所以不受 ReadView 影响,这也是很多人在 RR 级别下做并发控制时踩坑的地方。
4. 实操过程与核心环节实现:从伪代码到真实 SQL
理论讲完,来点能动手的。这一节我用两个维度的伪代码把 MVCC 的核心逻辑写出来,再配合真实的 SQL 演示,让你在本地也能复现整个流程。
4.1 伪代码一:版本链遍历与可见性判断
下面这段伪代码,模拟了 InnoDB 在执行快照读时,如何从当前版本开始,沿着 DB_ROLL_PTR 往前找,直到找到第一个可见的版本:
python复制def get_visible_version(current_row, read_view):
version = current_row
while version is not None:
trx_id = version.DBX_TRX_ID
if trx_id == read_view.creator_trx_id:
# 自己改的,直接返回
return version
if trx_id < read_view.min_trx_id:
# 事务在生成快照前已提交,可见
return version
if trx_id >= read_view.max_trx_id:
# 事务在生成快照后才开始,不可见,继续往前找
version = version.prev_version
continue
if trx_id not in read_view.m_ids:
# 事务在生成快照前已提交,可见
return version
# 事务在快照生成时仍活跃,不可见,继续往前找
version = version.prev_version
# 所有版本都不可见,说明这一行对当前事务来说不存在
return None
这也就是为什么版本链会存在:一个查询可能要从最新版本一直往前翻好几跳,才能找到对应可见的版本。极端情况下,一条数据被改了 100 次、有 100 个版本,且前面 99 个版本都不可见,那这个查询就要在 undo log 里做 100 次回溯扫描。
4.2 伪代码二:UPDATE 时生成 undo log 的过程
再来模拟一下 UPDATE 语句执行时,undo log 和版本链是怎么更新的:
python复制def execute_update(row, new_values, trx_id, undo_allocator):
# 1. 生成 undo log,记录该行当前所有列的旧值
undo_record = {
'table_id': row.table_id,
'old_values': row.current_values,
'old_trx_id': row.DBX_TRX_ID,
'old_roll_ptr': row.DB_ROLL_PTR
}
# 2. 把 undo record 写入 undo log 段,并获取位置
undo_ptr = undo_allocator.write(undo_record)
# 3. 更新聚簇索引中该行的当前值
row.current_values = new_values
# 4. 更新隐藏列
row.DBX_TRX_ID = trx_id
row.DB_ROLL_PTR = undo_ptr
注意最后一步,新版本的 DB_ROLL_PTR 指向的,是刚才写入的 undo_record。而 undo_record 里的 old_roll_ptr,又指向上一个版本。这样,版本链就串起来了。
4.3 动手实践:本地复现 MVCC 判断过程
为了让你直观感受这个机制,我建议你在本地 MySQL 里跑一遍下面这个实验:
sql复制-- 准备数据
CREATE TABLE t_user (
id INT PRIMARY KEY,
age INT
) ENGINE=InnoDB;
INSERT INTO t_user VALUES (1, 10);
打开两个终端,分别执行:
sql复制-- 终端A:事务T1,先不要提交
BEGIN;
UPDATE t_user SET age = 18 WHERE id = 1;
这时在终端B查询:
sql复制-- 终端B:事务T2,在RR级别下查询
BEGIN;
SELECT age FROM t_user WHERE id = 1;
-- 结果:10
终端B看到的是 10,而不是 18。因为在终端B的事务里,第一次执行 SELECT 时生成了 ReadView,那时候 T1 是活跃事务,所以 T1 修改后的版本不可见。顺着版本链往前翻,看到了 T1 修改前的旧版本 10。
这时候你把终端A的事务提交,回到终端B再查一次:
sql复制SELECT age FROM t_user WHERE id = 1;
-- RR级别下:仍然返回 10
原因就是:终端B的事务复用第一次生成的 ReadView。虽然 T1 已经提交,但对这个 ReadView 来说,T1 在快照生成时是活跃的,依旧不可见。
4.4 一条 UPDATE 的完整执行流程串讲
把所有步骤组装起来,一条 UPDATE 语句在 InnoDB 里大概是这样一个流程:
- 获取事务 ID,写入当前会话的事务上下文
- 加行锁(或者间隙锁、next-key lock,取决于隔离级别和查询条件)
- 在缓冲池(Buffer Pool)中定位目标数据页。如果不在内存,先从磁盘读取
- 在数据页中找到目标行,生成一条 update undo log,记录旧值、旧事务ID、旧回滚指针
- 修改该行数据,更新 DB_TRX_ID 为当前事务ID,更新 DB_ROLL_PTR 指向刚写入的 undo log
- 写入 redo log,保证崩溃后恢复。此时对数据的修改标记为“未提交”
- 事务提交时,把 undo log 加入 purge 列表,等待后台线程最终清理
每一步都有对应的数据结构和写入时机。理解了这一步,你对“事务提交后为什么还有 undo log”这个问题,应该就不会再困惑了——因为它还要服务其他事务的 MVCC 读取。
5. 常见问题与排查技巧实录:实战中的坑与解法
这块是实践环节最容易出问题的地方。我把自己在实际项目里碰到的、以及帮读者排查过的经典问题整理出来,给你做一份速查表。
5.1 面试必问:MVCC 到底解决了什么问题,哪些问题不归它管
MVCC 解决的是“读-写并发”下的阻塞问题。在没有 MVCC 之前,读要等写完成,写要等读完成。有了 MVCC,快照读可以在不加锁的情况下读到历史版本,写操作不会被读操作阻塞。这也是 MySQL 能支撑高并发读的核心原因。
但要注意,MVCC 不解决“写-写冲突”。两个事务同时修改同一行,还是靠锁来解决。MVCC 也不解决死锁,死锁要靠事务回滚和重试机制。
面试加分项:如果面试官问你“MVCC 是不是一定不会产生幻读”,别急着回答。在 RR 级别下,快照读靠着 ReadView 复用,确实不会产生幻读;但当前读(SELECT ... FOR UPDATE)在 RR 级别下,如果没有配合间隙锁,仍然可能产生幻读。所以 MySQL 的 RR 级别能防幻读,靠的是 MVCC + 间隙锁的组合拳。
5.2 长事务导致的历史版本堆积
这是最影响线上性能的问题。事务长时间不提交,意味着它的 ReadView 一直存活,那么早于这条 ReadView 生成时刻的历史版本,purge 线程统统不能清理。结果就是:
- undo log 表空间持续膨胀
- 版本链越来越长,查询要回溯的节点越来越多
- 极端情况下,buffer pool 被脏页和旧版本数据占满,内存压力上升
排查手段很简单:
sql复制-- 查看当前所有事务的运行状态
SELECT * FROM information_schema.innodb_trx\G
-- 重点关注 trx_started、trx_state、trx_rows_modified
-- trx_started 很早但 trx_state 还是 RUNNING 的事务,就是重点排查对象
发现长事务后,优先确认它是否真的需要那么久,如果只是代码里忘了提交,立刻手动提交或回滚。
5.3 半一致性读:一个容易被忽略的优化
这个特性可能很多人都没听说过。它出现在 UPDATE 语句执行时,如果 UPDATE 发现目标行被其他事务锁定,InnoDB 会尝试读一下这个行的旧版本,判断这个行是否真的满足 UPDATE 的 WHERE 条件。如果旧版本不满足条件,就跳过这一行,不继续等待锁。
这个设计的目的是减少锁等待。但要警惕:在 READ COMMITTED 级别下,这个优化可能导致 UPDATE 影响行数出现“你意想不到”的结果。RR 级别下,由于实现机制不同,半一致性读不会生效。说白了,就是不同隔离级别对 UPDATE 的行为有微妙差异,线上切换隔离级别前,最好在测试环境先压一遍。
5.4 伪代码跑了,但为什么线上还那么慢
如果你在代码里跑过一次版本链遍历,你会发现单次判断的复杂度并不高。但线上慢,通常不是单次遍历的问题,而是并发量太大、每条 SQL 都要遍历多次、undo log 还在磁盘上,这种叠加效应会让延迟指数级上升。
优化方向就三个:
- 减少长事务,缩短版本链
- 让常用查询走覆盖索引,避免回表触发不必要的版本链回溯
- 控制单行数据的更新频率,不要把一张表当成“计数器”来刷
5.5 问题速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| undo 表空间持续增大 | 有长事务持有 ReadView | 查 innodb_trx,定位 RUNNING 时间很久的事务 |
| 快照读结果“不一致” | RC/RR 隔离级别下 ReadView 生成时机不同 | 确认当前会话隔离级别,理解 RC 每次 SELECT 都新建 ReadView |
| UPDATE 被锁阻塞 | 写-写冲突,与 MVCC 无关 | 检查锁等待,考虑优化业务逻辑减少并发更新同一行 |
| 查询走全表扫描但很慢 | 版本链过长 | 检查是否有高频 UPDATE 单行数据 + 长事务组合 |
| 事务提交后 SQL 查不到旧值 | purge 线程已清理历史版本 | 正常现象,MVCC 只保证在事务活跃期间版本可追溯 |
以后怎么继续深入
这次讲的,是整个 InnoDB 事务和 MVCC 最核心的骨架。你把这条链路走顺之后,再去看 redo log、binlog、purge 线程、间隙锁这些概念,会发现它们都能挂靠在这条主线上。我个人建议的下一步是:去研究一下 redo log 和 binlog 的两阶段提交,那时候你就明白“事务持久性”和“一致性”到底是怎么落地的。再往后,可以试试根据这篇文章里的伪代码,自己写一个简化版的 MVCC 判断引擎,写完之后你对“版本链”这三个字的理解,绝对会上一个台阶。
