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。这意味着:
- 需要Undo日志:因为未提交事务的修改可能已经写回磁盘,崩溃后需要撤销这些修改。
- 需要Redo日志:因为已提交事务的修改可能还在内存,崩溃后需要重做这些修改。
- 内存效率高:缓冲池可以灵活腾挪空间,不必为未提交事务保留大量内存。
- 提交延迟低:只需要刷日志,不需要等待所有数据页写回。
但选择这个策略的代价是恢复算法的复杂性。这正是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的基本假设:
-
Redo阶段需要读取页面:ARIES使用"physiological logging"——日志记录物理页面上的逻辑操作。例如"在页面P的第7个槽位插入记录",需要先读取页面P了解其结构。如果页面P是Torn Page,读取就会失败。
-
校验和也无法恢复:页面校验和可以检测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_delay和commit_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的作用是:
- 将所有脏页刷新到磁盘
- 在WAL中写入checkpoint记录
- 释放checkpoint之前的日志空间
不同数据库对checkpoint的实现策略各异:
PostgreSQL:后台checkpointer进程定期执行,受checkpoint_timeout和max_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:
- 如果日志记录的LSN ≤ 页面的pageLSN:该操作已经体现在页面中,跳过
- 如果日志记录的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调优,每一项改进都在压榨存储性能的极限。理解这些机制,才能在面对"数据库为什么这么慢"或"为什么数据丢失了"时,给出有依据的回答。
参考资料
- Mohan, C., et al. “ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging.” ACM TODS, 1992.
- PostgreSQL Documentation. “Write-Ahead Logging (WAL).” https://www.postgresql.org/docs/current/wal-intro.html
- PostgreSQL Wiki. “Full page writes.” https://wiki.postgresql.org/wiki/Full_page_writes
- SQLite Documentation. “Write-Ahead Logging.” https://sqlite.org/wal.html
- MySQL Reference Manual. “InnoDB Doublewrite Buffer.” https://dev.mysql.com/doc/refman/9.3/en/innodb-doublewrite-buffer.html
- Sookocheff, K. “Write-ahead logging and the ARIES crash recovery algorithm.” https://sookocheff.com/post/databases/write-ahead-logging/
- EnterpriseDB. “On the impact of full-page writes.” https://www.enterprisedb.com/blog/impact-full-page-writes
- Transactional Blog. “Torn Write Detection and Protection.” https://transactional.blog/blog/2025-torn-writes
- InterDB. “Internal Layout of WAL Segment.” https://www.interdb.jp/pg/pgsql09/03.html
- Crunchy Data. “Postgres WAL Files and Sequence Numbers.” https://www.crunchydata.com/blog/postgres-wal-files-and-sequuence-numbers
- RocksDB Wiki. “WAL Performance.” https://github.com/facebook/rocksdb/wiki/WAL-Performance
- CMU 15-445. “Database Crash Recovery.” https://15445.courses.cs.cmu.edu/spring2023/notes/20-recovery.pdf