预写式日志:它如何保护你的数据

从 IBM 的 ARIES 讲到今天。为什么先写顺序日志再落数据页能挺住断电、checkpoint 与 group commit 各自解决什么、物理 WAL 与逻辑 WAL 的区别,以及两组最常见的误用,长事务不关、关 fsync 换速度。

中文
复制
文章封面图

概要

  • PRAGMA journal_mode=WAL; 一行就能在 SQLite 中启用预写式日志模式。
  • 每次写入都先记录到日志中,之后才应用到数据文件。
  • PostgreSQL 中的 pg_current_wal_lsn() 可以随时揭示日志的精确位置。
  • 检查点调优不当会让 .wal 文件不断增长,直到占用比数据库本身还多的空间。
  • PostgreSQL 的流式复制实际上就是把 WAL 发送到每个副本。
  • SQLite 在 WAL 模式下允许并发读取,同时由单个写入者修改数据。
  • fsync=off 能换来速度,但每一次突然断电都会变成数据损坏。
  • SHOW wal_level; 和 PRAGMA wal_checkpoint(TRUNCATE); 可以确认日志的实际状态。

什么是预写式日志,它为何存在

想想船上的航海日志。船长在把最终位置更新到正式海图之前,会先把每一次操作都记下来:时间、航向、风向。如果航行途中出了岔子,这本日志能让你精确还原发生了什么、船最后到了哪里。带预写式日志的数据库对每一次改动也是这么做的:先把它记录到顺序日志中,然后才去碰最终的数据页。 它解决的问题说起来简单,做好却很难。一个事务可能在内存中修改多个页面,而操作系统可能在这些页面落盘之前就重启了。如果没有事先记录改动,就无从判断磁盘上那个写了一半的页面属于已提交的事务、已中止的事务,还是两者混杂的损坏结果。 IBM 的研究人员发表的 ARIES 算法在 1992 年把这一方案形式化了:每次改动先被描述为一条带序列号(LSN,日志序列号)的日志记录,在事务向客户端确认之前,这条记录必须经 fsync 强制落盘,之后引擎才从容地把修改过的页面应用到数据文件。如果进程中途崩溃,日志里有足够的信息重做已提交的事务、撤销那些做了一半的事务。 天真的替代方案是直接写入每个修改过的页面,然后指望不会在恰好错误的时刻出故障。八十年代这么做的引擎,每一次时机不巧的断电都会丢数据,也正因如此,预写式日志成了 PostgreSQL、MySQL、SQLite 以及几乎所有现代关系型数据库事实上的标准。

内部是如何运作的

整个流程涉及三个角色:请求写入的应用程序、日志(一组只追加增长的文件),以及最终的数据文件。当你提交事务时,引擎会构造一条记录,写明确切的改动,给它分配一个递增的 LSN,并追加到当前日志的末尾。只有当这条记录持久地落盘之后,引擎才会向应用程序返回提交成功。

修改后的数据页先留在内存的缓冲池里,之后再写回磁盘,这个过程叫 checkpoint。checkpoint 会遍历内存中的脏页,把它们刷入数据文件,并在日志里记下已经应用到哪个 LSN。这就限定了系统重启时需要重放多少日志:不必从第一条记录开始重做全部历史,恢复从最后一次确认的 checkpoint 开始。

下图概括了单次写入的整个流程,从应用发起请求,到 checkpoint 把它刷入最终文件。

sequenceDiagram
    participant App as Application
    participant Log as Write-Ahead Log
    participant Disk as Data file
    App->>Log: requests to write a change
    Log-->>App: confirms after fsync
    Note over Log,Disk: the checkpoint runs in the background
    Log->>Disk: applies the modified page

这套方案之所以快,是因为 fsync 只碰日志,而日志是顺序写入的,代价很低。数据页可能散落在文件中的任何位置,可以稍后再更新,不必让等待确认的客户端干等。

日志始终追加在文件末尾,从不改写中间部分。图片来自 Ales Krivec,发布于 Unsplash

实际示例

SQLite 将预写式日志实现为默认日志模式之外的一种可选模式。启用它只需一条语句:

PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;

第一行改变日志模式:SQLite 不再先把原始页面复制到回滚文件再修改,而是把变更写入单独的 name.db-wal 文件,主数据库在下一次检查点之前保持不变。第二行放宽了 fsync 级别,因为在 WAL 模式下不再需要 synchronous=FULL 所要求的更严格的保证;SQLite 手册本身就把这一组合列为大多数应用的推荐配置。

一个更贴近实际的例子:某个应用在插入行的同时,另一个进程正在读取同一个文件——在经典日志模式下,这会阻塞读取方。

import sqlite3

con = sqlite3.connect("inventario.db")
con.execute("PRAGMA journal_mode=WAL;")
con.execute(
    "INSERT INTO productos (nombre, stock) VALUES (?, ?)",
    ("mechanical keyboard", 120),
)
con.commit()
con.close()

在那个进程写入期间,另一个进程可以打开同一个数据库并执行 SELECT,无需等待:在 WAL 模式下,读取方看到的是写入开始之前文件的一致快照,不会因写入而阻塞。

在 PostgreSQL 中,WAL 不是可选项,它始终开启,但记录的详细程度可以配置。要做流复制,必须把 wal_level 提高到最低级别以上:

-- in postgresql.conf
wal_level = replica
max_wal_senders = 5
wal_keep_size = 512MB

当 wal_level 设为 replica 时,每条日志记录都包含足够的信息,让副本服务器无需访问原始数据文件就能重建出相同的状态。

上手:一步步启用并验证 WAL

SQLite 的流程很直接。每个连接执行一次 PRAGMA,然后确认结果:

sqlite3 inventario.db "PRAGMA journal_mode=WAL;"
-- expected response: wal

sqlite3 inventario.db "PRAGMA journal_mode;"
-- confirms it's still wal after reconnecting

数据库旁边会出现两个新文件:inventario.db-wal 保存待写入的变更,inventario.db-shm 是一块共享内存区域,用于协调读取方与写入方。

在 PostgreSQL 中,编辑 postgresql.conf 并重启服务后,确认当前生效的级别和日志位置:

SHOW wal_level;
-- expected response: replica

SELECT pg_current_wal_lsn();
-- returns something like 0/3000060, the current log position

如果要启用复制,下一步是创建一个专用用户,并在副本服务器上把 primary_conninfo 指向该用户。具体细节因版本而异,PostgreSQL 的 WAL 参数文档针对每个发行版都给出了所需的详细说明。

实际使用场景

PostgreSQL 的流式复制,核心就是 WAL 在网络上的传输:主库不会为复制额外计算什么,它只是把原本就为崩溃恢复而生成的日志记录原样发出去。每个副本按相同顺序应用这些记录,最终到达相同状态,延迟从毫秒到秒不等,取决于网络延迟。

SQLite 用同一套机制解决另一个问题:移动端和桌面应用需要多个线程读数据库,同时后台进程在同步数据。大量嵌入式应用都依赖这种「多个非阻塞读者 + 单个写者」的组合。

还有一些系统借用了同样的思路,只是不叫 write-ahead logging。Apache Kafka 本质上就是一个分布式的追加日志,把同样的原理搬到了集群规模上;它和关系数据库的 WAL 之间的相似并非巧合,两者用同一种底层数据结构解决同一个顺序与持久化的问题。

PostgreSQL 可以让多个副本同时读取同一条 WAL 流。Foto de Radowan Nakif Rehan en Unsplash

常见错误与最佳实践

SQLite 里最常见的错误,是在 WAL 模式的数据库上开一个长事务然后一直不关。只要这个读事务还活着,自动 checkpoint 就无法越过它开始的位置,.wal 文件会一直涨,直到事务结束。

⚠️ 注意: .wal 文件持续增长,几乎总是意味着有一个没人关闭的读连接还开着。

PostgreSQL 里的对应情况,是某个副本宕机或断连,而 wal_keep_size 或某个复制槽仍在为它保留日志段。如果这个副本迟迟不回来,主库就会堆积几个 GB 的保留 WAL,等着有人来消费。

另一个经典错误是为了在高写入负载下提速而关掉 fsync。PostgreSQL 的 fsync=off 或 SQLite 的 PRAGMA synchronous=OFF 都会让完整的持久化保证失效:操作系统可以在物理磁盘还没真正收到数据时就报告写入完成,而恰好在这一瞬间断电,数据库就会损坏。

作为最佳实践,先测量。PostgreSQL 的 pg_stat_bgwriter 视图会显示有多少检查点是被时间强制触发的,又有多少是因为 WAL 积累过多而强制触发的——这直接说明提高 max_wal_size 是否值得,或者是否需要重新审视写入负载。

与其他方案的对比

预写日志并非保证持久性的唯一技术,但它是现代关系型引擎中最普遍的一种。

技术适用场景优势局限
预写日志通用事务型引擎(PostgreSQL、SQLite、InnoDB)恢复快,且能直接从同一份日志做原生复制必须管理日志增长并调优检查点
影子分页并发写入很少的极简嵌入式引擎不需要 redo 阶段,恢复几乎瞬时完成会造成文件碎片,并让并发事务变复杂
无日志的异步写入能容忍丢失最后几秒数据的缓存或指标系统写入速度可达最高断电时必然丢数据

深入一点:组提交与逻辑复制

如果每个事务都单独做一次 fsync,在高负载下会很慢,所以现代引擎会把若干待提交的事务归为一组,用一次 fsync 全部落盘。这叫作组提交。PostgreSQL 通过 commit_delay 和 commit_siblings 启用它,多等几微秒来凑齐更多事务,再付出 fsync 的代价。

物理 WAL 与逻辑 WAL 之间也有区别。物理 WAL 以磁盘页为单位描述变更,对恢复以及运行完全相同引擎版本的副本来说足够且高效。逻辑 WAL 则把变更描述为操作(插入这一行、更新这一列),因此可以在不同版本之间复制,或把变更喂给事件队列之类的外部系统。

💡 提示: 如果你需要把变更从 PostgreSQL 发送到 Kafka 或其他外部系统,去文档里查一下逻辑解码:它使用的源就是同一份 WAL,只是把它转换成引擎之外也能读懂的格式。

PostgreSQL 的复制槽解决了该保留哪些 WAL 段的问题:它告诉主库,在某个特定副本消费完之前不要删除任何内容,代价是如果某个副本永久下线而没人清理它的槽,主库的磁盘就会被填满。

flowchart TD
    A["Primary: generates WAL"] --> B["Replication slot retains segments"]
    B --> C["Replica: consumes the WAL"]
    C --> D[("Replica database")]
    subgraph PostgreSQL Cluster
    A
    B
    D
    end

下一步:在一个测试用的 SQLite 数据库上启用 PRAGMA journal_mode=WAL;,同时打开两个连接,一个循环写入,另一个读取,亲眼看看读操作从不会被阻塞。

常见问题

预写日志能替代备份吗?

不能。WAL 防的是进程或操作系统崩溃,但如果 WAL 本身损坏,或者磁盘彻底报废,日志和数据会一起没了。备份仍然必要;事实上 PostgreSQL 就是用 WAL 本身来做持续备份的,但那是额外的用途,不是替代。

SQLite 里 .wal 文件涨得太大怎么办?

默认每 1000 页触发一次自动 checkpoint,但如果有一个长时间运行的读操作挡住了它,文件就会一直涨,直到那个读操作结束。用 PRAGMA wal_checkpoint(TRUNCATE); 手动强制一次,就能清空日志并把文件缩到最小。

能关掉 PostgreSQL 的 WAL 来提速吗?

不能完全关掉:PostgreSQL 始终会写 WAL,因为崩溃恢复需要它,哪怕单机部署也是如此。你能调的是 wal_level 决定的记录详细程度,以及 fsync 的激进程度,代价就是耐久性上的取舍。

物理 WAL 和逻辑 WAL 有什么区别?

物理 WAL 记录的是磁盘页面的底层变更;逻辑 WAL 把同样的变更翻译成 insert、update 这类可读的操作,为的是在不同版本之间做复制,或者把数据导出到外部系统。

SQLite 和 PostgreSQL 实现 WAL 的方式一样吗?

原理相同,实现不同。SQLite 每个数据库只有一个 .wal 文件,每次 checkpoint 都整个刷盘;PostgreSQL 把日志切成 16 MB 的段循环复用,并且可以同时让多个副本消费同一条流。

参考资料

来源: El Solitario← 返回首页