write - ahead - log

04. 日志先行:WAL、redo、AOF 与消息队列的共同根

0. 本章先解决什么问题

MySQL 有 redo log、undo log、binlog,Redis 有 AOF 和 RDB,Kafka 干脆整个就是日志,文件系统还有 journal。我背这些的时候一度觉得存储系统怎么都在写日志,写这么多日志不慢吗。本章把这一族机制归到一个根上,然后把每个成员单独讲透:

随机写很贵,顺序追加很便宜。
所以先把”发生了什么”顺序追加到日志并落盘,
数据本体可以慢慢改、慢慢刷;
崩溃了不怕,重放日志就能回到崩溃前。

这个思想叫日志先行(Write-Ahead Logging,WAL)。

日志先行的通用结构

这张图怎么读

左边是没有日志的世界:每次修改直接改数据本体,随机 IO 又慢又怕崩溃。右边是日志先行的世界:修改先顺序追加进日志,数据本体在内存里慢慢改,后台批量刷盘;崩溃恢复走”重放日志”这条虚线。

1. 物理前提:顺序写和随机写差多少

文件系统 讲过磁盘的成本结构,这里只留数字:

  • 机械盘:一次寻道 + 旋转约 10 ms,每秒只够一两百次随机 IO;
    顺序吞吐却有 100~200 MB/s。
    随机写几百次的时间,顺序写能推完上百 MB
  • SSD:没有寻道,但上一章讲过它按擦除块工作,
    随机小写触发写放大和 GC 搬运,顺序大块写依然显著更友好

差距两三个数量级。所以所有想快的持久化系统都做同一个变形:把”改磁盘上某个位置”翻译成”在文件末尾追加一条记录”。剩下的全部复杂度,都是为这个变形收尾:数据本体没立刻改,怎么保证读到新值、崩溃不丢、空间不爆炸。

2. InnoDB redo log:WAL 的教科书实现

2.1 一条 UPDATE 的完整旅程

  1. 目标数据页读进 buffer pool(16 KB 页,上两章的主角)
  2. 在内存里改页,该页成为脏页,进 flush list
  3. “哪一页哪个偏移改成了什么”写进 redo log buffer(内存)
  4. 事务提交: redo log buffer 落盘(fsync) <- 唯一必须等的磁盘 IO
  5. 脏页由后台线程在之后某个时刻批量刷回磁盘

提交路径上只有一次顺序小写,这就是数据库敢承诺”提交即持久”又不慢的原因。redo 是物理日志(记页号 + 偏移 + 新值),重放时不需要理解 SQL,拿着页直接套。

2.2 刷盘三档:innodb_flush_log_at_trx_commit

这个参数是理解”持久性到底押在哪”的钥匙,三档语义要精确:

  • 1(默认):每次提交都写入并 fsync 到磁盘
    -> MySQL 崩、OS 崩、断电,已提交事务都不丢
  • 2:每次提交写进 OS 的 page cache,每秒才 fsync 一次
    -> MySQL 进程崩不丢(数据已在内核),OS 崩/断电丢最近约 1 秒
  • 0:每秒才写入并 fsync 一次
    -> 连 MySQL 进程崩都可能丢最近约 1 秒

区分”进程崩”和”机器崩”两种故障域,是读懂所有持久化参数的通用姿势,后面 AOF 的三档和它一一对应。

2.3 循环文件、LSN 和 checkpoint

redo 文件是固定大小的环形空间,三个游标在环上追逐:

write pos: 日志写到哪了(一直往前)
flushed lsn: 落盘到哪了
checkpoint: 脏页刷盘的进度线。
这条线之前的日志对应的脏页已全部落盘,空间可以复用
write pos 追上 checkpoint = 环满了
-> 数据库被迫停下来先刷脏页推进 checkpoint,表现为写入猛烈抖动

LSN(日志序列号)是全库单调递增的字节计数,页头也记着”本页最后修改的 LSN”。崩溃恢复的算法因此极简:从 checkpoint 开始扫日志,凡日志 LSN 大于页上 LSN 的就重放,小于的说明页已经是新的,跳过。

2.4 组提交:批处理思想的第一次客串

高并发下每个事务都单独 fsync 太奢侈,InnoDB 把同一时刻在等的多个事务的 redo 合成一次 fsync(binlog 同样有组提交,还按提交批次给事务标了号,这个编号在下一章成为并行复制的依据)。一次刷盘平摊给几十个事务,这是第 11 章批处理思想在本章的预演。

2.5 doublewrite:补定长块的非原子性

上一章留的尾巴在这里收:16 KB 页刷盘时底层是 4 个文件系统块,断电可能只写了一半。redo 是物理日志,重放需要一个完整的旧页做底子,半页损坏连底子都没了。所以刷脏页时先把整页顺序写进 doublewrite buffer 区域并落盘,再写目标位置:

崩在 doublewrite 阶段 -> 目标位置还是完整旧页,redo 重放没问题
崩在写目标位置阶段 -> doublewrite 里有完整新页,直接拿来修复
两次写代价可控: doublewrite 是顺序写,且整批页一起写

3. binlog:Server 层的逻辑日志

3.1 和 redo 的分工

redo logbinlog
属于谁InnoDB 引擎层Server 层,所有引擎共用
形态物理(页 + 偏移 + 值)逻辑(SQL 或行变化)
写法环形循环,不保历史追加写,按大小滚动新文件
用途崩溃恢复复制给从库、按时间点恢复

3.2 三种格式

  • statement:记 SQL 原文。省空间,但 now()、uuid()、
    limit 不带 order by 这类语句在从库重放结果可能不同
  • row(默认):记每一行的前后镜像。结果确定,代价是
    一条 UPDATE 影响百万行就记百万行变化,日志暴涨
  • mixed:平时 statement,检测到不确定语句自动切 row

下一章会讲到,状态机复制要求操作确定性,row 成为默认就是在保这条。binlog 自己的刷盘参数 sync_binlog(1 = 每次提交 fsync,N = 攒 N 次,0 = 交给 OS)和 redo 的三档同一套逻辑。“双 1 配置”指 flush_log_at_trx_commit=1 加 sync_binlog=1,最安全也最慢。

3.3 两阶段提交:两本日志的一致性

一次提交两本日志都要写,崩在中间就会出现”redo 说提交了、binlog 里没有”,从库和主库从此分叉。所以 InnoDB 内部走两阶段:

Pasted image 20260802195139
  1. redo 写入,标记为 prepare 状态
  2. binlog 写入并落盘
  3. redo 标记 commit
    崩溃恢复的裁决规则:
    redo 已 commit -> 提交
    redo 只 prepare,binlog 完整有这条 -> 补提交(以 binlog 为准)
    redo 只 prepare,binlog 没有 -> 回滚
    裁决权给 binlog 的原因:binlog 一旦写成,可能已经被从库拉走重放了,主库只能跟随它,不能反悔。三本日志的展开在 日志

4. undo log:日志家族的第三员

undo 记的是逻辑反操作(插入记删除、更新记旧值),两个用途:

  1. 回滚: 事务失败,沿 undo 链把改动逐条倒放回去
  2. MVCC: 每行的隐藏列 roll_ptr 指向它的历史版本链,
    快照读沿链找自己该看的版本

严格说 undo 不是 WAL(它自己反而受 redo 保护,改 undo 页也要先记 redo),但它证明了”日志”这个形态的第三种用途:不只恢复和复制,还能当版本仓库。MVCC 的完整机制放在第 10 章多版本里展开。

5. Redis:AOF 是日志,RDB 是快照

5.1 AOF:命令级逻辑日志

每条写命令执行成功后先进 aof_buf 缓冲,落盘要过两级缓冲,对应两个丢失窗口:

完整链路: 命令执行 -> aof_buf(Redis 用户态缓冲)
-> 每轮事件循环睡前 write 进 page cache(内核缓冲)
-> 按 appendfsync 策略 fsync 落盘
窗口一: 还在 aof_buf 里没 write 的,Redis 进程崩就丢
窗口二: write 了没 fsync 的,OS 崩或断电才丢
(第 3 章缓存与缓冲的两级结构在这里原样复现,
参数管的只是第二级的节奏)

appendfsync 三档:

  • always:每条命令都 fsync。最多丢一条,QPS 直接被磁盘按在地上
  • everysec(默认):每秒由后台线程 fsync 一次。崩了丢最近约 1 秒
    一个暗坑: 主线程发现上一次 fsync 超过 2 秒还没完成时,
    会阻塞等它,于是慢盘能把 everysec 卡成全局停顿
    (“Redis 每秒卡一下”排查清单里的那条就是这个机制)
  • no:只 write 不主动 fsync,交给 OS。丢多少看 OS 心情

和 redo 三档逐格对应,同一根”丢失窗口换写延迟”的滑杆。一个诚实的辨析:AOF 是”写后日志”,命令先执行成功才追加,顺序和 WAL 相反。Redis 敢反着来,因为数据本体在内存,不存在”页改一半崩掉”的损坏问题,日志只承担恢复职责,不承担原子性职责。同族机制,约束不同就长得不同。

5.2 AOF 重写:对付无限长

AOF 记的是过程,同一个 key 改一万次就是一万条记录,恢复时全要重放。重写(bgrewriteaof)按当前终态生成最小命令集:

fork 子进程,按此刻数据快照写一份新 AOF
(fork 的写时复制机制在第 10 章展开)
期间父进程的新写命令进重写缓冲,子进程写完后追加上去,原子替换
7.0 的 Multi-Part AOF: 一个 manifest 清单管着
一个 base 文件(重写产物,实际是 RDB 格式)+ 若干增量 AOF 文件
重写不再需要”把缓冲追加进新文件”的复杂缝合,切文件就行

5.3 RDB:快照派

  • save:主线程直接写快照,阻塞全部请求,基本只用于关机前
  • bgsave:fork 子进程写,父进程靠写时复制继续服务
  • 触发:手动命令、配置的 save 规则(如 900 秒内 1 次修改)、
    主从全量同步时(下一章会用到)
  • 特点:二进制紧凑、恢复快;但两次快照之间的写全在丢失窗口里

5.4 混合持久化:快照 + 增量的标准配方

4.0 引入、5.0 默认开启(aof-use-rdb-preamble):重写时 base 直接用 RDB 格式(快、小),之后的增量继续 AOF 追加。恢复 = 载入 RDB 底 + 重放 AOF 尾巴。这个结构值得单独记住,因为它是通用形态:

快照压缩历史,日志记录增量 = 恢复速度和丢失窗口双赢
ZooKeeper 的 snapshot + txn log 同一配方
下一章 Redis 复制的”全量 RDB + 增量命令流”还是同一配方

细节在 yori - 持久化 & 集群 &哨兵

6. Kafka:把日志从后台机制扶正成产品本体

6.1 日志就是数据

前面的系统里日志是数据本体的影子,Kafka 反过来:partition 就是一个只追加的日志(上一章拆过它的段文件和稀疏索引)。生产是追加,消费是顺序读,删除是整段删文件,读写路径里没有任何随机修改。

6.2 零拷贝:顺序读的极致兑现

消费者拉取历史数据时,传统路径要四次拷贝两次系统调用:

  • 传统:磁盘 -> 页缓存 -> 应用缓冲区 -> socket 缓冲区 -> 网卡
  • sendfile:磁盘 -> 页缓存 -> 网卡(DMA 直送,数据不进用户态)

因为 Kafka 不需要看消息内容(存的就是要发的字节),才有资格用 sendfile 让数据从页缓存直达网卡。配合”缓存全交给 OS 页缓存”(第 3 章讲过它是页缓存的重度依赖者),单机几十万条每秒的吞吐就是这三件事叠出来的:顺序写、页缓存、零拷贝。

6.3 持久性押在复制上,不押在 fsync 上

一个反直觉的设计:Kafka 默认不主动 fsync(flush.messages 默认不设),消息写进页缓存就算写入。
单机断电确实会丢页缓存里的数据,Kafka 的回答是:持久性靠多副本(acks=all 时消息已经在多台机器的内存里),整个机房同时断电才会丢,那个概率比单机磁盘故障低。
这是”同一个可靠性预算,花在 fsync 还是花在副本上”的路线分歧,下一章复制会接着讲。

7. 同族机制点名

7.1 文件系统 journal

ext4 的日志有三档模式,又是同一根滑杆:

  • journal:元数据和数据都先进日志,最安全最慢
  • ordered(默认):只有元数据进日志,但保证数据先于元数据落盘
  • writeback:只记元数据,顺序也不保证,崩溃后文件可能新旧混杂

7.2 LSM 树:把顺序写贯彻到底

RocksDB、LevelDB、HBase 的存储引擎把 WAL 思想推到极致,连数据本体都是顺序写出来的:

  • 写路径:追加 WAL,写入内存表 memtable,随后即可返回;memtable 满后再整体顺序刷成不可变的 SSTable 文件。
  • 读路径:先查 memtable,再逐层查找 SSTable;每个文件内部有序,并用布隆过滤器加速排除。
  • 后台 compaction:归并、重写多层 SSTable,同时清掉已经被覆盖的旧值。
  • 三角代价:compaction 反复重写带来写放大,多层查询带来读放大,旧值等待合并带来空间放大;调参就是在这三项之间移动成本。
  • 与 B+ 树对照:B+ 树读路径短但写入包含随机页修改;LSM 写路径几乎全是顺序追加,但读路径更长。写多读少更偏向 LSM,读多写少更偏向 B+ 树。

7.3 ZooKeeper 与 etcd

ZooKeeper 每个写请求先追加事务日志再应答,配 snapshot 定期压缩,标准的”快照 + 增量”。etcd 的 Raft 日志本身就是 WAL,且这份日志同时承担复制和共识职责,是”日志多重身份”的集大成者。

7.4 循环写与追加写:全家的空间管理只有两种

日志文件不能无限长,怎么管空间,全家分成两派,判据只有一条:这份日志写给谁消费。

循环写(固定空间,绕圈覆盖):
redo log: 只服务崩溃恢复,checkpoint 之前的日志已经没有读者,
覆盖掉天经地义
repl_backlog(第 5 章的环形缓冲): 只服务断线重连那个窗口,
环的大小就是窗口的大小
undo 的回滚段: purge 确认没有读者(旧事务全结束)就复用
共同点: 消费者只有”最近一段”的需求,历史无价值
共同病: 环的容量决定追赶窗口,写太猛追尾就出事
(redo 追上 checkpoint 被迫刷脏抖动、
backlog 被覆盖触发全量同步风暴,一根病根两个症状)

追加写(无限增长,滚动分段,按策略清历史):
binlog: 从库和按时间点恢复都要重放历史,不能覆盖,
按保留天数删旧文件
AOF: 恢复要从头重放,靠 rewrite 按当前终态压缩历史
Kafka: 消费者随时可能回溯,按 retention 时间/大小删段,
或按 key 压缩(compact,同 key 只留最新)
共同点: 有外部读者依赖历史,清理策略就是和读者的约定
共同病: 清理跟不上写入,磁盘账单爆炸

一句话收拢:日志只给自己恢复用的,循环写;要给别人(从库、消费者、未来的恢复点)重放的,追加写加历史压缩。看到一个新系统的日志,先问这条,空间管理的设计就能倒推出来。

8. 日志的第三重身份:复制的载体

到这里日志已有两个身份:性能优化(顺序写)和崩溃保险(重放恢复)。第三个身份重要到值得预告:

日志是一份”发生过什么”的完整有序记录。
把它发给另一台机器重放一遍,那台机器就成了副本。
MySQL 复制的是 binlog,Redis 传播的是写命令流,
Kafka 的 follower 拉的就是日志本身。

这是下一章主从复制的全部基础。一个机制攒下的资产被另一个机制复用,这种复利是底层设计的常态。

9. 联系实际:日志视角的排查清单

  • 现象:MySQL 写入周期性猛烈抖动

  • 方向:write pos 追上 checkpoint,被迫同步刷脏页;
    redo 空间调大,或查为什么脏页刷不动(IO 能力、脏页比例参数)

  • 现象:Redis 每秒卡一小下

  • 方向:AOF everysec 的 fsync 撞上慢盘;或恰逢重写的 fork 瞬间
    (fork 耗时和内存量成正比,第 10 章讲为什么)

  • 现象:断电后 MySQL 起来前滚了几分钟

  • 方向:正常 crash recovery,在重放 checkpoint 之后的 redo;
    checkpoint 越落后恢复越久

  • 现象:主库磁盘坏了,从库数据也缺最后几秒

  • 方向:复制的是 binlog,binlog 组提交批次还没发出去;
    丢失窗口问题进下一章

  • 现象:Kafka 明明写磁盘,为什么比写库快得多

  • 方向:纯顺序追加 + 页缓存 + 零拷贝,没有 B+ 树的随机页修改

通用配置直觉: 所有持久化系统都有”每次写都 fsync 吗”的旋钮
(flush_log_at_trx_commit / sync_binlog / appendfsync / acks)
拧的是同一根滑杆: 丢失窗口 vs 写延迟;
读参数时先问”进程崩”和”机器崩”两个故障域各丢多少

10. 学完本章你能解决什么问题

  1. WAL 把什么代价换成了什么代价,物理依据是什么?
  2. 提交一个事务,磁盘上必须等的是哪一次写,三档参数各在什么故障域丢数据?
  3. write pos、checkpoint、LSN 怎么配合完成崩溃恢复?
  4. doublewrite 在补哪个机制的什么漏洞?
  5. binlog 三种格式的取舍是什么,两阶段提交的裁决规则为什么以 binlog 为准?
  6. AOF 为什么是”写后日志”也没问题,重写和混合持久化各解决什么?
  7. “快照 + 增量日志”配方在哪些系统里复现?
  8. Kafka 的高吞吐是哪三件事叠出来的,它的持久性押在哪?
  9. LSM 树和 B+ 树的读写路径怎么互换了代价?
  10. 哪些日志循环写、哪些追加写,判据是什么,两派各有什么共同病?
  11. AOF 的两级缓冲对应哪两个丢失窗口,everysec 为什么会被慢盘卡成全局停顿?

核心一句话:日志先行用一次顺序追加换掉多次随机写,同时白送崩溃恢复;快照负责压缩历史,日志负责记录增量;每个系统的持久化参数都是同一根”丢失窗口换写延迟”的滑杆,读懂一根就读懂了全家。

延伸阅读