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 的完整旅程
- 目标数据页读进 buffer pool(16 KB 页,上两章的主角)
- 在内存里改页,该页成为脏页,进 flush list
- “哪一页哪个偏移改成了什么”写进 redo log buffer(内存)
- 事务提交: redo log buffer 落盘(fsync) <- 唯一必须等的磁盘 IO
- 脏页由后台线程在之后某个时刻批量刷回磁盘
提交路径上只有一次顺序小写,这就是数据库敢承诺”提交即持久”又不慢的原因。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 log | binlog | |
|---|---|---|
| 属于谁 | 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 内部走两阶段:
- redo 写入,标记为 prepare 状态
- binlog 写入并落盘
- redo 标记 commit
崩溃恢复的裁决规则:
redo 已 commit -> 提交
redo 只 prepare,binlog 完整有这条 -> 补提交(以 binlog 为准)
redo 只 prepare,binlog 没有 -> 回滚
裁决权给 binlog 的原因:binlog 一旦写成,可能已经被从库拉走重放了,主库只能跟随它,不能反悔。三本日志的展开在 日志。
4. undo log:日志家族的第三员
undo 记的是逻辑反操作(插入记删除、更新记旧值),两个用途:
- 回滚: 事务失败,沿 undo 链把改动逐条倒放回去
- 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. 学完本章你能解决什么问题
- WAL 把什么代价换成了什么代价,物理依据是什么?
- 提交一个事务,磁盘上必须等的是哪一次写,三档参数各在什么故障域丢数据?
- write pos、checkpoint、LSN 怎么配合完成崩溃恢复?
- doublewrite 在补哪个机制的什么漏洞?
- binlog 三种格式的取舍是什么,两阶段提交的裁决规则为什么以 binlog 为准?
- AOF 为什么是”写后日志”也没问题,重写和混合持久化各解决什么?
- “快照 + 增量日志”配方在哪些系统里复现?
- Kafka 的高吞吐是哪三件事叠出来的,它的持久性押在哪?
- LSM 树和 B+ 树的读写路径怎么互换了代价?
- 哪些日志循环写、哪些追加写,判据是什么,两派各有什么共同病?
- AOF 的两级缓冲对应哪两个丢失窗口,everysec 为什么会被慢盘卡成全局停顿?
核心一句话:日志先行用一次顺序追加换掉多次随机写,同时白送崩溃恢复;快照负责压缩历史,日志负责记录增量;每个系统的持久化参数都是同一根”丢失窗口换写延迟”的滑杆,读懂一根就读懂了全家。