【InnoDB磁盘结构3】撤销表空间,Undo日志
目录
一. 撤销表空间 - Undo Tablespaces
1.1.撤销表空间的作用?
1.2 在使用 MySQL 时并没有手动创建撤销表空间,它是什么时候被创建的?
1.3 可以手动创建撤销表空间吗?
1.4 如何删除撤销表空间?
1.5 如何查看撤销表空间的状态?
二. 撤销日志(Undo Log)
2.1.什么是撤销日志?撤销日志的作用是啥?什么时候写撤销日志?
2.2.撤销⽇志在撤销表空间中的组织形式是怎样的?
2.3.撤销⽇志的格式是怎样的?
2.4 撤销⽇志是如何组织在⼀起的?
2.4.1.UNDO PAGE HEADER (撤销页头)
2.4.2.UNDO LOG SEGMENT HEADER (撤销日志段头)
2.4.3.UNDO LOG HEADER (撤销日志头)
2.5 撤销日志如何分类?
2.6 InnoDB最大支持并发读写事务的数量如何计算?
2.7 如何理解Undo链?
2.8 撤销日志为什么需要落盘?
一. 撤销表空间 - Undo Tablespaces
在磁盘结构里面,撤销表空间在哪里呢?
如果大家想了解更多,可以去官网:MySQL :: MySQL 8.0 Reference Manual :: 17.6.3.4 Undo Tablespaces
1.1.撤销表空间的作用?
想象一个大型图书馆(MySQL 数据库)
核心任务:记录每一次“书”的修改历史
图书馆里有很多珍贵的书籍(你的数据表)。图书管理员(InnoDB 存储引擎)最重要的工作之一就是保证这些书的完整性和借阅秩序。
当有人(一个事务)要借走一本书修改(UPDATE
)、撕掉几页(DELETE
)或者放入一本新书(INSERT
)时,管理员不会让他直接在原书上乱涂乱画!这太危险了。
管理员会做一件关键的事情:在动手修改原书之前,先快速、详细地记录下这本书当前要修改部分的样子(比如要改的那一页的原文是什么,要撕掉的那几页内容是什么,要放新书的那个位置原来有没有书)。管理员会把这些记录写在一张张专用的 “修改记录卡” 上。
撤销日志 (Undo Logs) 就是这些“修改记录卡”!
-
内容: 它精确记录了每一行数据在被事务修改前的原始状态(旧值)。修改一行就记录该行的旧值;删除一行就记录整行的旧值;插入一行就记录一个标记(表示回滚时要删除它)。
-
形式: 它们是逻辑上的记录,就像一张张写满了修改前信息的卡片。
撤销表空间 (undo_001, undo_002等) 就是专门用来存放所有这些“修改记录卡”的秘密档案库!
-
作用: 它是一个或多个物理文件,专门负责持久化存储那些“修改记录卡”(撤销日志)。
-
位置: 它不和真实书籍(数据表空间)或者临时草稿纸(临时表空间)混在一起,有自己独立的房间(文件),通常就在图书馆的档案区(数据目录下)。
-
数量: 默认有 2 个档案库(文件),管理员可以配置更多(通过
innodb_undo_tablespaces
),方便管理和提高效率(比如轮流往不同的档案库放记录卡)。
为什么需要这些“修改记录卡”和存放它们的“档案库”?两大核心作用:
作用一:支持“反悔”(事务回滚 - Rollback):
想象借书人改到一半,突然说:“哎呀,我改错了,不想改了!”(事务执行了 ROLLBACK
)。
这时,管理员就会去档案库(撤销表空间)里找出这个人之前写的所有“修改记录卡”(撤销日志),然后按照卡片上的记录,把书一页一页地恢复成修改前的样子。没有这些记录卡,管理员就完全无法知道书原来是什么内容,也就无法撤销错误的修改。撤销日志和撤销表空间共同保证了事务的 原子性 (Atomicity):事务要么完全执行,要么完全像没执行过一样。
作用二:让不同人同时“看书”互不干扰(多版本并发控制 - MVCC):
图书馆很忙,很多人想同时看书(并发读取)。一个人(事务A)正在修改某本书的第5页(写了修改记录卡并存档了),但此时另一个人(事务B)也想读这本书。如果直接让事务B去读正在被修改的原书,他可能会看到改了一半的、不完整的内容(脏读),或者因为书被锁住而读不了(阻塞)。
聪明的管理员(MVCC)利用档案库里的 “修改记录卡” 解决了这个问题:
-
当 事务B 来读这本书时,管理员不直接给他看正在被修改的原书。
-
而是根据 事务B开始阅读的时间点,管理员去档案库(撤销表空间)里翻找。
-
管理员找出在那个时间点之后,所有关于这本书的 “修改记录卡”。
-
管理员 “倒着应用” 这些记录卡上的信息(相当于逆向操作),就能 “还原” 出这本书在事务B开始阅读那个时间点的样子。
-
管理员把这个 “还原出来的版本” 给事务B看。
这样:
-
事务B 看到的是这本书在它开始阅读那个时间点的样子(一致性视图),即使书正在被事务A修改。这叫 一致性读 (Consistent Read)。
-
事务A 可以安心地继续修改原书(并继续写修改记录卡存档),不用担心锁冲突阻塞事务B。这叫 非锁定读 (Non-locking Read)。
撤销日志(记录卡)和存放它们的撤销表空间(档案库)是实现 一致性读 和 非锁定读 的关键,极大地提高了数据库的并发性能。它们共同保证了事务的 隔离性 (Isolation)。
撤销表空间的特点(档案库的特点):
-
持久化存储: 与临时草稿纸(临时表空间
ibtmp1
)不同,档案库(撤销表空间)里的“修改记录卡” 默认是永久保存的。即使图书馆关门(服务器重启),档案库里的记录卡依然存在。这是为了保证长时间运行的事务或崩溃恢复的需要。 -
自动清理 (Purge): 档案库不是无限大的!管理员(InnoDB的后台 purge 线程)会定期清理那些 不再需要 的记录卡:
-
回滚不再需要: 对应的事务已经提交 (
COMMIT
) 了,修改永久生效,那么用于回滚这个事务的记录卡就没用了(反悔功能不需要了)。 -
“时光机”不再需要: 最关键的一点! 只要还有哪怕一个在 书被修改之前 就开始阅读的人(一个较老的事务),他可能还需要根据他阅读开始的时间点来“还原”书的旧版本。那么,用来“还原”他所需要看到的那个旧版本的所有记录卡,都必须保留在档案库里!直到 所有 在那些修改发生之前就开始的事务都结束阅读离开了(都commit了),这些记录卡才能被安全清理掉。这就是为什么有时档案库(撤销表空间)会占用比较多的磁盘空间——因为可能有长时间运行的查询(“读者”)阻止了清理。
-
什么是撤销日志?
介绍完撤销表空间之后将会详细详细撤销日志
1.2 在使用 MySQL 时并没有手动创建撤销表空间,它是什么时候被创建的?
MySQL 初始化时会在数据目录下创建两个默认的撤销表空间,数据文件名分别为 undo_001 和 undo_002 ,数据字典中对应 undo 表空间名称为 innode_undo_001 和 innode_undo_002 :
默认的撤销表空间名称和路径是什么?
要查看撤销表空间名称和路径,请查询 INFORMATION_SCHEMA.FILES
SELECT TABLESPACE_NAME, FILE_NAME FROM INFORMATION_SCHEMA.FILES
WHERE FILE_TYPE LIKE 'UNDO LOG';
./表示的是MySQL工作目录
1.3 可以手动创建撤销表空间吗?
可以,通过使用 CREATE UNDO TABLESPACE 语句可以创建撤销表空间
CREATE UNDO TABLESPACE tablespace_name ADD DATAFILE '文件路径(必须以.ibu结尾)';
示例
创建之前,我们先去数据目录看一下
CREATE UNDO TABLESPACE tablespace_test ADD DATAFILE 'undo_log_test1.ibu';
这个时候我们回去数据目录看一下
这个时候我们再次查看所有撤销表空间
SELECT TABLESPACE_NAME, FILE_NAME FROM INFORMATION_SCHEMA.FILES
WHERE FILE_TYPE LIKE 'UNDO LOG';
发现变成3个了。
什么时候需要手动创建撤销表空间?
默认配置: MySQL 8.0 启动时自动创建两个这样的默认撤销表空间文件 (undo_001.ibu
和 undo_002.ibu
)。
默认的两个文件 (undo_001
, undo_002
) 适用于大多数场景。但在以下特定情况下,手动创建更多的撤销表空间文件 (undo_003.ibu
, undo_004.ibu
等) 是有益或必要的:
-
应对非常大或长时间运行的事务:
-
场景: 一个事务需要修改海量数据(例如,批量更新数百万条记录)或者一个事务运行时间极长(小时甚至天)。
-
问题: 这样的操作会产生巨量的“历史版本”信息(撤销日志记录)。
-
后果: 默认的两个撤销表空间文件会被迅速填满并变得异常庞大。
-
解决方案: 手动创建额外的撤销表空间文件,相当于提供了更多专用的存储库。新事务产生的撤销日志可以分配到这些新文件中,避免将默认的两个文件撑得过大到难以管理的地步(如影响性能、增加备份/恢复难度)。
-
-
防止单个撤销表空间文件过大:
-
问题: 即使没有单个巨型事务,持续的繁忙写入也可能导致某个撤销表空间文件(尤其是默认的第一个)增长到非常大的尺寸。
-
风险:
-
性能影响: 超大文件的读写效率可能降低(I/O 瓶颈)。
-
空间回收困难: InnoDB 的
purge
线程负责清理不再需要的旧版本记录。如果所有记录都集中在一个超大文件中,purge
操作可能效率低下,或者需要回收空间时操作更复杂耗时(涉及文件内部碎片整理)。 -
管理不便: 监控、备份、移动超大文件都更麻烦。
-
-
解决方案: 创建多个撤销表空间文件,将撤销日志记录分散存储。这有助于:
-
保持单个文件大小在更可控的范围内。
-
提高
purge
操作的效率(可以在较小的文件上并行或更快完成)。 -
简化文件管理。
-
-
-
提高并发写入吞吐量和负载均衡:
-
场景: 数据库整体写入负载非常高,产生撤销日志的速度非常快。
-
问题: 默认的两个撤销表空间文件可能成为写入争用的瓶颈点。所有并发事务产生的撤销日志记录都只能写入这两个文件。
-
后果: 写入撤销日志可能成为性能瓶颈,影响整体事务处理速度。
-
解决方案: 手动创建更多的撤销表空间文件。InnoDB 可以轮询 (round-robin) 地将新事务的撤销日志分配到不同的可用文件中。这相当于增加了并行写入的通道,能更均衡地分散 I/O 负载,提升高并发场景下生成撤销日志(进而处理事务)的效率。
-
使用自己创建的撤销表空间需要注意什么?
- 通过系统变量 `innodb_undo_directory` 指定撤销表空间的默认存放路径,如果不指定默认位置为数据目录;
- 撤销表空间文件名必须以 `.ibu` 为扩展名,定义 undo 表空间文件名时如果需要指定路径,必须使用绝对路径;不允许指定相对路径,建议使用唯一的撤销表空间文件名,避免在以后移动和复制的过程中发生文件名冲突;
- 如果指定其他路径,那么路径必须在 `innodb_directories` 中定义,以便 MySQL 扫描并识别;
- 最多支持 127 个 undo 表空间,包括实例初始化时创建的两个默认表空间;也就是说我们自己创建的话,最多只能创建125个
- MySQL 8.0.23 开始初始撤销表空间大小通常为 16MB,并根据服务器负载以 [16MB, 256MB] 的增量进行扩容;
MySQL 8.0.14 之前版本,额外的撤销表空间通过配置系统变量 `innodb_undo_tablespaces` 来创建,取值范围 [2, 127],MySQL 8.0.14 开始,此变量已弃用且不再可配置。
1.4 如何删除撤销表空间?
从 MySQL 8.0.14开始使用 CREATE UNDO TABLESPACE 语法创建的撤销表空间(这个是前提)可以使用 DROP UNDO TABALESPAC 语法删除;
撤销表空间在被删除之前必须是空的,要清空撤销表空间,必须首先使用 ALTER UNDO TABLESPACE 语法将撤销表空间标记为不活动,以便该表空间不再用于其他新的事务;
ALTER UNDO TABLESPACE 表空间名 SET INACTIVE;
在将undo表空间标记为非活动后,等待当前undo表空间的事务完成后表空间被截断到初始大小,当undo表空间为空,就可以进行删除操作
DROP UNDO TABLESPACE 撤销表空间名;
从 MySQL 8.0.14开始使用 CREATE UNDO TABLESPACE 语法创建的撤销表空间可以使用 DROP UNDO TABALESPAC 语法删除,但要确保撤销表空间在被删除之前必须是空的,具体的操作步骤如下:
- 要删除的撤销表空间必须是CREATE UNDO TABLESPACE 语法创建的撤销表空间
- 将撤销表空间标记为不活动
- 等待当前undo表空间的事务完成后表空间被截断到初始大小
- 执行删除操作
删除撤销表空间的示例
查看所有撤销表空间,确保需要删除的撤销表空间是存在的
SELECT TABLESPACE_NAME, FILE_NAME FROM INFORMATION_SCHEMA.FILES
WHERE FILE_TYPE LIKE 'UNDO LOG';
查询指定的表空间状态
SELECT NAME, STATE FROM INFORMATION_SCHEMA.INNODB_TABLESPACES
WHERE NAME LIKE 'tablespace_test';
设置为不活动状态
ALTER UNDO TABLESPACE tablespace_test SET INACTIVE;
等待一会,再次查询状态
SELECT NAME, STATE FROM INFORMATION_SCHEMA.INNODB_TABLESPACES WHERE NAME LIKE 'tablespace_test';
只有当表空间状态为empty时,才能进行删除操作,如果是其他状态,需要再等待一会
DROP UNDO TABLESPACE tablespace_test;
查询撤销表空间,发现删除成功
SELECT TABLESPACE_NAME, FILE_NAME FROM INFORMATION_SCHEMA.FILES WHERE FILE_TYPE LIKE 'UNDO LOG';
这个时候我们回到数据目录看看
我们发现那个 undo_log_test1.ibu文件不见了!!!也就代表我们删除了这个自己创建的撤销表空间。
撤销表空间被置为不活动并且已被截断为初始大小,这时不想删除了是否可以重新启用?
undo表空间状态为空(empty)时,可以重新激活,方法如下:
ALTER undo tablespace 撤销表空间名 SET ACTIVE;
1.5 如何查看撤销表空间的状态?
通过 SHOW STATUS LIKE 'Innode_undo_tablespaces%';语句可以查看撤销表空间的基本信息
SHOW STATUS LIKE 'Innodb_undo_tablespaces%';
二. 撤销日志(Undo Log)
我们说撤销表空间里面存放的是撤销日志。那么撤销表空间里面怎么组织撤销日志,怎么保存撤销日志。我们都有必要去了解一下
2.1.什么是撤销日志?撤销日志的作用是啥?什么时候写撤销日志?
撤销日志是什么?
撤销日志是数据库(特别是使用 InnoDB 存储引擎的 MySQL)为了支持事务的核心特性(ACID 中的 原子性 (A) 和 隔离性 (I) )而创建的一种逻辑记录。
它记录了什么?
-
撤销日志记录的是数据被修改之前的旧状态(Old State)。
-
具体来说:
-
当你执行
UPDATE
一条记录时,撤销日志会记录这条记录更新前的完整内容(所有列的值)。 -
当你执行
DELETE
一条记录时,撤销日志会记录这条记录被删除前的完整内容(所有列的值)。 -
当你执行
INSERT
一条记录时,撤销日志会记录一个特殊的“删除标记”,它本质上包含了足够的信息(如主键值),以便在回滚时能够精准地定位并删除这条新插入的记录。
-
撤销日志的核心作用(为什么需要它?)
这个看似简单的“记旧账”行为,是数据库能稳定可靠运行、支持复杂操作的基石,主要解决三大关键问题:
-
实现“后悔药”(事务回滚 - Rollback):
-
你开启了一个事务,在里面修改了 A1、B2、C3 三个单元格的值。改到一半,你发现算错了,或者程序出错了,你想取消这次操作,让数据恢复到修改前的状态。这时怎么办?
-
撤销日志就是你的“后悔药”! 数据库引擎会拿出你的“小本本”,找到你这次事务(ID=X)记录的所有修改前的旧值(A1=100, B2=旧值, C3=旧值),然后按照记录,一条一条地把数据恢复成修改前的样子。就像看录像带回放,把操作倒着执行一遍。这就是事务 原子性 (Atomicity) 的保证:要么全部成功,要么全部失败回滚,不会停留在半中间状态。
-
-
支持“平行宇宙”(多版本并发控制 - MVCC):
-
想象一下:你(事务A)正在仔细地、慢慢地修改 A1 的值(从 100->200)。与此同时,另一个用户(事务B)也想查询 A1 的值。事务B 不应该 看到你改了一半的脏数据(比如150),也不应该被你的修改操作卡住读不了。
-
撤销日志创造了“平行宇宙”! 当事务B 来读取 A1 时,数据库引擎会去查看:
-
当前 A1 的最新值是
200
(事务A 改的,但还没提交)。 -
但撤销日志里清晰地记录着:在事务B 开始的那个时间点,A1 的值是
100
(并且这个值是由一个更早的、已经提交的事务写入的)。
-
-
于是,数据库引擎 不会给事务B 看最新的 200,而是根据撤销日志,重建出在事务B 开始那一刻 A1 应该的值
100
,并返回给事务B。事务B 仿佛进入了一个独立的“平行宇宙”,完全不受事务A 未提交修改的影响。 -
这实现了事务的 隔离性 (Isolation),特别是
READ COMMITTED
和REPEATABLE READ
隔离级别,让读写操作能高度并发进行,互不阻塞。这是数据库高性能的关键之一。
-
-
辅助“崩溃恢复”(Crash Recovery):
-
假设数据库服务器突然断电了!重启后,有些事务可能提交了但数据没完全写入磁盘(在内存里丢了),有些事务还没提交。
-
数据库需要恢复到一种一致的状态。重做日志 (Redo Log) 负责重做那些 已提交 但丢失的操作(把数据“前滚”到崩溃点)。但是,那些 未提交 的事务所做的修改怎么办呢?
-
撤销日志在这里再次登场! 在恢复过程中,数据库会检查所有在崩溃时处于活动状态(未提交)的事务。对于这些事务,它会利用撤销日志里记录的“旧值”,把它们所做的修改 全部撤销掉 (Rollback),确保这些未完成的操作不会污染数据库。这样,数据库就恢复到了只包含已提交事务修改的一致状态。
-
撤销日志的写入时机?
核心原则:先写撤销日志,再改真实数据!
想象你要修改账本上的一条记录(比如更新“张三的余额”):
-
第一步:先写撤销日志 (Undo Log)
-
在动笔修改账本上的数字之前,你必须先在“撤销日志”这个专用的“备份本”上,原封不动地抄下旧值(例如:“张三余额:100元”),并明确标注是哪个事务在做这次修改。
-
✅ 关键点: 此时,旧值已经安全、持久地记录在“备份本”(即 撤销表空间文件)中。
-
-
第二步:再修改真实数据
-
确认旧值备份完成后,你才能动手在正式的账本(数据表)上,把“张三余额”修改为新值(例如:“200元”)。
-
⚠️ 这个操作顺序绝对不能颠倒!
为什么? 如果先改数据再写备份,中途万一服务器崩溃,旧值就永远丢失了!你将没有任何依据把数据恢复到修改前的状态(无法回滚),破坏了事务的原子性。
2.2.撤销⽇志在撤销表空间中的组织形式是怎样的?
⾸先看来撤销⽇志在撤销表空间中的组织结构图,如下所⽰:
事实上,这个其实跟数据页差不多。
数据页是:段,区组,区,页,数据行
而我们撤销页是:撤销表空间->回滚段->槽位-> 撤销日志记录
1. 撤销表空间 (Undo Tablespace) - 顶层容器
-
是什么? 物理磁盘文件(如
undo_001.ibu
),是存储撤销日志的终极仓库。 -
作用: 持久化保存所有撤销日志记录。
-
关键点: 一个 MySQL 实例可以有多个撤销表空间文件(默认 2 个,可增加)。它是最大粒度的存储单元。
2. 回滚段 (Rollback Segment / Undo Segment) - 空间管理单元
-
通常位于undo表空间和全局临时表空间中,使用系统变量innodb_rollback_segments可以定义分配给每个undo表空间和全局临时表空间的回滚段的数量;
-
是什么? 撤销表空间内部划分出的逻辑区域。每个撤销表空间的回滚段数目默认值为128,取值范围是[1, 128] 。(可通过
innodb_rollback_segments
配置) -
作用:
-
作为撤销日志空间的分配管理器。
-
负责管理其内部的 槽位 (Undo Slots)。
-
-
类比: 如果把撤销表空间看作一个大仓库,回滚段就是仓库里划分出来的 128 个独立分区。
3. 槽位 (Undo Slot) - 事务工作单元
-
是什么? 回滚段内部更细粒度的分配单元。每个回滚段包含 1024 个槽位。
-
一个回滚段支持的事务数取决于回滚段中的undo slots(槽数)和每个事务所需的undo日志数,一个回滚段中的undo槽数可以根据InnoDB页面大小进行计算,公式是(InnoDB Page Size / 16),比如默认情况下InnoDB Page Size大小为 16KB,那么一个回滚段就可以包含1024个undo slot(槽位 )用来存储事务的撤销日志。
-
作用:
-
事务绑定: 当一个事务首次修改数据时,InnoDB 会为它分配一个(或多个)空闲的槽位。这个槽位在该事务结束前专属于该事务。
-
空间分配: 槽位是事务写入其撤销日志记录的基础容器。事务产生的所有撤销日志记录都写入到它分配的槽位中。
-
-
关键点:
-
一个事务通常占用一个槽位。如果事务修改的数据量极其巨大,产生的撤销日志记录一个槽位放不下,它可能会占用同一个回滚段内的多个连续槽位。
-
一个槽位同一时刻只能被一个事务占用。
-
槽位是 InnoDB 管理并发事务和撤销日志空间的核心调度单位。
-
-
类比: 回滚段(分区)里摆放着 1024 个标准规格的箱子(槽位)。事务开始干活时,就去领一个(或几个)空箱子来装自己产生的“修改记录”。
4. 撤销日志记录 (Undo Log Record) - 核心数据
-
是什么? 最底层的、有实际含义的数据单元。它记录了单行数据被修改前的具体信息。
-
内容: 包含事务 ID (
TRX_ID
)、回滚指针 (ROLL_PTR
)、操作类型 (INSERT
/UPDATE
/DELETE
) 以及该行数据的旧值(或用于撤销INSERT
的主键信息)。 -
生成时机: 事务每次修改一行数据前(遵循“先写 Undo Log”原则),都会生成一条对应的撤销日志记录。
-
存储位置: 生成的撤销日志记录被写入到该事务当前占用的槽位所关联的存储空间中(最终物理存储在撤销表空间文件里)。事务 每次修改数据行前 生成的 一条撤销日志记录,都会被写入到该事务 占用的槽位中。一个槽位内可存储 多条 属于同一事务的撤销日志记录(按操作顺序链接)。
-
关键结构 - 链表:
-
事务回滚链: 同一个事务内产生的多条撤销日志记录,会按修改发生的先后顺序链接成一个链表(通过记录内部指针)。这是为了回滚时能按操作逆序精准撤销。
-
行版本链 (MVCC 生命线): 对同一行数据的不同事务的多次修改,其对应的撤销日志记录通过
ROLL_PTR
指针连接起来,形成一个按修改时间倒序排列的链表(最新修改的 Undo Record 在链头)。这就是 MVCC 实现“时光机”读取的基础。
-
-
类比: 撤销日志记录就是事务写下的一张张具体的“修改记录卡”(“把张三余额从 100 改成 200 前,旧值是 100”)。这些卡片被有序地放入事务领到的箱子(槽位) 里,并且同一行数据的卡片还用绳子 (
ROLL_PTR
) 按顺序串了起来。
查看 Undo表空间中的回滚段数量
show variables like 'innodb_rollback_segments';
设置 Undo表空间中的回滚段数量
SET GLOBAL innodb_rollback_segments = 128;
2.3.撤销⽇志的格式是怎样的?
这个也就是说撤销日志的结构是啥?
撤销⽇志格式⽰意图如下:
一条记录在Undo Log页中的Undo Log日志大体包含两部分:分别是记录了Undo类型、表ID、上
一条、下一条日志的偏移地址等在内的“基本信息”,以及记录了不同操作和数据的“操作信息”,如
上图所示
在事务中不同的DML操作对应的撤销日志是否不同?
在执行DML语句操作数据库时,不同SQL语句对应的撤销操作不同,不同的撤销操作对应的Undo
Log存储格式也不相同,按照增、删、改等不同的DML操作,生成对应的撤销日志。
不同操作对应的撤销日志如何区分?
不同的撤销操作对应的UndoLog存储格式也不相同,我们主要通过记录头部的 TYPE
字段来区分,最常见的就是
- 增:TRX_UNDO_INSERT_REC
- 删 :TRX_UNDO_DEL_MARK_REC
- 改:TRX_UNDO_UPD_EXIST_REC ,
如图所示:
-
插入操作 (
TYPE = TRX_UNDO_INSERT_REC
)-
产生时机: 当执行
INSERT
语句向表中添加新行时。 -
日志内容: 这是结构相对最简单的一种撤销日志。
-
核心信息: 它主要记录了新插入行的主键信息,包括主键值本身和主键长度。这是最关键的信息,因为回滚插入操作意味着需要根据主键精确地定位并删除这条新插入的记录。
-
表标识: 包含插入操作发生的表ID。
-
其他元数据: 可能包含一些事务相关的基本信息(如日志序列号等)。
-
-
回滚作用: 如果事务回滚,系统会根据这条Undo Log提供的主键信息,执行一个删除操作(
DELETE
)来移除该新插入的行。
-
-
删除操作 (
TYPE = TRX_UNDO_DEL_MARK_REC
)-
产生时机: 当执行
DELETE
语句标记删除一行时(InnoDB的删除通常是标记删除,并非立即物理移除)。 -
日志内容: 比插入日志复杂得多,承载了构建多版本数据链的关键信息。
-
主键信息: 记录被删除行的主键(值和长度),用于定位该行。
-
旧版本指针: 记录被删除行之前版本的
TRX_ID
(事务ID) 和ROLL_POINTER
(回滚指针)。这是构建有序Undo版本链的核心,使得系统能够沿着这个指针找到该行更早的历史版本,从而实现一致性读(MVCC)。 -
索引信息: 记录被删除行上所有被索引字段(包括二级索引)的值。这对于正确回滚删除操作至关重要,因为回滚一个删除操作相当于重新插入该行,需要重建该行在所有索引中的条目。
-
表标识与元数据: 同样包含表ID和相关的基本信息。
-
-
回滚作用: 回滚删除操作意味着要将该行“复活”。系统利用Undo Log中的主键、所有索引列值和旧版本指针信息,将该行标记为未删除状态,并重建其索引条目。同时,
ROLL_POINTER
会被恢复,重新指向其之前的历史版本。
-
-
更新操作 (
TYPE = TRX_UNDO_UPD_EXIST_REC
)-
注意这是不更新主键的情况
-
产生时机: 当执行
UPDATE
语句修改一行中非主键列的值时(即不更新主键的原地更新)。 -
日志内容: 结构最为复杂,融合了删除和部分插入日志的特点。
-
主键信息: 记录被更新行的主键(值和长度),用于定位。
-
旧版本指针: 记录更新前该行版本的
TRX_ID
和ROLL_POINTER
。同样用于构建/延续Undo版本链。 -
索引信息: 记录被更新行上所有被索引字段(包括二级索引)在更新前的值。如果更新影响了索引列,回滚时需要恢复这些列的旧值以维护索引正确性。
-
增量修改: 最关键的部分是记录被修改列(字段)在更新前的旧值。通常不会记录整行旧值,而是记录哪些列被修改了以及这些列修改前的值。这称为“增量Undo”或“部分Undo”。
-
表标识与元数据: 包含表ID和基本信息。
-
-
回滚作用: 回滚更新操作意味着将被修改的列恢复到旧值。系统根据主键定位到行,然后利用Undo Log中记录的旧列值去覆盖新值。同时,如果索引列被修改,需要利用记录的旧索引值去更新索引条目。
ROLL_POINTER
也会被恢复指向这个Undo Log记录本身(或链中合适的位置)。
-
-
特殊情况:更新主键 (
TYPE = TRX_UNDO_DEL_MARK_REC
+TYPE = TRX_UNDO_INSERT_REC
)-
对于更新主键的操作,本质都是删除旧的数据行,新增一个数据行
-
产生时机: 当
UPDATE
语句修改了行的主键值时。 -
处理方式: InnoDB 不会 产生单一的
TRX_UNDO_UPD_EXIST_REC
日志。因为主键定义了行的物理位置(在聚簇索引中),更改主键相当于移动行。 -
日志内容: 系统会隐式地将其拆分为两个操作,并产生两条独立的Undo Log记录:
-
删除旧主键行 (
TYPE = TRX_UNDO_DEL_MARK_REC
): 这条日志记录的内容与普通的删除日志完全相同,记录了旧主键、旧版本指针、所有索引列的旧值等。它代表删除旧主键标识的行。 -
插入新主键行 (
TYPE = TRX_UNDO_INSERT_REC
): 这条日志记录的内容与普通的插入日志完全相同,记录了新主键信息。它代表插入一条具有新主键的行(该行的非主键列值来源于UPDATE操作设定的新值)。
-
-
回滚作用: 回滚主键更新操作需要回滚这两条日志:
-
先回滚插入操作:根据新主键日志删除新插入的行。
-
再回滚删除操作:根据旧主键日志“复活”旧主键标识的行及其所有索引条目。
-
-
2.4 撤销⽇志是如何组织在⼀起的?
我们之前说过InnoDB在不同的使用场景定义多种不同类型的页,每种页的数据结构都不相同。
但是不论哪种类型的页都具有页头(File Header)和页尾(File Trailer)两个信息
现在我们就来讲解一下 Undo Log页
撤销日志页的组织⽰意图如下
大家很清楚的就能看到这第一个Undo日志页和后面的Undo日志页有点区别。
其实第一个Undo日志页也就比后面的Undo日志页多了2个字段:
- UNDO LOG SEGMENT HEADER (撤销日志段头)
- UNDO LOG HEADER (撤销日志头)
除此之外,没有别的什么不同。
除了第一个Undo日志页之外,后续日志页不需要存储段级元信息(因为已在首页定义),它们纯粹用于存储 撤销日志记录 (Undo Log Records) 和 槽位 (Slot) 的具体数据,是回滚段空间的主体存储单元。
-
当第一个 Undo 日志页的槽位空间用完时,InnoDB 会分配新的 Undo 日志页链接到当前回滚段。
-
新页通过 UNDO PAGE HEADER 里面的 上一页指针 (FIL_PAGE_PREV)与前一页形成链表:
那么我们就重点讲解事务的第一个撤销日志页即可。
事务的第一个撤销日志页:完整的“封面与目录”
当一个事务启动并首次需要记录撤销日志时,系统会为它分配一个新的撤销日志页,这个新的撤销日志页就是我们上面说的事务的第一个撤销日志页,它的结构是上图这样子。
关于页头和页尾,我就不提了,因为InnoDB存储引擎中,不论哪种类型的页都具有页头(File Header)和页尾(File Trailer)两个信息,要是想要了解的,可以去:【InnoDB存储引擎3】页结构-CSDN博客
我们这里只讲撤销日志页特有的部分
- UNDO PAGE HEADER (撤销页头)
- UNDO LOG SEGMENT HEADER(撤销日志段头)
- UNDO LOG HEADER (撤销日志头)
2.4.1.UNDO PAGE HEADER (撤销页头)
想象你手中拿着的是一本 撤销日志段 (Undo Log Segment) 中的 一页纸 (Undo Page)。这页纸专门用来记录“如何撤销某个操作”的 Undo 日志记录。UNDO PAGE HEADER 就是印在这页纸最上方的一个本页专属管理信息区。这个区域不记录具体的操作步骤(那是正文部分),而是记录关于 “这张纸本身如何管理、如何与其他纸连接” 的关键元数据。
这个页头信息区包含以下核心字段:
-
所属段标识 (TRX_UNDO_SEG_HDR / Undo Segment Header Pointer):
-
每个事务(或一组事务)都有自己的专属撤销日志段。这个字段是一个指针(通常指向同一个段的第一页,即 段头页 Undo Segment Header Page),明确标识了“我这张纸是属于哪个撤销日志段的”。这保证了每张纸都严格归属于它的事务撤销日志段。
-
-
日志起始偏移 (TRX_UNDO_LOG_START):
-
纸张顶部是各种管理信息(文件头、页头、UNDO PAGE HEADER 本身等),这些都不是具体的操作 Undo 日志记录。这个字段记录了一个偏移量 (Offset)。它指示:“所有页头管理信息在此结束,从纸张的这个位置(比如从页起始地址向后偏移 500 个字节处)开始,就是记录具体操作撤销步骤(即 Undo Log Records)的地方了”。这划定了 日志记录正文区 (Undo Log Record Area) 的起点。
-
-
空闲空间起始偏移 (TRX_UNDO_LOG_FREE / Free Space Offset):
-
随着事务执行操作,需要不断在这张纸上追加 (Append) 新的 Undo 日志记录。这个字段记录着另一个偏移量。它明确指示:“目前纸上最后一条有效 (Valid) Undo 日志记录写到了这里(比如偏移量 1500 字节处),从这个位置往后,一直到页的底部,都是空闲空间 (Free Space),新来的 Undo 日志记录可以直接从这里接着写”。系统需要写入新日志时,通过这个偏移量可以立即定位到写入位置,非常高效。
-
-
下一页指针 (FIL_PAGE_NEXT):
-
这是极其关键的一栏!一个页的空间是有限的(默认 16KB)。如果事务的操作步骤特别多,当前页即将写满时,系统会提前分配好一个新页。这个字段记录的就是下一个 Undo Page 的页号 (Page Number)。它表示:“本页内容已满,后续操作请访问撤销日志段的下一个页,其页号是 12345”。有了这个页号(结合表空间信息),系统就能轻松地找到撤销日志段的下一页,保证所有 Undo 日志记录是按顺序连续存放的。这个指针将撤销日志段的所有页物理地、顺序地串联成一个链表 (Linked List)。
-
-
上一页指针 (FIL_PAGE_PREV):
-
这个字段记录着上一张 Undo Page 的页号。这对于撤销日志段中间或末尾的页很重要,方便系统反向遍历 (Backward Traversal) 链表。
-
对于撤销日志段的第一个页(段头页 Undo Segment Header Page)的特殊说明: 作为链表的头部,它的
FIL_PAGE_PREV
字段不会存储一个真实的页号,而是存储一个特殊的、表示“无效”或“空”的标记值:FIL_NULL
(在 InnoDB 中通常定义为0xFFFFFFFF
)。这个值明确宣告:“我是这个撤销日志段链表的首节点 (Head Node),前面没有其他页了!”。
-
2.4.2.UNDO LOG SEGMENT HEADER (撤销日志段头)
-
段定位信息 (Segment Location - "身份证+地址"):
-
明确记录该撤销日志段属于哪个回滚段 (Rollback Segment) 以及在该回滚段中的具体 撤销槽 (Undo Slot)。
-
这是该段在整个撤销日志系统中的唯一标识和快速定位依据,系统据此能瞬间找到特定事务的撤销日志。
-
-
状态标识 (State Flag - "状态牌"):
-
指示该段的当前状态,最重要的状态是 ACTIVE (活跃),表示其关联的事务正在运行或该段中的撤销记录仍被其他事务(MVCC)需要,绝对不可回收。
-
其他状态包括 CACHED (已归还待缓存)(段结构保留以备重用)和 TO_PURGE (待清理)(可安全回收其空间)。
-
-
最新日志指针 (Last Log Pointer - "最新记录坐标"):
-
动态记录该段中最新写入的撤销日志记录所在的页号和该页内的偏移量。
-
这是新日志写入的直接起点,确保高速写入;也是事务回滚操作的精确起点和 MVCC 查找最新历史版本的快速入口;同时为 Purge 线程清理过期日志提供关键位置信息。
-
-
段首页指针 (First Page Pointer - "第一页身份证"):
-
明确记录构成该撤销日志段的第一页的页号。
-
这提供了独立访问和遍历整个段链表的绝对可靠起点,是系统访问该段内容的基础入口点,增强了结构的独立性和可靠性。
-
2.4.3.UNDO LOG HEADER (撤销日志头)
UNDO LOG HEADER (撤销日志头): 这个头信息是事务级别的,专门记录产生这条(以及后续链接页中所有)日志的事务的关键信息:
- 每个事务都是以UNDO LOG HEADER开始作为事务开始的标记
- 事务ID: 产生这些日志的唯一事务标识符。这是 MVCC 和回滚的核心依据。
- 记录事务的第一条日志的偏移量
- 事务提交序号: 事务在提交时获得的一个全局递增序号(并非所有系统都有,但在讨论事务可见性时很重要)。它定义了事务提交的相对顺序。
- 其他事务相关信息,如事务状态等。
如何理解Undo链以及它是如何构成的?
本小节最后会详细介绍Undo链的构成
事务提交后Undo Log是否就可以删除了?
这里强调一下
操作类型 | Undo Log 内容 | 事务提交后能否立即删除? | 原因 | 处理方式 |
---|---|---|---|---|
INSERT | 记录插入行的主键信息 | ✅ 可以立即删除 | 仅用于事务自身回滚。事务提交后,新数据已可见,无需保留旧版本。 | 直接释放空间 |
DELETE | 记录被删行的完整数据 | ❌ 不可立即删除 | 需为其他事务提供 MVCC 读快照(其他事务可能需读取删除前的数据版本)。 | 加入 History List |
UPDATE | 记录被修改字段的旧值 | ❌ 不可立即删除 | 需为其他事务提供 MVCC 读快照(其他事务可能需读取更新前的数据版本)。 | 加入 History List |
我们发现INSERT和DELETE,UPDATE有明显差别。
这其实是理解 MVCC(多版本并发控制)机制的核心差异。我来用更生活化的方式解释为什么会有这种区别:
核心原因:MVCC 机制需要“历史版本”,但 INSERT 操作没有“历史”!
想象一个图书馆的藏书登记簿:
-
INSERT (新增一本书)
-
操作: 你在登记簿上新增一条记录:《三体》刘慈欣,编号 T001,放入科幻区。
-
Undo Log 内容: 记录“如果回滚,需要把编号 T001 的书从科幻区拿走并撕掉这条记录”。
-
事务提交后: 书已经上架,登记簿上也有记录了。大家都看到科幻区有《三体》了。
-
为什么能立即删除 Undo Log?
-
没有“旧版本”数据! 这本书之前根本不存在。对于其他读者来说,这本书要么是存在的(事务提交后),要么是不存在的(事务提交前或回滚后)。不存在“这本书在提交前是什么样子”这种历史版本问题。
-
MVCC 不需要它: 如果另一个事务在“新增《三体》”这个事务提交前开始读,它根本读不到《三体》这本书(因为事务没提交,数据不可见)。如果在这个事务提交后开始读,它直接就能看到书架上《三体》和登记簿上的记录。没有任何场景需要依赖这个 INSERT 的 Undo Log 来构建一个“《三体》提交前”的视图,因为提交前它压根不存在! 所以,这个 Undo Log 的唯一作用就是事务自己回滚时用。提交了,它就没用了,可以立刻丢掉。
-
-
-
DELETE (删除一本书)
-
操作: 你把《三体》的记录标记为“已下架/删除”。
-
Undo Log 内容: 记录“如果回滚,需要把《三体》的信息(编号 T001,科幻区)重新写回登记簿,并去掉删除标记”。它完整记录了被删行的数据。
-
事务提交后: 书被拿走了,登记簿上标记为已删除。
-
为什么不能立即删除 Undo Log?
-
存在“旧版本”数据! 在删除操作发生前,《三体》是存在的。
-
MVCC 需要它! 想象一个事务 B 在事务 A “删除《三体》” 提交之前 就开始了。事务 B 期望看到的是它启动那一刻的数据库快照。在那个快照里,《三体》应该是存在的!事务 B 去查登记簿时,发现现在《三体》标记为删除了,但它需要知道在它启动时这本书的状态。这时,事务 B 就会去查找 《三体》这条记录的 Undo Log (由删除操作产生的)。这个 Undo Log 里完整保存了《三体》的信息,事务 B 就能利用这个信息“重建”出它在启动时看到的《三体》存在的状态。只要还有类似事务 B 这样在删除操作提交前启动的事务存在,这个 Undo Log 就必须保留。只有等到所有在删除提交前启动的事务都结束了,确认没人再需要看删除前的样子了,这个 Undo Log 才能被清理(Purge)。
-
-
-
UPDATE (修改一本书信息)
-
操作: 你把《三体》的位置从“科幻区”改成“推荐区”。
-
Undo Log 内容: 记录“如果回滚,需要把《三体》的位置从‘推荐区’改回‘科幻区’”。它记录了被修改字段(位置)的旧值。
-
事务提交后: 登记簿上《三体》的位置更新为“推荐区”,书也被移过去了。
-
为什么不能立即删除 Undo Log?
-
存在“旧版本”数据! 在更新操作发生前,《三体》是在科幻区的。
-
MVCC 需要它! 同样,一个在更新操作 提交之前 启动的事务 C,它期望看到的是启动时的快照。在那个快照里,《三体》应该在科幻区。当事务 C 去查登记簿,发现现在写的是“推荐区”,它就需要去查找 《三体》这条记录的 Undo Log (由更新操作产生的)。这个 Undo Log 里记录了旧位置“科幻区”,事务 C 就能知道“哦,在我开始的时候它还在科幻区呢”。只要还有类似事务 C 这样在更新操作提交前启动的事务存在,这个 Undo Log 就必须保留。同样,需要等待所有可能依赖这个旧版本的事务结束后才能清理。
-
-
因些InnoDB为了最大程度节省空间提升效率对Undo Log进行了分类
关于MVCC和history list在事务和锁专题中详细介绍
2.5 撤销日志如何分类?
为了方便管理和优化性能,Undo Log 根据操作类型和表类型被分为两大类进行管理:
-
按操作类型分类:
-
Insert Undo Log: 专门记录
INSERT
操作。这类日志在事务提交后即可直接安全清除,因为新插入的行在回滚时只需删除即可。 -
Update/Delete Undo Log: 专门记录
UPDATE
和DELETE
操作。这类日志需要保留更长时间(可能涉及一致性读、MVCC),用于将数据恢复到修改前的状态。
因此,在 Undo 页的组织上,一个页内只会存放同一种类型的 Undo Log 记录。这也形成了两种独立的回滚链:
-
Insert Undo 链: 仅包含
INSERT
操作的 Undo 记录。 -
Update Undo 链: 仅包含
UPDATE
和DELETE
操作的 Undo 记录。
-
-
按表类型分类:
-
普通表 (用户定义的表): 其对应的 Undo Log(无论是 Insert 还是 Update/Delete 类型)存储在系统或用户自己创建的撤销表空间中。
-
临时表: 其对应的 Undo Log(无论是 Insert 还是 Update/Delete 类型)存储在临时表空间 (通常是 ibtmp1 文件) 的专用回滚段中
-
组合与分配:
基于以上分类,一个事务可能最多需要四种独立的 Undo Log 链(即四个 Undo Log 槽位),这取决于它执行的操作和涉及的表类型:
-
普通表的
INSERT
操作:使用普通表空间的 Insert Undo 链。 -
普通表的
UPDATE
/DELETE
操作:使用普通表空间的 Update Undo 链。 -
临时表的
INSERT
操作:使用临时表空间的 Insert Undo 链。 -
临时表的
UPDATE
/DELETE
操作:使用临时表空间的 Update Undo 链。
分配原则:
-
事务根据其执行的具体操作类型(
INSERT
,UPDATE
,DELETE
)和操作对象(普通表 或 临时表),按需从相应的回滚段申请对应的 Undo Log。 -
例如:
-
一个只在普通表上执行
INSERT
的事务,只需要分配 普通表的 Insert Undo 链。 -
一个在普通表和临时表上都执行了
INSERT
、UPDATE
、DELETE
操作的事务,则需要分配全部四种 Undo 链。
-
-
普通表的操作总是从撤销表空间(系统或用户创建的)的回滚段分配 Undo Log。
-
临时表的操作总是从临时表空间(通常是 ibtmp1 文件) 的专用回滚段分配 Undo Log。
总之: Undo Log 的管理严格区分了操作类型(Insert vs. Update/Delete)和表类型(普通表 vs. 临时表),形成四种逻辑上的 Undo Log 链。事务运行时,会根据其执行的操作,动态地从对应表空间的对应回滚段中申请所需类型的 Undo Log 槽位。
我们可以看一些例子
当一个用户执行下单操作时,事务 T1 包含以下步骤,InnoDB 会为不同类型的表操作动态分配和管理相应的 Undo Log 链,确保事务的原子性(回滚能力):
-
更新商品库存 (操作普通表):
-
SQL:
UPDATE products SET stock = stock - 1 WHERE id = 1001;
-
动作: 减少商品 ID 1001 的库存。
-
Undo Log 分配与写入:
-
触发: 此 UPDATE 操作。
-
类型: Update Undo Log 链 (处理 UPDATE/DELETE 操作)。
-
作用: 记录 id=1001 商品修改前的
stock
值。用于事务回滚时恢复库存。 -
分配位置: 从普通表空间(系统或者用户创建的撤销表空间)的回滚段中分配一个 Update Undo 链槽位。
-
写入内容: 此 UPDATE 操作生成的 Undo 记录被写入此链。
-
-
-
插入新订单记录 (操作普通表):
-
SQL:
INSERT INTO orders (user_id, product_id, quantity) VALUES (123, 1001, 1);
-
动作: 在订单表中创建一条新订单记录。
-
Undo Log 分配与写入:
-
触发: 此 INSERT 操作。
-
类型: Insert Undo Log 链 (处理 INSERT 操作)。
-
作用: 记录新插入订单记录的信息(主要是其主键)。用于事务回滚时删除此新订单。
-
分配位置: 从普通表空间(系统或者用户创建的撤销表空间)的回滚段中分配一个 Insert Undo 链槽位。
-
写入内容: 此 INSERT 操作生成的 Undo 记录被写入此链。
-
-
-
清理临时购物车 (操作临时表):
-
SQL:
DELETE FROM temp_cart WHERE user_id = 123 AND session_id = 'SESSION_A';
-
动作: 从用户的临时购物车中移除已下单的商品项。
-
Undo Log 分配与写入:
-
触发: 此 DELETE 操作(作用于临时表)。
-
类型: Update Undo Log 链 (处理临时表的 UPDATE/DELETE 操作)。
-
作用: 记录被删除的临时购物车条目信息。用于事务回滚时将此项重新插入
temp_cart
。 -
分配位置: 从临时表空间(通常是
ibtmp1
文件)的专用回滚段中分配一个 Update Undo 链槽位。 -
写入内容: 此 DELETE 操作生成的 Undo 记录被写入此链。(注:虽然 SQL 是 DELETE,但其 Undo Log 在分类上属于 UPDATE/DELETE 类型)。
-
-
-
记录临时订单摘要 (操作临时表):
-
SQL:
INSERT INTO temp_order_summary (order_id, total_amount) VALUES (LAST_INSERT_ID(), 99.99);
-
动作: 在临时订单汇总表中插入一条订单总结信息(使用上一步
orders
表插入生成的自增 ID)。 -
Undo Log 分配与写入:
-
触发: 此 INSERT 操作(作用于临时表)。
-
类型: Insert Undo Log 链 (处理临时表的 INSERT 操作)。
-
作用: 记录新插入的临时订单摘要信息(主要是其标识)。用于事务回滚时删除此条摘要记录。
-
分配位置: 从临时表空间(通常是
ibtmp1
文件)的专用回滚段中分配一个 Insert Undo 链槽位。 -
写入内容: 此 INSERT 操作生成的 Undo 记录被写入此链。
-
-
2.6 InnoDB最大支持并发读写事务的数量如何计算?
1. 普通表事务(非临时表)
事务类型 | 计算公式 | 默认最大值 (16KB页) |
---|---|---|
纯INSERT/UPDATE/DELETE | (MySQL页大小/16) × 128表空间 × 127回滚段 | 16,646,144 |
混合操作事务(INSERT+另外任意一个) | (MySQL页大小/16) × 128表空间 × 127回滚段 ÷ 2 | 8,323,072 |
核心逻辑:
-
分母是16:每个Undo Slot的管理开销(16字节/槽位)
-
128×127:全局最大Undo Slot数(128个表空间 × 每个表空间127个回滚段)
-
混合事务÷2:因需同时占用INSERT和UPDATE两个独立Undo Slot
2. 临时表事务(ibtmp1
空间)
事务类型 | 计算公式 | 默认最大值 (16KB页) |
---|---|---|
临时表事务 | 页大小 / 16 | 1,024 |
核心逻辑:
-
独立存储:临时表Undo Log固定存储在
ibtmp1
表空间 -
仅1个回滚段:临时表空间只有1个回滚段(与普通表的128表空间无关!)
2.7 如何理解Undo链?
理解 InnoDB 的 Undo 链,可以想象成数据行的“历史档案室”,它用两种不同的方式记录数据的过去:
1. Insert Undo 链:记录“出生证明”
-
场景: 当你新增一行数据(
INSERT
操作)。 -
记录方式: 每新增一行数据,InnoDB 就会为它单独创建一份“出生证明”(Insert Undo Log)。这份证明里记录了这条数据被创建时的原始信息。一条新增的数据行都对应一条Undo日志,它们之间是一对一的关系。
-
关联方式: 这条新数据行上有一个特殊的指针
ROLL_POINTER
,它直接指向这份专属的“出生证明”。 -
链的本质: 所谓的 “Insert Undo 链”,其实更像是一堆独立的档案袋。每个新插入的数据行都有自己的档案袋(Insert Undo Log),袋子里装的是它自己的出生信息。它们之间没有相互链接。回滚时,数据库只需找到这个数据行的
ROLL_POINTER
,打开它指向的那个档案袋,就能知道如何删除这条新插入的数据(因为档案袋里存了插入前的“不存在”状态)。 -
作用: 主要是为了在事务回滚时,能干净地删除这条新插入的数据行,就像它从未出现过一样。
2. Update Undo 链:记录“成长履历”
-
场景: 当你修改一行数据(
UPDATE
)或删除一行数据(DELETE
)。 -
记录方式: 每次修改或删除数据时,InnoDB 不会覆盖旧数据,而是把修改前或删除前的旧数据版本记录下来,生成一个 Update Undo Log。每一次修改或者删除操作都会生成一条Undo日志。
-
关联方式: 这是关键!当前数据行上的
ROLL_POINTER
不再指向一个孤立的档案袋,而是指向最新一次修改或删除所创建的 Update Undo Log。而这个 Update Undo Log 本身,又包含一个指针,指向它之前那次修改的 Undo Log。这样,通过ROLL_POINTER
一层层回溯,就形成了一条记录着这个数据行所有历史版本的链条。 -
链的本质: 这就是一个真正的链条。每个 Update Undo Log 就像链条上的一个环,环环相扣。链条的起点是当前数据行(最新状态),链尾是最早的历史版本。每次更新或删除,就在这个链条的最前面添加一个新环(最新的旧版本)。
-
作用:
-
回滚: 当需要撤销一个修改或删除操作时,数据库顺着这个链条找到上一个版本的数据,用它覆盖当前版本,就像时光倒流。
-
MVCC (多版本并发控制): 这是更重要的作用!不同时刻启动的事务,看到的可能是这个数据行的不同历史版本(通过访问这个链条上的某个旧版本)。这解决了读-写冲突,保证了事务的“隔离性”。例如,一个长事务在读取数据时,看到的是它启动时刻的数据快照(链条上的某个旧版本),即使其他事务后来修改了这条数据(在链条前面加了新环),也不会影响它的读取结果。
-
我们看个例子,以下是⼀个关于更新操作的Undo链
回滚时根据事务的id找到对应的Undo日志进行回滚操作即可
-
事务回滚: 如果事务200需要回滚,系统会:
-
找到最新数据行 (宋江)。
-
顺着它的
roll_pointer
找到 UNDO 4,取出里面的旧值 (武松)。 -
用这个旧值覆盖当前数据行 (把宋江改回武松)。
-
然后顺着 UNDO 4 隐含指向的武松数据行的
roll_pointer
找到 UNDO 3,取出旧值 (孙悟空)。 -
用孙悟空覆盖武松。
-
事务200回滚完成,数据恢复到事务200开始前的状态 (孙悟空)。
-
-
MVCC (多版本并发控制):
-
假设在事务200第一次修改后但未提交时(数据是“武松”),另一个只读事务(比如事务ID=250)启动并要读取这条数据。
-
系统会根据事务250的启动时间点(和Read View规则),沿着版本链查找。
-
它发现最新版本是事务200修改的 (武松),但事务200未提交(或已提交但事务250看不到),所以它不能读这个最新版本。
-
它继续顺着
roll_pointer
找到 UNDO 3 指向的版本(孙悟空),修改它的事务100已经提交,并且事务250可以看到它(在Read View范围内)。于是,事务250读取到的值就是“孙悟空”。 -
这样,即使数据正在被修改(事务200未完成),只读事务(250)也能读取到一个一致的快照(孙悟空),实现了可重复读或读已提交的隔离级别。
-
如何保证事务的隔离性?
相关内容在事务和锁专题中详细介绍
2.8 撤销日志为什么需要落盘?
- 在对数据进行修改时,都是在内存中操作的,也就是在Buffer Pool中修改对应的数据页,在修改数据页之前先把对应的撤销日志记录在内存中,如果此时事务回滚直接根据内存中的撤销日志做回滚操作即可;
- 在修改完成提交事务后,脏页进行落盘操作,此时事务已提交,不能回滚,所以撤销日志也就失效了;
- 当服务器崩溃时,如果事务没有提交,所有的修改都在内存中,还没有落盘,对于修改直接丢弃;如果事务已经提交,则根据重做日志和双写缓冲区中的备份进行恢复;
通过分析看上去撤销日志并不需要落盘,其实以上的分析场景并没有考虑到全部的场景,比如大事务的运行、MVCC中版本链什么时候可以销毁、事务的不同隔离级别等因素;
注意:Undo日志落盘必须在真实数据落盘之前
- 在运行大事务时,InnoDB为了避免大事务提交时统一落盘操作带来的性能问题,允许在事务进行的过程中就进行落盘操作并记录对应的UndoLog,当发生崩溃恢复时,通过回放UndoLog把未提交的事务进行回滚;
- 如果一个事务已经提交,但还有其他事务需要访问版本链中对应的UndoLog,那么也需要把相应的撤销日志保存到 history list 中。
- 不同隔离级别下,没有提交的事务也可能会落盘,回滚时依然要完成撤销操作。
撤销日志在内存中如何记录?
与数据页在内存中的保存方式相同,撤销日志在内存中也保存在Buffer Pool中,与磁盘中的UndoLog页结构一致,内存中每个Undo Log页通过控制块管理
在内存中使用四个链表来管理正在使用的UndoLog页和空闲UndoLog页,根据不同的日志类型分为:
- Insert List:正在被使用的用来管理Insert类型的UndoLog 页
- Insert Cache List:空闲的或被清理后可以被后续事务重用的Insert类型UndoLog页
- Update List:正在被使用的用来管理Update类型的UndoLog 页
- Update Cache List:空闲的或被清理后可以被后续事务重用的Update类型UndoLog页
撤销日志的写入过程是怎样的?
首先我需要提一嘴:撤销日志记录(Undo Record)首先写入内存缓冲区。磁盘写入操作按需进行,并非实时同步。
-
事务启动与段分配: 当一个写事务开始时,系统会为其分配一个处于
ACTIVE
状态的回滚段(Rollback Segment)。 -
获取事务槽位: 当事务执行第一次 DML 操作(INSERT、UPDATE、DELETE)并产生撤销记录时,会通过轮询方式从当前分配的回滚段中获取一个可用的槽位(Slot)。该槽位关联着一个撤销日志段(Undo Log Segment),这个段在事务期间由该事务独占使用。
-
定位日志页与链表挂载: 根据撤销日志的具体类型(Insert、Insert Cache、Update、Update Cache),系统会获取相应的撤销日志页(Undo Log Page)。新获取的日志页会被挂载到对应类型的链表(List) 上。
-
顺序写入与页限制: 撤销记录按顺序写入当前撤销日志页。当一个日志页写满后,系统会申请新的撤销日志页继续写入该事务后续产生的记录。 需要特别注意:单条撤销记录不能跨页存储。如果当前页剩余空间不足以容纳整条新记录,该记录将完整地写入下一个新页。
-
后台刷盘: 由后台线程负责将内存中的撤销日志内容异步刷新(Flush)到磁盘上的撤销表空间。
-
事务结束与资源回收:
-
INSERT 类型日志: 当事务提交(Commit)或回滚(Rollback)后,其使用的 INSERT 类型日志关联的撤销日志段(Undo Log Segment)和撤销日志页(Undo Log Page)会立即被回收。
-
UPDATE/DELETE 类型日志: 这些日志关联的段和页不会立即回收。它们需要等待后台的清理线程(Purge)完成工作,确保没有任何正在进行的事务(包括可能使用 MVCC 快照的读事务)再需要访问这些旧版本日志后,才会被安全回收。
-
回收再利用: 被回收的撤销日志页会被挂载到对应的 Cache List(如 Insert Cache List 或 Update Cache List)中,以便后续事务快速复用。
-
撤销日志的回滚过程是怎样的?
回滚操作可以由用户显式执行 ROLLBACK
语句触发,也可能在数据库崩溃恢复时自动执行。无论触发方式如何,其核心过程是相同的:读取该事务生成的所有撤销记录(Undo Record),并按生成顺序的逆序(从最新到最旧)逐条执行逆向操作,以撤销该事务对索引记录的所有修改。
具体的逆向操作类型取决于原始操作:
-
INSERT 操作回滚: 其逆向操作是 DELETE。
-
该过程会删除由该 INSERT 操作创建的主键索引记录和所有相关的二级索引记录。
-
-
UPDATE / DELETE 操作回滚: 其逆向操作主要是 撤销该操作对索引结构的影响,具体包括:
-
重新插入(Re-insert): 如果 UPDATE/DELETE 操作导致了二级索引记录的物理删除,需要将这些记录重新插入回相应的二级索引。
-
清除删除标记(Clear Delete Mark): 对于标记删除(Delete Mark)的行(DELETE 操作或某些 UPDATE 场景),需要清除其行管理信息中的删除标记,使其恢复为有效行。
-
数据还原(Data Restoration): 将受影响的主键索引记录(以及可能存在的聚簇索引数据)的字段值回退(Rollback)到该 UPDATE/DELETE 操作执行之前的状态(即撤销日志中记录的前像值)。
-
回滚完成后的资源管理:
-
成功完成回滚操作后,该事务相关的撤销日志记录即完成使命。
-
这些不再使用的撤销日志页(Undo Log Page)所占用的磁盘空间会被回收(Reclaimed),释放回其所属的撤销日志段(Undo Log Segment)中。这个过程本质上是撤销日志写入时分配空间操作的逆过程,确保空间资源得以复用。
撤销日志的清理过程是怎样的?
InnoDB 通过维护数据行的多版本历史(存储在 Undo Log 中)来实现 MVCC(多版本并发控制)。当一个历史版本被确认不再被任何现有或未来的事务访问时,它就可以被安全地清理(Purge)以释放空间。
其核心清理逻辑基于事务可见性判断:
-
事务标识与读视图:
-
每个事务启动时都会被分配一个唯一的事务 ID (
trx_id
)。 -
当事务执行读操作时,InnoDB 会为其创建一个 Read View。
-
这个 Read View 会记录此刻系统中所有活跃事务的最小 ID (
m_low_limit_id
),它代表了一个“低水位线”。
-
-
可见性判断与清理条件:
-
当需要访问某个数据行的历史版本时,系统会遍历其版本链(由
roll_pointer
链接)。 -
对于链上的每个版本(即每个 Undo Log 记录),检查其关联的
trx_id
:-
如果
trx_id
<m_low_limit_id
,这意味着该版本对应的事务在创建当前 Read View 时已经提交。因此,对于当前读操作来说:-
该版本是可见的(如果它是链上满足条件的最新版本)。
-
更重要的是: 所有比这个版本 更旧 的历史版本(其
trx_id
必然也小于m_low_limit_id
)对于当前读操作(以及所有基于此 Read View 的后续读)都是确定可见或不需要回溯的。
-
-
-
由此推论:如果一个 Undo Log 记录的
trx_id
小于 当前所有可能存在的 Read View 的m_low_limit_id
,那么它所代表的历史版本就绝对不会再被任何现有或未来的事务所访问。此时,这个 Undo Log 记录及其所代表的旧数据版本就满足了清理条件。
-
-
清理执行:
-
Undo Log 的清理工作由专用的后台线程(Purge Thread)负责。
-
主线程扫描确定需要清理的 Undo Log 记录,然后将其分发给实际的清理工作线程。
-
可通过系统变量
innodb_purge_threads
来配置负责执行清理操作的后台线程数量。 -
系统变量
innodb_purge_batch_size
则控制每次清理操作处理的 Undo Log 页数量。 -
(这些参数的调整主要用于优化清理性能和系统吞吐量,此处不深入展开讨论)。
-
我们可以举一个例子
我们发现有一个 Undo Log 记录的
trx_id=80
小于最小活跃事务编号=90,那么代表这个事务id是80的就能被清理掉了。
关于MVCC的相关内容在事务和锁中介绍