Redo Log 与 Undo Log:InnoDB 怎么做到提交不丢、失败可回滚
事务提交时,InnoDB 并没有把改动的数据页写回磁盘,但断电后数据还在;事务执行到一半失败,改过的行又能恢复原样。前者靠 Redo Log,后者靠 Undo Log,两者再配合 Binlog 的两阶段提交,才构成完整的持久性保证。
先看一个在 MySQL 8.4.11 上实测的场景。表里初始只有一行 (1, 100):
- 会话 B 开启事务,把第 1 行余额改成 0,再插入
(2, 999),不提交; - 会话 A 插入
(3, 300)并提交; - 用
docker kill -s KILL直接杀掉 mysqld 进程,再重启。
重启后查询:
+----+---------+
| 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 记录:把被修改行的旧值记入 Undo Log,并让行记录的回滚指针指向它。
- 修改 Buffer Pool 中的数据页:页变成「脏页」,内存和磁盘上的内容不再一致。
- 生成 Redo 记录:描述步骤 1、2 对页的修改,先写入内存中的 Redo Log Buffer。
- 提交第一阶段(prepare):InnoDB 把事务标记为 prepare,并把 Redo 刷盘。
- 写 Binlog 并刷盘:Server 层把这个事务的 Binlog 事件写入 Binlog 文件。
- 提交第二阶段(commit):InnoDB 把事务标记为已提交。
- 后台刷脏: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 之间,崩溃后按下面的规则裁决:
核心在第二个分支:事务已经 prepare,并且 Binlog 中有完整的事件,说明它可能已经被复制出去了,必须提交;Binlog 不完整,说明外界不可能看到它,回滚是安全的。开头实验中重启时的日志也能看到这个过程:
[Server] Starting XA crash recovery...
[Server] XA crash recovery finished.为了减少每个事务单独刷盘的开销,MySQL 会把同一时刻提交的多个事务合并为一次刷盘,这就是组提交(group commit)。
五、持久性参数:安全与性能的取舍
在 MySQL 8.4.11 默认配置下查到的值:
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 ON5.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 中的写入事件 |
|---|---|---|---|
| 1 | 17,571 | 17,571 | 17,571 |
| 2 | 27,088 | 27,088 | 27,088 |
| 0 | 37,991 | 37,978 | 37,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 后缀的备用文件:
#ib_redo10_tmp
#ib_redo11_tmp
...容量不是越小越省:Redo 空间快用满时,InnoDB 必须加快刷脏页来推进 checkpoint,写入高峰时会表现为吞吐抖动。这个参数可以在线修改:
SET GLOBAL innodb_redo_log_capacity = 8 * 1024 * 1024 * 1024; -- 演示值,按写入量评估调整前后可以观察这几个状态变量:
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 表空间中,不再占用系统表空间。实测可以看到默认的两个:
SELECT FILE_NAME, TABLESPACE_NAME FROM information_schema.FILES WHERE FILE_TYPE = 'UNDO LOG';
-- ./undo_001 innodb_undo_001
-- ./undo_002 innodb_undo_002innodb_undo_log_truncate=ON 时,undo 表空间超过 innodb_max_undo_log_size(默认 1GB)后会被自动截断。截断的前提是其中的 Undo 已经被 purge,所以长事务依然会让 undo 表空间持续增长,自动截断解决不了这个问题。
排查 Undo 膨胀时关注:
-- 未清理的历史版本数量
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。
配套实验
- codesphere-labs/storage/mysql-redo-undo-recovery:
SIGKILL后的崩溃恢复、默认持久化参数、redo 文件与 undo 表空间,以及刷盘参数为 1、2、0 时进程崩溃丢失的已确认提交(验证记录)
参考资料