一、为什么需要预写日志
数据库正在处理一笔转账:从账户 A 扣 100 元,给账户 B 加 100 元。扣款成功了,加款还没执行——断电了。重启之后,A 少了 100 元,B 没收到钱,100 元凭空消失了。
没有日志的系统中,崩溃恢复只能靠运气:如果状态修改恰好完成了,数据就是对的;如果修改进行到一半被打断,状态就是不一致的。而且你根本无法判断哪些修改完成了、哪些没有——因为没有任何记录。
这里有一个很多人容易忽略的细节:write() 成功返回并不等于数据已经安全落盘。应用程序调用 write() 时,操作系统只是把数据从用户空间拷贝到了内核的页面缓存(Page Cache)中,然后就返回了。数据此刻还躺在内存里,断电就没了。要确保数据真正写入磁盘的物理扇区,必须调用 fsync()(或者在打开文件时使用 O_SYNC 标志)。这个 write() 和 fsync() 之间的间隙,是 WAL 实现中最容易踩坑的地方。
更微妙的是,崩溃的类型不同,数据丢失的风险也不同。如果只是进程崩溃(比如 OOM 被 kill),OS 页面缓存里的数据还在,内核会正常把脏页刷回磁盘,数据不会丢。但如果是操作系统崩溃或断电,页面缓存里的数据就全没了——只有已经 fsync() 过的数据才能活下来。所以 WAL 的正确实现必须在每条日志写入后调用 fsync(),不能用 write() 的返回值来判断持久化是否成功。
预写日志(WAL)的思路是:在修改状态之前,先把要做什么写到一个追加日志里。日志写入磁盘后才执行实际的状态修改。崩溃后,重放日志中已记录但未应用的操作,就能恢复到一致状态。关键洞察是两条:顺序追加写入对磁盘友好(快),重放操作是幂等的(安全)。
二、现实类比
船长的航海日志。在改变航向之前,船长先把计划写进日志。如果船在中途断电了,船员可以读日志来弄清楚:之前的航向变更哪些已经执行了,哪些还没来得及。日志就是唯一的事实来源——状态可以丢失,但日志不能丢。
三、核心思想
WAL 的写入流程是两步:先写日志,再改状态。日志是顺序追加的,状态是原地修改的。崩溃后从日志重放未应用的操作来恢复状态。
客户端 WAL(磁盘) 状态(内存)────── ────────── ──────────SET x=1 ──────► [1] SET x=1 ──────► { x: 1 }SET y=2 ──────► [2] SET y=2 ──────► { x: 1, y: 2 }DEL x ──────► [3] DEL x ──────► { y: 2 } ▲ *** 此处崩溃 ***恢复: 重放日志条目 1, 2, 3 → { y: 2 } ✓mermaidsequenceDiagram participant C as 客户端 participant W as WAL(磁盘) participant S as 状态(内存)
C->>W: 1. 追加写入操作日志 W-->>W: fsync 确保持久化 W->>S: 2. 应用到内存状态 C->>C: 3. 返回成功
Note over W,S: 崩溃后恢复 W->>S: 重放未应用的日志条目关键属性:
| 属性 | 值 | 说明 |
|---|---|---|
| 写入模式 | 顺序追加 | 磁盘友好的写入模式 |
| 持久性 | 崩溃后存活 | 日志在持久存储上 |
| 恢复方式 | 从头或从检查点重放 | 幂等操作,可安全重做 |
| 开销 | 每次变更多一次写入 | 日志写入 + 状态修改 |
3.1 为什么顺序写入快
顺序追加写入之所以快,是因为磁盘在顺序写入时不需要频繁寻址。即使是 SSD,顺序写入也远快于随机写入——SSD 的写入以页为单位,但擦除以块为单位,顺序写入可以让垃圾回收和写放大降到最低。WAL 把所有修改组织成顺序追加的日志流,正好利用了这个特性。
3.2 重放的顺序与幂等
WAL 重放有一个隐含的前提:日志条目必须按写入顺序重放。这不是可选的,而是必须的。考虑这样一个场景:日志里有两条操作,[1] SET x=1 和 [2] SET x=2。如果倒序重放,先执行 [2] 再执行 [1],最终 x=1——但正确结果应该是 x=2。顺序重放保证后写入的操作覆盖先写入的操作,与正常执行的效果一致。
幂等性是另一个关键前提。所谓幂等,就是同一个操作执行一次和执行多次的效果相同。SET x=5 是幂等的——执行多少次 x 都是 5。但 INCREMENT x BY 1 不是幂等的——执行一次 x 加 1,执行两次 x 加 2。如果日志里包含非幂等操作,重放时就需要额外的机制来追踪哪些操作已经应用过,比如用事务 ID 或操作序列号来去重。
3.3 日志压缩与检查点
WAL 有一个现实问题:日志会无限增长。如果系统运行了三个月,每秒写入 1000 条日志,日志文件会膨胀到几十亿条。崩溃后从头重放整个日志?恢复时间可能比崩溃前的运行时间还长。
解决方案是两个配合使用的机制:日志压缩(Log Compaction)和检查点(Checkpoint)。检查点是把当前完整状态写入一个快照文件,日志中检查点之前的条目就可以安全丢弃。恢复时先加载最近的检查点快照,再重放检查点之后的日志条目——可能只有几分钟的量,而不是几个月的。
日志压缩则更精细:不是一刀切地截断日志,而是扫描日志,对于同一个 key,只保留最新的操作,丢弃被覆盖的历史操作。比如日志里有 SET x=1、SET x=2、SET x=3,压缩后只保留 SET x=3。Kafka 的日志压缩(Log Compaction)就是这种思路——每个 topic partition 的日志只保留每个 key 的最新值。
3.4 WAL + MemTable 写入流程
上面讨论的是 WAL 作为通用日志的写入模式。在 LevelDB、Bigtable 这类 LSM 存储引擎中,WAL 和内存中的有序结构(MemTable)紧密配合,形成一条完整的写入流水线:
- 写入请求到达,先将操作追加到 WAL 文件并
fsync - 操作插入内存中的 MemTable(通常是跳表,保持有序)
- 当 MemTable 大小达到阈值,将其标记为不可变(Immutable MemTable),同时创建新的 MemTable 继续接收写入
- 后台线程将 Immutable MemTable 刷盘为 SSTable(有序的磁盘文件)
- 刷盘完成后,对应的 WAL 文件即可安全删除
这条流水线的关键在于:WAL 保证持久性(崩溃后可重放恢复 MemTable 中的数据),MemTable 保证有序性(刷盘时直接输出有序的 SSTable),两者配合让写入路径全程顺序 I/O。值得注意的是,第 2 步完成后就返回成功,第 4-6 步都是后台异步执行的——写入延迟只取决于 WAL 追加和 MemTable 插入,不受刷盘影响。这也是 LSM 树写入性能远超 B+ 树的根本原因。
四、变体与对比
| 模式 | 恢复粒度 | 写入开销 | 适用场景 |
|---|---|---|---|
| 预写日志(WAL) | 单条操作 | 每次变更多一次写入 | 数据库、消息队列 |
| 检查点(Checkpoint) | 全状态快照 | 周期性全量写入 | 配合 WAL 截断日志 |
| LSM 树 | 有序写入 + 后台合并 | 写入放大但顺序写 | 写密集场景 |
| 事件溯源 | 事件本身就是状态 | 无需额外状态存储 | 审计追踪场景 |
4.1 WAL + 检查点的黄金搭档
WAL 和检查点是经典的组合:WAL 保证每条操作不丢失,检查点限制 WAL 的大小。恢复时先加载最近的检查点,再重放检查点之后的 WAL 条目——比从头重放整个日志快得多。
这个组合的关键问题在于检查点的时机。太频繁,检查点本身的 I/O 开销大;太稀疏,日志过长,恢复慢。常见的策略有两种:基于时间(每隔 N 秒做一次检查点)和基于日志量(每积累 N 条日志做一次检查点)。PostgreSQL 用 checkpoint_timeout 控制时间间隔,MySQL InnoDB 则两者兼有。
还有一个细节:做检查点本身也需要时间,系统在写入检查点的过程中还在持续接收新操作。所以检查点必须记录一个”截止位”——表示这个快照包含了截止到哪个 LSN(Log Sequence Number)的所有修改。恢复时只需重放 LSN 大于截止位的日志条目。
4.2 事件溯源:WAL 的极端形态
事件溯源(Event Sourcing)可以看作 WAL 的极端版本:不保存当前状态,只保存事件日志。状态永远从日志推导。想要知道账户余额?不是查一个 balance 字段,而是把该账户的所有 Deposit 和 Withdraw 事件加起来。
好处是完整的审计追踪——每一条状态变更都有据可查,甚至可以回溯到任意历史时刻。代价也显而易见:查询需要重放历史。实践中通常用快照(本质上就是检查点)来缓解:定期把当前状态序列化存储,查询时从最近的快照开始重放。
事件溯源和 WAL 的本质区别在于用途:WAL 是实现细节,对应用层透明,目的只是崩溃恢复;事件溯源是领域建模方式,事件本身就是业务模型的一部分。
4.3 LSM 树与 WAL 的关系
LSM 树(Log-Structured Merge Tree)和 WAL 有深层的联系。LSM 树的所有写入都是顺序追加到 MemTable,MemTable 满了之后刷盘为有序的 SSTable——这本身就是一种 WAL 思想的延伸:把随机写转化为顺序写。不同之处在于,LSM 树的日志是数据本身,而 WAL 的日志是操作的记录。
实际上,很多 LSM 树的实现(如 RocksDB、LevelDB)在写入 MemTable 之前,仍然会先写一个 WAL。因为 MemTable 在内存中,断电就没了。WAL 在这里的作用是保证 MemTable 中的数据在崩溃后可以恢复。所以 LSM 树和 WAL 不是互斥的,而是互补的:LSM 树优化了数据的存储和查询结构,WAL 保证了写入的持久性。
五、多语言实现
5.1 Go 实现
package wal
import ( "bufio" "encoding/json" "os" "sync")
// LogEntry 日志条目type LogEntry struct { ID int `json:"id"` Operation string `json:"operation"` Data map[string]any `json:"data"` Applied bool `json:"applied"`}
// WAL 预写日志type WAL struct { mu sync.Mutex entries []LogEntry nextID int file *os.File maxBytes int64 // 段文件最大字节数,超过则轮转}
func New(path string) (*WAL, error) { f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644) if err != nil { return nil, err } return &WAL{file: f, nextID: 1, maxBytes: 64 * 1024 * 1024}, nil}
// Append 追加操作到日志,先写日志再返回func (w *WAL) Append(op string, data map[string]any) (int, error) { w.mu.Lock() defer w.mu.Unlock()
entry := LogEntry{ ID: w.nextID, Operation: op, Data: data, Applied: false, } w.nextID++
// 先写日志——这是 WAL 的核心 buf, err := json.Marshal(entry) if err != nil { return 0, err } buf = append(buf, '\n') if _, err := w.file.Write(buf); err != nil { return 0, err } // 确保持久化,不留在 OS 页面缓存 if err := w.file.Sync(); err != nil { return 0, err }
w.entries = append(w.entries, entry) return entry.ID, nil}
// Recover 从磁盘日志文件重放恢复状态func (w *WAL) Recover(applyFn func(LogEntry)) (int, error) { // 从头读取日志文件 if _, err := w.file.Seek(0, 0); err != nil { return 0, err } scanner := bufio.NewScanner(w.file) count := 0 for scanner.Scan() { var entry LogEntry if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { continue // 跳过损坏的行(可能是崩溃时写到一半的记录) } applyFn(entry) entry.Applied = true w.entries = append(w.entries, entry) count++ } return count, scanner.Err()}Go 版本的核心在 Append 方法:先序列化、写文件、Sync()(相当于 fsync),确保日志落盘后才返回。这里有一个需要仔细权衡的决策:每条日志都 fsync() 还是批量 fsync()? 每条都 fsync() 保证零数据丢失,但性能开销大——fsync() 会阻塞直到磁盘 I/O 完成,机械硬盘上可能需要 10ms 以上。批量 fsync()(比如每 100ms 调用一次)性能好得多,但可能丢失最后一次 fsync() 之后的几条日志。生产系统通常提供可配置的刷盘策略:PostgreSQL 的 synchronous_commit 参数、MySQL 的 innodb_flush_log_at_trx_commit 参数,本质上都是在回答同一个问题——你愿意用多少性能换多少安全性。
Recover 方法从磁盘文件逐行读取日志条目,跳过损坏的行——崩溃时可能写到一半就断了,最后一行大概率是残缺的 JSON,反序列化会失败,直接跳过就好。这也是 WAL 实现中常见的防御性处理:每条日志以换行符结尾,读取时按行分割,不完整的最后一行直接丢弃。
5.2 TypeScript 实现
interface LogEntry { id: number; operation: string; data: Record<string, unknown>; applied: boolean;}
class WriteAheadLog { private entries: LogEntry[] = []; private nextId = 1;
/** 追加操作到日志(模拟持久化) */ append( operation: string, data: Record<string, unknown>, ): number { const id = this.nextId++; this.entries.push({ id, operation, data, applied: false }); // 生产环境:这里应该 fsync 到磁盘 return id; }
/** 应用所有未应用的日志条目 */ apply(applyFn: (entry: LogEntry) => void): number { let count = 0; for (const entry of this.entries) { if (!entry.applied) { applyFn(entry); entry.applied = true; count++; } } return count; }
/** 崩溃恢复:重放所有日志条目 */ recover(applyFn: (entry: LogEntry) => void): number { let count = 0; for (const entry of this.entries) { applyFn(entry); entry.applied = true; count++; } return count; }}TypeScript 版本模拟了 WAL 的核心逻辑。生产环境中日志应写入持久存储(文件、IndexedDB 等),并在追加后确保刷盘。apply 和 recover 的区别在于:apply 跳过已应用的条目,recover 无条件重放所有条目——因为崩溃后不知道哪些已应用。
这里还有一个值得讨论的问题:浏览器环境中如何做持久化保证?Node.js 可以直接调用 fs.fsyncSync(),但浏览器里没有等价 API。IndexedDB 的写入是异步的,也没有 fsync 语义。浏览器环境的 WAL 更多是逻辑层面的保障——防止页面刷新导致的状态丢失,而不是断电级别的持久性。
六、生产验证
| 项目 | 源码位置 | 用途 |
|---|---|---|
| etcd | WAL 结构体 | 持有目录、编码器、互斥锁和文件管道。Save 方法(L958-L1000)持久化 Raft 硬状态和条目,同步到磁盘,超过 SegmentSizeBytes 时轮转段。WAL 是 etcd 分布式共识的事实来源 |
| PostgreSQL | XLogInsertRecord | WAL 核心插入入口。预留空间,将记录数据复制到 WAL 缓冲区,按需触发刷盘。XLogWrite(L2324-L2622)将 WAL 页从共享缓冲区写入磁盘。支撑崩溃恢复、复制和时间点恢复 |
| SQLite WAL Mode | WAL 模式 | SQLite 的 WAL 模式实现单写多读并发——写操作追加到 WAL 文件,读操作可以同时读取主数据库文件。这是 SQLite 最常用的日志模式 |
七、小结
何时使用:
- 数据库事务——崩溃恢复的标准方案,PostgreSQL、MySQL InnoDB、SQLite 的基石。几乎所有关系型数据库都依赖 WAL 来保证事务的持久性,没有它,ACID 中的 D 就无从谈起
- 分布式共识——Raft/Paxos 的日志复制本质上就是 WAL,etcd、ZooKeeper 的核心。WAL 保证了单节点上的操作顺序和持久性,复制协议则把这种保证扩展到多节点
- 消息队列——Kafka 的提交日志就是 WAL 的变体,保证消息不丢失。消息本身就是不可变的事件流,消费者通过偏移量来追踪消费进度
- 文件系统——ext4、NTFS 的元数据完整性日志。元数据如果损坏,整个文件系统可能不可挂载,所以元数据的 WAL 是可靠性的底线
何时不用:
- 临时数据——缓存条目或会话数据不需要崩溃恢复,WAL 增加不必要的开销。给 Redis 的纯缓存场景开 AOF 反而会拖慢写入速度,丢失几条缓存数据重新加载就好
- 可重算的幂等状态——如果状态可以从其他数据源重新推导,WAL 是多余的。比如搜索引擎的倒排索引,坏了可以从原始文档重建
- 同一键的高频更新——WAL 会快速增长,考虑 LSM 树或定期快照来压缩日志。一个计数器每秒更新 1000 次,WAL 每秒就多 1000 条记录,但实际只需要最终值
- 读密集工作负载——WAL 针对写优化,对读没有直接帮助。如果系统 99% 是读操作,WAL 的写入开销可以忽略,但也不值得为它引入额外的架构复杂度
八、参考资料
- PostgreSQL WAL Internals - PostgreSQL WAL 内部机制文档
- etcd WAL Design - etcd WAL 的设计说明
- SQLite WAL Mode - SQLite WAL 模式的详细说明,含读写并发机制
- ARIES: Transaction Recovery - ARIES 论文,WAL 恢复算法的经典文献
- Kafka Log Design - Apache Kafka 的日志存储设计,提交日志的生产级实现
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






