Skip to content

Redo Log 与 Undo Log:InnoDB 怎么做到提交不丢、失败可回滚 ​

事务提交时,InnoDB 并没有把改动的数据页写回磁盘,但断电后数据还在;事务执行到一半失败,改过的行又能恢复原样。前者靠 Redo Log,后者靠 Undo Log,两者再配合 Binlog 的两阶段提交,才构成完整的持久性保证。

先看一个在 MySQL 8.4.11 上实测的场景。表里初始只有一行 (1, 100):

  1. 会话 B 开启事务,把第 1 行余额改成 0,再插入 (2, 999),不提交;
  2. 会话 A 插入 (3, 300) 并提交;
  3. 用 docker kill -s KILL 直接杀掉 mysqld 进程,再重启。

重启后查询:

text
+----+---------+
| id | balance |
+----+---------+
|  1 |     100 |
|  3 |     300 |
+----+---------+

已提交的第 3 行在,未提交的修改全部消失。崩溃时,这些改动很可能都还只在内存的数据页里。这篇文章要解释的就是:重启过程中,InnoDB 依据什么把该留的留下、该撤的撤掉。

一、先说结论 ​

  • Redo Log 保证持久性。 它记录「哪个数据页的什么位置改成了什么」,提交时只需把这段日志刷盘,数据页可以稍后再写。崩溃后重放 Redo,就能把页恢复到崩溃前的状态。
  • Undo Log 保证原子性,并支撑 MVCC。 它记录修改前的值,用于回滚未完成的事务,也用于给快照读构造旧版本。
  • 两者是配合关系,不是二选一。 Undo 记录本身写在 undo 表空间的页里,这些页的修改同样受 Redo 保护;崩溃恢复时先重放 Redo,再用 Undo 回滚未提交的事务。
  • 提交时 Redo 与 Binlog 通过两阶段提交保持一致,这决定了复制和按 Binlog 恢复时,replica 和恢复出来的库不会与 source 分叉。
  • MySQL 层的持久性由两个参数决定:innodb_flush_log_at_trx_commit 与 sync_binlog,MySQL 8.4 默认都是 1。实测 mysqld 进程被 SIGKILL 时,设为 1 或 2 都不丢已确认的提交;设为 0 丢了 13 个,而且这些事务还留在 binlog 里。断电后能否不丢,还取决于存储设备和文件系统是否真正完成了刷盘。
  • MySQL 8.0.30 起,Redo 的容量用 innodb_redo_log_capacity 配置,旧的 innodb_log_file_size 与 innodb_log_files_in_group 已被取代。

二、为什么不直接把数据页写回磁盘 ​

一次更新可能只改了一行里的一个字段,但 InnoDB 以页(默认 16KB)为单位管理数据。如果每次提交都要把涉及的数据页写回磁盘:

  • 随机写:不同行分散在不同的页,写入位置随机,在机械盘上尤其慢;
  • 写放大:改几个字节却要写 16KB;
  • 一个事务涉及多个页:写到一半断电,数据文件就处于中间状态。

先写日志的做法(Write-Ahead Logging,WAL)把这三个问题一起解决了:提交时只顺序追加一小段描述修改的 Redo 记录并刷盘,数据页留在内存(Buffer Pool)中,由后台线程合并后再写。

三、一条 UPDATE 的写入路径 ​

语句执行提交后台① 写 undo 记录保存修改前的值② 改 Buffer Pool 页页变成脏页③ 写 redo log buffer记录页的变化两阶段提交④ redo prepare · fsync⑤ binlog 写入 · fsync⑥ redo 写 commit⑦ page cleaner 异步刷脏页,推进 checkpoint
图 1 · 语句执行时只改内存;提交时 redo 与 binlog 通过两阶段提交落盘;数据页由后台线程稍后刷盘

按步骤展开:

  1. 写 Undo 记录:把被修改行的旧值记入 Undo Log,并让行记录的回滚指针指向它。
  2. 修改 Buffer Pool 中的数据页:页变成「脏页」,内存和磁盘上的内容不再一致。
  3. 生成 Redo 记录:描述步骤 1、2 对页的修改,先写入内存中的 Redo Log Buffer。
  4. 提交第一阶段(prepare):InnoDB 把事务标记为 prepare,并把 Redo 刷盘。
  5. 写 Binlog 并刷盘:Server 层把这个事务的 Binlog 事件写入 Binlog 文件。
  6. 提交第二阶段(commit):InnoDB 把事务标记为已提交。
  7. 后台刷脏:page cleaner 线程之后把脏页写回数据文件,并推进 checkpoint,之前的 Redo 空间就可以复用了。

提交成功返回给客户端的时间点在第 6 步之后,第 7 步和客户端无关。

3.1 Redo 记录的是什么 ​

Redo 记录的是页级别的物理修改,例如「表空间 X 的第 Y 页,偏移 Z 处写入这些字节」,某些记录类型在页内部又带有逻辑含义,因此也常被称为「物理逻辑日志」。它不关心 SQL 是什么,只关心页怎么变。

每条 Redo 都有一个单调递增的日志序列号(Log Sequence Number,LSN)。数据页头部也记录着「最后一次修改对应的 LSN」,恢复时据此判断哪些 Redo 已经体现在页上、哪些还需要重放。

3.2 Undo 记录的是什么 ​

Undo 记录的是逻辑上的反向操作:插入对应「删除这一行」,更新对应「把这些列改回旧值」,删除对应「取消删除标记」。

Undo 分两类,生命周期不同:

类型用途何时可以清理
Insert Undo只用于回滚事务提交后即可丢弃,因为新插入的行对其他事务的旧快照本来就不存在
Update Undo回滚,以及为快照读构造旧版本没有任何活跃快照还需要它时,由 purge 线程清理

这就是长事务会让 Undo 膨胀的原因,详见 InnoDB MVCC 与隔离级别。

四、两阶段提交:Redo 和 Binlog 为什么要对齐 ​

InnoDB 的 Redo 管的是本机数据页,Server 层的 Binlog 用于复制和按时间点恢复,复制链路见 MySQL 复制与延迟。如果两者不一致:

  • Redo 提交了、Binlog 没写:source(主库)有这笔数据,replica(从库)和用 Binlog 恢复出来的库没有;
  • Binlog 写了、Redo 没提交:replica 有这笔数据,source 重启后却回滚了。

两阶段提交把 Binlog 的写入夹在 InnoDB 的 prepare 和 commit 之间,崩溃后按下面的规则裁决:

重启重放 redo从 checkpoint LSN 开始检查未结束的事务按 redo 与 binlog 状态已写 commit 标记保留prepare 且 binlog 完整提交未 prepare 或 binlog 缺失用 undo 回滚实测:kill -9 后重启,已提交的插入保留,未提交的更新与插入全部回滚
图 2 · 重启时先用 redo 把页恢复到崩溃前的状态,再按事务在 redo 与 binlog 中的状态决定提交或回滚

核心在第二个分支:事务已经 prepare,并且 Binlog 中有完整的事件,说明它可能已经被复制出去了,必须提交;Binlog 不完整,说明外界不可能看到它,回滚是安全的。开头实验中重启时的日志也能看到这个过程:

text
[Server] Starting XA crash recovery...
[Server] XA crash recovery finished.

为了减少每个事务单独刷盘的开销,MySQL 会把同一时刻提交的多个事务合并为一次刷盘,这就是组提交(group commit)。

五、持久性参数:安全与性能的取舍 ​

在 MySQL 8.4.11 默认配置下查到的值:

text
innodb_flush_log_at_trx_commit   1
sync_binlog                      1
innodb_redo_log_capacity         104857600   (100MB)
innodb_undo_tablespaces          2
innodb_undo_log_truncate         ON
innodb_max_undo_log_size         1073741824  (1GB)
innodb_doublewrite               ON

5.1 innodb_flush_log_at_trx_commit ​

值提交时的行为可能丢失的数据
1(默认)写入并刷盘 Redo不丢已提交事务
2写入操作系统缓存,约每秒刷盘一次mysqld 崩溃不丢;操作系统崩溃或断电可能丢约 1 秒
0不写,约每秒写入并刷盘一次mysqld 崩溃就可能丢约 1 秒

「约 1 秒」受 innodb_flush_log_at_timeout 控制,默认为 1。

5.2 sync_binlog ​

值行为
1(默认)每次提交都把 Binlog 刷盘
0由操作系统决定何时刷盘
N每 N 次组提交刷盘一次

两个参数都是 1 时,每次提交 MySQL 都会要求操作系统把 redo 与 binlog 写到存储上,这是 MySQL 层能给出的最强保证。它能否在断电时兑现,还取决于下面几层:存储设备的写缓存是否有掉电保护(或已关闭)、RAID 卡缓存是否有电池、文件系统与虚拟化层是否如实执行了刷盘。这些要在硬件选型和故障演练中验证,调参本身证明不了。

不同的故障模型,对参数的要求也不同。实测让客户端逐条自动提交并记录每一次成功确认,写入进行中直接 SIGKILL mysqld,再重启比较(sync_binlog 统一设为 0):

innodb_flush_log_at_trx_commit客户端已确认的提交重启后 InnoDB 中的行binlog 中的写入事件
117,57117,57117,571
227,08827,08827,088
037,99137,97837,991
  • 进程崩溃时,1 和 2 都不丢。 设为 2 时 redo 已经写进操作系统的页缓存,进程被杀不影响它;只有操作系统崩溃或断电才会丢,这类故障在容器里无法模拟。
  • 设为 0 时,进程崩溃就会丢。 这一次丢了 13 个已确认的提交(三次重复实验中有一次没有丢,取决于崩溃落在后台每秒刷盘的哪个时刻)。
  • 丢掉的事务还在 binlog 里。 binlog 已写入页缓存,InnoDB 的 redo 却没有,两者出现分叉:replica 会拿到 source 自己已经没有的事务。这正是两阶段提交要避免的情况。

把参数调低能明显提升写入吞吐:同样写 4 秒,设为 1、2、0 时分别确认了约 1.8 万、2.7 万、3.8 万个提交。它适合可以容忍少量丢失、能够重放的场景,比如可重跑的数据导入;核心交易库不建议修改。

还要区分崩溃恢复和备份恢复:这两个参数保证的是 mysqld 或机器崩溃后已提交的事务还在,挡不住误删、错误的批量更新和逻辑错误。那些要靠备份与 binlog 恢复,见 MySQL 误删恢复。

5.3 innodb_redo_log_capacity ​

MySQL 8.0.30 起,Redo 文件放在数据目录的 #innodb_redo 子目录中,InnoDB 始终维护 32 个文件,每个大小为总容量的 1/32。实测目录下能看到正在使用的文件和带 _tmp 后缀的备用文件:

text
#ib_redo10_tmp
#ib_redo11_tmp
...

容量不是越小越省:Redo 空间快用满时,InnoDB 必须加快刷脏页来推进 checkpoint,写入高峰时会表现为吞吐抖动。这个参数可以在线修改:

sql
SET GLOBAL innodb_redo_log_capacity = 8 * 1024 * 1024 * 1024;  -- 演示值,按写入量评估

调整前后可以观察这几个状态变量:

sql
SHOW STATUS LIKE 'Innodb_redo_log_%';
-- Innodb_redo_log_current_lsn、Innodb_redo_log_checkpoint_lsn:两者之差是尚未 checkpoint 的 Redo 量
-- Innodb_redo_log_capacity_resized、Innodb_redo_log_resize_status:在线调整的状态

current_lsn - checkpoint_lsn 长期逼近容量上限,说明 Redo 空间偏小或刷脏跟不上。

5.4 双写缓冲:Redo 解决不了的「页写一半」 ​

Redo 描述的是对页的修改,前提是磁盘上的页本身是完整的。如果写入 16KB 页的过程中断电,页只写了一半(torn page),Redo 无法在一个损坏的页上重放。

双写缓冲(doublewrite buffer)的做法是先把页写到双写区域,再写到数据文件中的真正位置。恢复时如果发现数据页损坏,就从双写区域取回完整副本。MySQL 8.4 默认开启,只有在文件系统本身能保证原子写入时才考虑关闭。

六、Undo 的空间管理 ​

MySQL 8.0 起,Undo 默认存放在独立的 undo 表空间中,不再占用系统表空间。实测可以看到默认的两个:

sql
SELECT FILE_NAME, TABLESPACE_NAME FROM information_schema.FILES WHERE FILE_TYPE = 'UNDO LOG';
-- ./undo_001  innodb_undo_001
-- ./undo_002  innodb_undo_002

innodb_undo_log_truncate=ON 时,undo 表空间超过 innodb_max_undo_log_size(默认 1GB)后会被自动截断。截断的前提是其中的 Undo 已经被 purge,所以长事务依然会让 undo 表空间持续增长,自动截断解决不了这个问题。

排查 Undo 膨胀时关注:

sql
-- 未清理的历史版本数量
SELECT COUNT FROM information_schema.INNODB_METRICS WHERE NAME = 'trx_rseg_history_len';

-- 开始最早的事务
SELECT trx_id, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 5;

七、常见误区 ​

  • 「Redo Log 用来重做未完成的事务」:Redo 重放的是所有已记录的页修改,不区分事务是否完成;未提交的事务随后用 Undo 回滚。
  • 「有了 Binlog 就不需要 Redo」:Binlog 是逻辑日志,不能描述页的物理状态,也不参与 InnoDB 的崩溃恢复。
  • 「Undo Log 提交后就删除了」:Update Undo 要等没有快照需要时才能被 purge。
  • 「innodb_flush_log_at_trx_commit=2 也不会丢数据」:mysqld 进程崩溃不丢,操作系统崩溃或断电会丢。
  • 「两个参数都设成 1 就绝对不丢」:这是 MySQL 层的保证,还要看存储是否如实刷盘;它也挡不住误删,那要靠备份恢复。
  • 「Redo 空间越小越省资源」:空间太小会迫使频繁刷脏,高峰期吞吐抖动。

小结 ​

InnoDB 的持久性由三部分组成:Redo 让提交只需顺序写一小段日志,Undo 让未完成的修改可以撤销,两阶段提交让 Redo 与 Binlog 对同一个事务给出同一个结论。线上需要关心的只有几件事:核心库保持 innodb_flush_log_at_trx_commit 与 sync_binlog 为 1,按写入量设置 innodb_redo_log_capacity,警惕长事务阻塞 purge。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权