mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4058 字
11 分钟
预写日志(Write-Ahead Log)
2026-06-13

一、为什么需要预写日志#

数据库正在处理一笔转账:从账户 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 } ✓
mermaid
sequenceDiagram
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=1SET x=2SET x=3,压缩后只保留 SET x=3。Kafka 的日志压缩(Log Compaction)就是这种思路——每个 topic partition 的日志只保留每个 key 的最新值。

3.4 WAL + MemTable 写入流程#

上面讨论的是 WAL 作为通用日志的写入模式。在 LevelDB、Bigtable 这类 LSM 存储引擎中,WAL 和内存中的有序结构(MemTable)紧密配合,形成一条完整的写入流水线:

  1. 写入请求到达,先将操作追加到 WAL 文件并 fsync
  2. 操作插入内存中的 MemTable(通常是跳表,保持有序)
  3. 当 MemTable 大小达到阈值,将其标记为不可变(Immutable MemTable),同时创建新的 MemTable 继续接收写入
  4. 后台线程将 Immutable MemTable 刷盘为 SSTable(有序的磁盘文件)
  5. 刷盘完成后,对应的 WAL 文件即可安全删除
sequenceDiagram participant C as 客户端 participant W as WAL(磁盘) participant M as MemTable(内存) participant I as Immutable MemTable participant S as SSTable(磁盘) C->>W: 1. 追加写入操作日志 W-->>W: fsync 确保持久化 W->>M: 2. 插入 MemTable M-->>C: 3. 返回写入成功 Note over M,I: MemTable 大小达到阈值 M->>I: 4. 标记为 Immutable,创建新 MemTable Note over I,S: 后台线程异步执行 I->>S: 5. 刷盘为 SSTable S-->>W: 6. 刷盘完成,可删除旧 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 字段,而是把该账户的所有 DepositWithdraw 事件加起来。

好处是完整的审计追踪——每一条状态变更都有据可查,甚至可以回溯到任意历史时刻。代价也显而易见:查询需要重放历史。实践中通常用快照(本质上就是检查点)来缓解:定期把当前状态序列化存储,查询时从最近的快照开始重放。

事件溯源和 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 等),并在追加后确保刷盘。applyrecover 的区别在于:apply 跳过已应用的条目,recover 无条件重放所有条目——因为崩溃后不知道哪些已应用。

这里还有一个值得讨论的问题:浏览器环境中如何做持久化保证?Node.js 可以直接调用 fs.fsyncSync(),但浏览器里没有等价 API。IndexedDB 的写入是异步的,也没有 fsync 语义。浏览器环境的 WAL 更多是逻辑层面的保障——防止页面刷新导致的状态丢失,而不是断电级别的持久性。

六、生产验证#

项目源码位置用途
etcdWAL 结构体持有目录、编码器、互斥锁和文件管道。Save 方法(L958-L1000)持久化 Raft 硬状态和条目,同步到磁盘,超过 SegmentSizeBytes 时轮转段。WAL 是 etcd 分布式共识的事实来源
PostgreSQLXLogInsertRecordWAL 核心插入入口。预留空间,将记录数据复制到 WAL 缓冲区,按需触发刷盘。XLogWrite(L2324-L2622)将 WAL 页从共享缓冲区写入磁盘。支撑崩溃恢复、复制和时间点恢复
SQLite WAL ModeWAL 模式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 的写入开销可以忽略,但也不值得为它引入额外的架构复杂度

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

预写日志(Write-Ahead Log)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-write-ahead-log/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时