Skip to content

Redo Log ​

一条 UPDATE 执行完,COMMIT 返回「成功」——这时数据真的写进磁盘了吗?如果立刻断电,这条修改还在吗?InnoDB 的回答是「在」。它靠的不是把每一行数据都刷进表空间,而是一份按顺序追加的小日志:Redo Log。理解了它,就理解了「为什么事务提交后数据不丢」和「为什么 MySQL 的写性能可以接受」这两个问题其实共享同一个答案。

1. 为什么需要 Redo Log ​

1.1 一个「断电后数据还在」的现象 ​

先看一个场景。执行一条更新,然后立刻拔掉服务器电源:

sql
BEGIN;
UPDATE accounts SET balance = 1000 WHERE id = 1;
COMMIT;  -- 返回成功
-- 此刻断电

重启后,id=1 的 balance 是 1000 还是原来的值?答案是 1000——只要事务返回了提交成功,这条修改就不会因为断电而丢失。

但直觉上这是反常识的:磁盘写入有先后,COMMIT 返回时,那行数据所在的 16KB 数据页可能还躺在内存的 Buffer Pool 里,根本没写回磁盘。为什么它「承诺」的数据还能在断电后找回来?

答案分两层:数据页靠 Redo Log 重放找回来,Redo Log 本身靠顺序写保证在 COMMIT 前真正落盘。下面先把「为什么需要一份单独的日志」讲清楚。

1.2 为什么不能直接把数据页写回磁盘 ​

先退一步问:没有 Redo Log,MySQL 能不能保证持久性?

能,但代价很高。数据页的存放位置是分散的——一条 UPDATE 可能同时改到聚簇索引页、二级索引页、回滚段页,这些页在表空间文件里彼此相距很远。每提交一个事务,都要把这些页逐个「随机写」回磁盘,而随机写(磁头寻道 / SSD 随机小写)是磁盘上最慢的操作。

更麻烦的是「部分写」:一个 16KB 的页只写了一半就断电,这一半数据既不是旧值也不是新值,整页就废了。

Redo Log 的思路是绕开这两点:修改发生时,不急着把数据页落盘,而是先把「改了什么」按顺序追加到一份日志里。 顺序追加是磁盘最快的写入方式,一次 COMMIT 只需要把一小段日志追加写进文件末尾,而不是把一堆散落的数据页随机写回。数据页可以晚点、甚至批量地异步刷盘。

这就是 WAL(Write-Ahead Logging,先写日志)的核心动机:用一次廉价的顺序写,换取「修改已持久化」的承诺,把昂贵的随机写推迟到后台批量完成。

1.3 WAL 原理:先写日志,再写数据 ​

WAL 规则只有一条:在把数据页刷盘之前,必须先把它对应的日志记录刷盘。

顺序很重要,反了就失效。设想「先刷数据页、后写日志」:数据页已经落盘了,日志还没写,此时断电,日志里没有这次修改的记录——但数据页已经在磁盘上了,表面看没丢。问题出在「日志是唯一可信的重放依据」这一点:崩溃恢复时,InnoDB 只相信日志,用日志去校准数据页。如果数据页写在了日志前面,恢复时就无法判断这个页到底该是旧值还是新值。

反过来「先写日志、后刷数据页」就自洽了:

顺序是:① Buffer Pool 改页 → ② 写 Redo 到 Log Buffer → ③ 提交时按策略把日志刷盘 → ④ 数据页由后台线程异步刷盘。

关键在于:第 2 步的日志一旦落盘,第 4 步的数据页哪怕永远不写,崩溃后也能靠日志重放出来。 数据页落盘晚一点没关系,日志落盘才是「提交成功」的真正边界。

2. 日志的物理基础 ​

2.1 LSN:Redo Log 的坐标轴 ​

要讲清崩溃恢复和 Checkpoint,得先引入一个贯穿全篇的量:LSN(Log Sequence Number,日志序列号)。

LSN 是 Redo Log 的字节偏移量,单调递增。每写一个字节的日志,LSN 就加一。它有三个关键落脚点:

  • 全局当前 LSN:Redo Log 写到了哪个字节位置。
  • 数据页上的 FIL_PAGE_LSN:每个数据页的 File Header 里记录「我最后一次被修改时,对应的日志 LSN 是多少」。
  • Checkpoint LSN:已刷盘数据页对应的最大 LSN。

这三个值一比较,就能回答「这个数据页需不需要重放」:如果某页的 FIL_PAGE_LSN 小于某条日志记录的 LSN,说明这条日志对应的修改还没刷进这个页,崩溃恢复时就要重放。

用 LSN 这把尺子,Checkpoint 和崩溃恢复就有了统一的度量,不再需要逐页猜测。

2.2 Redo Log 的物理结构与记录格式 ​

Redo Log 在磁盘上的形态,随 MySQL 版本有变化:

sql
-- MySQL 8.0.30 之前:固定大小的一组文件
SHOW VARIABLES LIKE 'innodb_log_file_size';        -- 单个文件大小
SHOW VARIABLES LIKE 'innodb_log_files_in_group';   -- 文件个数

-- MySQL 8.0.30 之后:统一容量,自动管理,存于 #innodb_redo 目录
SHOW VARIABLES LIKE 'innodb_redo_log_capacity';    -- 总容量

文件被组织成一个循环缓冲区:写满一圈就回到开头覆盖。覆盖的前提是「被覆盖的那部分日志对应的数据页已经刷盘」,这正是 Checkpoint 的职责(见 §3.2)。

每条 Redo Log 记录本身是「物理日志」——记录的不是 SQL,而是「哪个页的哪个位置改成了什么」:

MLOG_1BYTE、MLOG_2BYTE 这类类型名表明它精确到「字节级」:崩溃恢复时不需要重算,照着记录把字节写回指定位置即可。这种「物理」特性是 Redo Log 能快速重放的原因,也是它和 Binlog(逻辑日志)最本质的区别。

2.3 MTR:保证「一组修改」的原子性 ​

一条 SQL 往往涉及多处修改,比如插入一条记录,要同时改:

  1. 数据页(写入这条记录);
  2. 页的 Page Header(记录数 +1);
  3. 可能还有系统页(更新 MAX_TRX_ID)。

这三处修改必须「要么都恢复,要么都不恢复」。如果崩溃发生在写了一半的时候,只恢复了数据页的插入、没恢复 Page Header 的计数,页就处于自相矛盾的状态。

InnoDB 用 MTR(Mini-Transaction,迷你事务) 解决:MTR 把「一组逻辑上不可分割的修改」打包成一批 Redo Log 记录,这一批作为一个整体写入。崩溃恢复时,MTR 的日志要么整段重放,要么整段跳过,不会出现「重放到一半」的中间态。

可以把 MTR 理解为 Redo Log 层的原子单位——事务(Transaction)是业务层的原子单位,MTR 是物理层的原子单位,一个事务通常由多个 MTR 组成。

3. 刷盘与空间回收 ​

3.1 Log Buffer 与刷盘策略 ​

日志不是一条一条直接写磁盘的。修改产生后,Redo Log 记录先写入内存里的 Log Buffer,再按策略批量刷盘:

sql
SHOW VARIABLES LIKE 'innodb_log_buffer_size';  -- Log Buffer 大小,默认 16MB

Log Buffer 刷盘的时机有四种:事务提交时、Log Buffer 写满一半时、后台线程每秒一次、Checkpoint 时。其中「事务提交时怎么刷」由参数 innodb_flush_log_at_trx_commit 控制,这是全篇最需要理解的一个参数:

取值提交时行为断电后可能丢多少数据
0不主动刷,仅靠后台每秒刷最近 1 秒 的事务
1每次提交都刷盘(fsync)0(提交即落盘)
2每次提交写 OS 缓存,靠 OS 每秒刷最近 1 秒 的事务

三个值的差异,本质在「刷」这个词落到哪一层:

理解数据丢失边界的关键:值 0 和 2 都可能丢「最近 1 秒」的数据,区别在于什么故障才会丢。

  • 值 0:事务提交了,日志还停在 Log Buffer,MySQL 进程崩了(不只是断电)就直接没了。
  • 值 2:日志已经交给 OS 缓存,MySQL 进程崩了不影响(OS 缓存还在),只有整机断电才可能丢 OS 缓存里那 1 秒。
  • 值 1:fsync 返回后,日志已在磁盘物理扇区上,MySQL 崩溃、断电都不丢。

所以「值 0 和值 2 都丢 1 秒」是简化说法,严谨的边界是:值 0:进程崩溃、整机断电都会丢;值 2:进程崩溃不丢,只有整机断电才丢;值 1:都不丢(代价是每次提交都 fsync,性能最差)。

生产环境的常见选择:默认 1 保安全;对性能极度敏感、又能容忍 1 秒丢失的日志类业务,可降为 2;0 基本不推荐。

3.2 Checkpoint:Redo Log 的空间回收 ​

Redo Log 是循环写的,容量有限。它覆盖旧日志的前提,是「旧日志对应的数据页已经刷盘」。这个「刷盘推进到哪里」的标记,就是 Checkpoint。

Checkpoint 做的事:把 Buffer Pool 里「LSN 小于某个值」的脏页批量刷盘,然后把 Checkpoint LSN 推进到那个值。推进之后,Checkpoint LSN 之前的日志就可以安全覆盖了。

它直接决定了两个关键指标:

  • 崩溃恢复速度:Checkpoint 推进得越勤,恢复时要重放的日志越少,恢复越快;但刷盘越频繁,正常运行时开销越大。
  • Redo Log 会不会写满:如果脏页刷盘速度跟不上日志产生速度,Checkpoint LSN 停住不动,未刷盘部分越积越多,最终填满整个 Redo Log。一旦写满,InnoDB 只能暂停写入、强制刷脏页,表现为写入吞吐骤降。

这正是「为什么不能有长事务、不能有长时间刷不动脏页」的底层原因之一:Redo Log 空间被未刷盘的日志占满,写路径被 Checkpoint 卡死。

4. 崩溃恢复 ​

4.1 崩溃恢复的完整流程 ​

把前面几节串起来,MySQL 崩溃重启后做这样几件事:

第 3 步里 FIL_PAGE_LSN 的比较,正是 §2.1 那把尺子的落地:数据页上记的 LSN 比日志旧,说明日志对应的修改没刷进页里,需要重放;比日志新或相等,说明页已经是新状态,跳过即可。这个判断让恢复变得幂等——重复重放同一段日志也不会出错。

第 4 步引出了 Redo 与 Undo 的分工:Redo 负责「把已提交的修改找回来」,Undo 负责「把未提交的修改抹掉」。 崩溃时,日志里既有已提交事务的修改,也有未提交事务的修改,重放会把两者都恢复出来,再由 Undo 回滚掉未提交的那部分。Undo 的机制见 Undo Log。

5. 与 Binlog 的关系 ​

5.1 两阶段提交:Redo Log 与 Binlog 的一致性 ​

Redo Log 只解决「InnoDB 自己崩溃不丢数据」。但 MySQL 还有第二份日志——Binlog(Server 层,用于主从复制和按时间点恢复)。于是每个事务的提交,都要同时保证两份日志一致:Redo Log 里有,Binlog 里也得有。

难点在于:写两份日志是两次独立的磁盘写,崩溃可能发生在「只写完了一份」的瞬间。如果不管先后顺序,都会出问题:

两种失败的后果正好相反:方案一断电,主库靠 Redo 恢复了这笔、Binlog 里没有,从库少一笔;方案二断电,主库回滚了这笔、Binlog 里已有,从库多一笔。

无论哪种固定顺序,断电都会打破「主库和从库看到同一批已提交事务」的约定。两阶段提交的解法,是把这个「提交」拆成两段,并在崩溃恢复时根据 Binlog 的状态反推该不该提交:

为什么这个规则能保证一致?核心观察是:Binlog 的写入是原子标志。 只要 Binlog 完整落地,就说明这个事务「对外已经算提交」了,从库迟早会重放到它,主库必须跟着提交;只要 Binlog 没落地,就说明事务还没对外生效,主库回滚它,从库也不会知道。于是无论崩溃发生在 prepare 之后还是 commit 之前,主从最终都收敛到同一状态。两阶段提交因此把「要不要提交」的裁决权交给了 Binlog:Redo Log 的 prepare 状态只是「先占个位,等 Binlog 的结果再定去留」。

Binlog 的格式、事件类型与参数见 Binlog。

5.2 Redo Log 与 Binlog 的区别 ​

对比项Redo LogBinlog
所属层InnoDB 存储引擎层MySQL Server 层
记录内容物理日志(哪个页哪段字节改成什么)逻辑日志(SQL 语句或行变更)
写入方式循环写,固定容量追加写,文件轮转
主要用途崩溃恢复主从复制、按时间点恢复
事务边界无明确的 BEGIN/COMMIT 标记有明确事务边界与 XID

6. 最佳实践 ​

  1. 数据安全优先用「双 1」:innodb_flush_log_at_trx_commit=1 + sync_binlog=1,提交即落盘,崩溃不丢。
  2. Redo Log 容量设置:写密集型业务给足空间(如 2G-4G),避免写满触发 Checkpoint 卡顿。
  3. SSD 环境:Redo Log 与数据文件同盘即可,顺序写的优势在 SSD 上依然成立。
  4. 监控写入量:SHOW GLOBAL STATUS LIKE 'Innodb_os_log_written'; 观察日志产生速率。
  5. 避免长事务与脏页堆积:长事务让 Undo 无法清理、脏页迟迟刷不干净,间接拖慢 Checkpoint。

Released under the MIT License.