1992年3月,ACM Transactions on Database Systems发表了一篇题为《ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging》的论文。这篇由IBM Almaden研究中心的C. Mohan等人撰写的文章,定义了此后三十余年数据库崩溃恢复的基本范式。

论文的核心贡献之一,是将Write-Ahead Logging(WAL)确立为事务持久性的基石。ARIES这个名字本身就揭示了其设计哲学——Algorithms for Recovery and Isolation Exploiting Semantics。其中的"Recovery",指的就是基于WAL的崩溃恢复。

三十多年后的今天,从手机里的SQLite到云端的分布式数据库,WAL无处不在。但这个看似简单的机制背后,隐藏着一系列精妙的设计权衡:为什么必须先写日志再写数据?为什么PostgreSQL要在WAL里塞入整页数据?为什么MySQL要发明Doublewrite Buffer?为什么SQLite的WAL模式能带来巨大的性能提升?

一个被误解的"先写"

WAL的字面意思——“先写日志”——常常被误读为"先写日志,再写数据"。这种理解虽然方向正确,但遗漏了关键细节。

真正的WAL协议包含两条规则:

规则一:在将数据页写入磁盘之前,该数据页的所有修改记录必须已经写入日志。形式化表达:pageLSN ≤ flushedLSN

规则二:在向客户端返回事务提交成功之前,该事务的所有日志记录(包括commit标记)必须已经写入磁盘。

第一条规则解决的是"脏页写回"问题。数据库使用缓冲池(Buffer Pool)来缓存数据页,当缓冲池满时,需要将一些脏页(被修改过的页)写回磁盘。但此时该页可能还被未提交的事务修改过——这就带来了一个两难:写回磁盘,可能丢失未提交事务的修改;不写回,缓冲池又腾不出空间。

WAL给出的答案是:写,但要确保日志先到磁盘。这样即使崩溃,也能通过日志恢复。

第二条规则解决的是"提交确认"问题。当客户端收到"commit成功"的响应时,这条事务必须已经持久化——即使下一秒服务器机房停电,数据也不能丢。唯一的办法是:在返回响应前,先确保日志已经刷入磁盘。

这两条规则共同构成了WAL的核心约束。但为什么是日志,而不是直接写数据?

答案藏在I/O的性能差异里。日志是顺序追加写,数据页是随机写。在现代存储设备上,顺序写的吞吐量可以是随机写的几十甚至上百倍。将多个事务的修改合并成一条顺序的日志流,比逐个修改散落在磁盘各处的数据页,效率高出数个数量级。

Steal与Force:数据库的四重选择

理解WAL的必要性,需要先理解缓冲池管理的两个维度。

Steal策略:是否允许将未提交事务修改的脏页写回磁盘?

  • Steal:允许。缓冲池可以"偷走"一个帧来存放其他页,即使该帧上的脏页来自未提交事务。
  • No-Steal:不允许。未提交事务的修改必须留在内存,直到事务提交。

Force策略:是否要求事务提交时将所有修改强制写入磁盘?

  • Force:必须。提交前刷盘,确保持久化。
  • No-Force:不必。可以延迟写回,依赖日志保证持久性。

四种组合形成四种策略:

策略 复杂度 性能 说明
No-Steal + Force 最低 最差 不需要Undo/Redo日志,但内存压力大,提交延迟高
No-Steal + No-Force 只需Redo日志,但内存压力依然大
Steal + Force 只需Undo日志,但提交延迟高
Steal + No-Force 最高 最佳 需要Undo+Redo日志,但灵活性和性能最优

几乎所有现代数据库都选择了Steal + No-Force。这意味着:

  1. 需要Undo日志:因为未提交事务的修改可能已经写回磁盘,崩溃后需要撤销这些修改。
  2. 需要Redo日志:因为已提交事务的修改可能还在内存,崩溃后需要重做这些修改。
  3. 内存效率高:缓冲池可以灵活腾挪空间,不必为未提交事务保留大量内存。
  4. 提交延迟低:只需要刷日志,不需要等待所有数据页写回。

但选择这个策略的代价是恢复算法的复杂性。这正是ARIES要解决的问题。

ARIES:三阶段恢复的艺术

ARIES的恢复过程分为三个阶段:Analysis(分析)、Redo(重做)、Undo(撤销)。

sequenceDiagram
    participant Log as WAL日志
    participant Analysis as Analysis阶段
    participant Redo as Redo阶段
    participant Undo as Undo阶段
    participant DB as 数据库
    
    Note over Log,DB: 系统崩溃后重启
    Log->>Analysis: 从最后checkpoint开始扫描
    Analysis->>Analysis: 识别脏页(DPT)
    Analysis->>Analysis: 识别活跃事务(ATT)
    Analysis->>Redo: 确定Redo起点
    
    Redo->>Log: 从Redo起点向前扫描
    Redo->>DB: 重放所有修改(含将abort的事务)
    Redo->>Redo: 更新pageLSN
    
    Undo->>Log: 从最后向前扫描
    Undo->>DB: 撤销未提交事务
    Undo->>Log: 写入CLR记录
    Note over DB: 数据库恢复完成

Analysis阶段:从最后一个checkpoint开始扫描日志,构建两个关键数据结构:

  • Dirty Page Table (DPT):崩溃时哪些页面是脏的
  • Active Transaction Table (ATT):崩溃时哪些事务还在进行中

Redo阶段:从DPT中最小的recLSN开始,重放所有日志记录——包括那些最终会回滚的事务。这一步将数据库恢复到崩溃瞬间的精确状态。

Undo阶段:撤销ATT中所有未提交事务的修改。每撤销一个操作,就写入一个Compensation Log Record (CLR),记录撤销行为本身。这确保即使撤销过程中再次崩溃,也能正确恢复。

一个反直觉的设计:为什么要Redo那些会Rollback的事务?

ARIES的答案是:简化逻辑。通过Redo所有历史,数据库在Redo阶段结束后处于崩溃时的精确状态——包括未提交事务的中间状态。然后Undo阶段只需要关心如何回滚这些未提交事务,而不必考虑崩溃前的页面状态。

Torn Page:被忽视的致命威胁

WAL假设一个前提:日志写入是原子的。但如果日志记录本身被部分写入呢?

这就是Torn Page问题,也称为Partial Write或Torn Write。

数据库使用8KB或更大的页面。但存储栈的每一层可能使用不同的原子写入单位:

  • 文件系统:4KB页(Linux ext4/XFS)
  • 硬件:512B扇区(传统磁盘)或4KB块(现代SSD)

当数据库写入一个8KB页面时,文件系统可能将其拆分为两个4KB页,硬件可能进一步拆分为多个512B扇区。如果此时发生断电,可能出现:前4KB写入成功,后4KB没写入——这就是Torn Page。

Torn Page之所以致命,是因为它破坏了WAL的基本假设:

  1. Redo阶段需要读取页面:ARIES使用"physiological logging"——日志记录物理页面上的逻辑操作。例如"在页面P的第7个槽位插入记录",需要先读取页面P了解其结构。如果页面P是Torn Page,读取就会失败。

  2. 校验和也无法恢复:页面校验和可以检测Torn Page,但无法修复——缺少恢复原始数据的信息。

各数据库对这个问题的解决方案截然不同。

PostgreSQL:Full Page Writes

PostgreSQL采用了一种看似暴力但有效的方法:在每个checkpoint后,第一次修改某个页面时,将该页面的完整副本写入WAL

checkpoint后的WAL记录:
[页面A的完整镜像] → [修改记录1] → [修改记录2] → ...
[页面B的完整镜像] → [修改记录1] → ...

这样,即使数据文件中的页面被torn,恢复时也可以从WAL中取出完整的页面镜像,然后应用后续修改。

性能代价:写入放大。修改一个字节,可能产生8KB的WAL记录。EnterpriseDB的测试显示,使用UUID主键(导致随机插入)时,WAL体积比BIGSERIAL高出20倍——从2GB膨胀到40GB。

优化思路

  • 调大checkpoint间隔:Full Page Writes只在checkpoint后首次修改时触发
  • 使用4KB页面:WAL中记录4KB而非8KB,可减少约50%的WAL体积
  • 在确保原子写入的存储上关闭:如ZFS、支持RWF_ATOMIC的NVMe设备

MySQL InnoDB:Doublewrite Buffer

MySQL选择了另一条路:在写入数据页之前,先将页面副本写入一个独立的"双写缓冲区"

写入流程:
1. 将脏页写入Doublewrite Buffer(顺序写)
2. fsync Doublewrite Buffer
3. 将脏页写入实际数据文件位置(随机写)
4. fsync数据文件

如果写入数据文件时发生torn,恢复时可以从Doublewrite Buffer中恢复完整页面。

设计权衡

  • 优点:不影响WAL的紧凑性,对事务提交路径无影响
  • 缺点:所有页面都写两遍,2x写入放大;每个数据页写回需要两次fsync延迟

MySQL 8.0.23对Doublewrite Buffer进行了重大优化,引入并发写入支持,缓解了高负载下的mutex竞争问题。

SQLite:All-In WAL

SQLite的做法更为彻底:整个WAL模式就是对Torn Page问题的终极解答

传统rollback journal模式下,SQLite在修改数据前先将原始页面写入journal文件。WAL模式则反转了这个逻辑:不修改数据文件,所有修改都追加到WAL文件

WAL模式写入流程:
1. 将修改后的页面追加到WAL文件
2. 写入commit记录
3. (checkpoint时)将WAL中的页面合并回数据文件

WAL中的页面是完整的,因此不存在Torn Page问题。checkpoint时,SQLite会等待没有活跃的读取事务,确保安全地合并页面。

额外收益

  • 写入变成纯顺序写,性能大幅提升
  • 读写可以并发:读者从数据文件和WAL读取,写者追加WAL,互不阻塞

代价:长事务会阻止checkpoint完成,导致WAL无限增长——这就是著名的"checkpoint starvation"问题。

Group Commit:摊薄fsync的成本

无论WAL设计多么精妙,最终都要面对一个物理限制:fsync()的成本

fsync()是操作系统调用,强制将文件缓冲区刷入磁盘。在PostgreSQL的测试中,单次fsync的延迟可以从几百微秒到几十毫秒不等——取决于存储设备、文件系统和当前负载。

对于事务处理系统,每个commit都需要一次fsync。如果每秒提交1000个事务,就需要1000次fsync。这在高延迟存储上会成为严重瓶颈。

Group Commit的思想是:将多个事务的commit合并成一次fsync

无Group Commit:
事务A commit → fsync → 返回
事务B commit → fsync → 返回
事务C commit → fsync → 返回

有Group Commit:
事务A commit → 等待
事务B commit → 等待
事务C commit → 等待
批量fsync → 同时返回A、B、C

PostgreSQL通过commit_delaycommit_siblings参数控制Group Commit行为:

  • commit_delay:在fsync前等待多少微秒,期待更多事务加入
  • commit_siblings:至少有多少并发事务时才触发延迟

MySQL InnoDB的Group Commit实现更为激进,通过innodb_flush_log_at_trx_commit参数允许在性能和持久性之间权衡:

  • 1(默认):每次commit都fsync,最安全
  • 2:commit时只写入OS缓冲区,每秒fsync一次
  • 0:不主动fsync,依赖OS调度

RocksDB的测试表明,Group Commit可以将WAL写入吞吐量提升数倍,同时降低写入放大。

Checkpoint:回收日志空间的边界

WAL文件会无限增长吗?不会,因为存在Checkpoint机制。

Checkpoint的作用是:

  1. 将所有脏页刷新到磁盘
  2. 在WAL中写入checkpoint记录
  3. 释放checkpoint之前的日志空间

不同数据库对checkpoint的实现策略各异:

PostgreSQL:后台checkpointer进程定期执行,受checkpoint_timeoutmax_wal_size参数控制。过于频繁的checkpoint会增加Full Page Writes的触发概率,导致WAL膨胀。

MySQL InnoDB:使用LSN(Log Sequence Number)跟踪日志位置。innodb_log_capacity参数控制redo log的总大小。InnoDB的sharp checkpoint会等待所有脏页刷完,可能造成长时间停顿。

SQLite:WAL文件达到1000页时自动触发checkpoint,也可以手动触发。长读取事务会阻止checkpoint完成,导致WAL无限增长。

Checkpoint的频率是一个关键调优点:

  • 频繁checkpoint:恢复时间短,但运行时性能差(刷页开销大)
  • 稀疏checkpoint:运行时性能好,但恢复时间长(需要重放更多日志)

LSN:贯穿恢复的核心线索

Log Sequence Number(LSN)是理解WAL机制的关键概念。它是一个单调递增的数字,标识每条日志记录在日志流中的位置。

在ARIES模型中,多个LSN协同工作:

LSN类型 说明
LSN 每条日志记录的唯一标识
pageLSN 数据页上最后修改该页的日志LSN
flushedLSN 已经刷入磁盘的最大LSN
recLSN 页面自上次刷盘后第一次被修改的LSN

这些LSN共同维护一个关键不变式:页面刷盘前,必须满足 pageLSN ≤ flushedLSN

实现方式是:刷页前,先检查该页的pageLSN是否小于等于flushedLSN。如果不是,先刷新WAL直到满足条件,再刷数据页。这就是WAL"先写日志"的真正含义。

恢复时,这些LSN用于判断哪些操作需要Redo:

  1. 如果日志记录的LSN ≤ 页面的pageLSN:该操作已经体现在页面中,跳过
  2. 如果日志记录的LSN > 页面的pageLSN:需要重放

这避免了对所有日志无脑重放,加速了恢复过程。

当WAL遇上现代存储

传统WAL设计基于一个假设:磁盘是昂贵的随机I/O设备。但现代SSD和云存储改变了游戏规则。

SSD的特性

  • 顺序写优势缩小:SSD的顺序写和随机写性能差距比HDD小得多
  • 写入放大敏感:每个写入都会消耗SSD的寿命,WAL的2x写入放大需要重新审视
  • 内部并行:SSD有多个并行通道,可以同时服务多个写入请求

云存储的特性

  • IOPS限制:AWS EBS按IOPS计费,每次fsync都是成本
  • 原子写入支持:部分云厂商提供torn write prevention,允许关闭Full Page Writes

Linux 6.11引入的RWF_ATOMIC标志,允许应用程序请求原子写入。结合4KB原生扇区的NVMe设备,数据库可以绕过传统的torn page保护机制,直接享受原子写入的便利。AlloyDB Omni已经支持这一特性。

小结

Write-Ahead Log的设计,本质上是在回答一个问题:如何在不可靠的硬件上构建可靠的数据存储?

答案是通过冗余换取可靠:

  • 日志是数据的冗余副本,用于崩溃恢复
  • Full Page Writes/Dubblewrite Buffer是页面的冗余副本,用于防止Torn Page
  • Group Commit是fsync的冗余"合并",用于摊薄成本

每一层冗余都有代价:空间放大、写入放大、延迟增加。好的数据库设计,就是要在这些tradeoff中找到最优平衡点。

ARIES论文发表三十多年后,WAL的基本原理未变,但实现细节持续演进。从fsync优化到原子写入支持,从Group Commit到checkpoint调优,每一项改进都在压榨存储性能的极限。理解这些机制,才能在面对"数据库为什么这么慢"或"为什么数据丢失了"时,给出有依据的回答。


参考资料

  1. Mohan, C., et al. “ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging.” ACM TODS, 1992.
  2. PostgreSQL Documentation. “Write-Ahead Logging (WAL).” https://www.postgresql.org/docs/current/wal-intro.html
  3. PostgreSQL Wiki. “Full page writes.” https://wiki.postgresql.org/wiki/Full_page_writes
  4. SQLite Documentation. “Write-Ahead Logging.” https://sqlite.org/wal.html
  5. MySQL Reference Manual. “InnoDB Doublewrite Buffer.” https://dev.mysql.com/doc/refman/9.3/en/innodb-doublewrite-buffer.html
  6. Sookocheff, K. “Write-ahead logging and the ARIES crash recovery algorithm.” https://sookocheff.com/post/databases/write-ahead-logging/
  7. EnterpriseDB. “On the impact of full-page writes.” https://www.enterprisedb.com/blog/impact-full-page-writes
  8. Transactional Blog. “Torn Write Detection and Protection.” https://transactional.blog/blog/2025-torn-writes
  9. InterDB. “Internal Layout of WAL Segment.” https://www.interdb.jp/pg/pgsql09/03.html
  10. Crunchy Data. “Postgres WAL Files and Sequence Numbers.” https://www.crunchydata.com/blog/postgres-wal-files-and-sequuence-numbers
  11. RocksDB Wiki. “WAL Performance.” https://github.com/facebook/rocksdb/wiki/WAL-Performance
  12. CMU 15-445. “Database Crash Recovery.” https://15445.courses.cs.cmu.edu/spring2023/notes/20-recovery.pdf