第 7 篇钉住了 rollback journal 的写路径:原始内容备份到
-journal,改动直接写主文件,EXCLUSIVE
锁持有期间新读者完全被挡。官方 Write-Ahead Logging
文档把 WAL
模式的设计动作总结成一句话——“把这个关系反过来”:原始内容留在主文件不动,改动追加到独立的
-wal 文件,提交就是往 WAL 里写一条带 commit
标记的帧。这个反转直接换来”读者不阻塞写者、写者不阻塞读者”的并发特性,但也引入了一个
rollback journal
里不存在的新操作——checkpoint,以及一个新的开放权衡:WAL
越大读得越慢,checkpoint 越频繁写得越慢。
常见误区是把 WAL 和 PostgreSQL 的 WAL 划等号,或者以为开启 WAL 后 checkpoint 是可选项。本文按四件事推进:
- WAL 文件格式、帧结构与 wal-index 共享内存如何支撑并发读写。
- checkpoint 的官方三种主动模式(PASSIVE/FULL/RESTART)与 TRUNCATE 变体,谁能真正让 WAL 文件收缩。
- 本机 3.53.2 实测:
journal_mode=WAL切换、-wal/-shm文件的出现与 checkpoint 前后的真实帧数。 - 与第 7 篇 rollback journal、以及 PostgreSQL WAL 的分工边界。
本文是「SQLite 内核」系列第 8 篇(共 17 篇)。→ 系列目录
篇目 核心内容 第 7 篇 · Rollback Journal 模式 写前拷贝、DELETE/TRUNCATE/PERSIST、hot journal 第 8 篇 · WAL 与 checkpoint -wal/-shm、帧、checkpoint 模式第 9 篇 · 锁状态与 shared cache UNLOCKED→…→EXCLUSIVE 完整状态机
版本锚定:官方 Write-Ahead Logging(sqlite.org/wal.html);File Format For SQLite Databases §4(sqlite.org/fileformat2.html);
wal_checkpointPRAGMA 与sqlite3_wal_checkpoint_v2()C API 文档。WAL 文件格式、checkpoint 模式名跨版本稳定;自动 checkpoint 阈值(默认 1000 页)、synchronous相关的 fsync 次数属于运行时行为,按官方文档转述,不在本机重复验证 fsync 计数。实测锚定本机 SQLite 3.53.2。
一、反转关系:原始内容不动,改动追加到
-wal
Write-Ahead Logging §2 的原文表述:“传统 rollback journal 把原始内容写进独立的日志文件,改动直接写进数据库文件……WAL 的做法反过来:原始内容留在数据库文件里不变,改动追加进一个独立的 WAL 文件。” 提交(COMMIT)就是往 WAL 追加一条带 commit 标记的记录,这个操作不需要碰主数据库文件,因此其他读者可以继续从”原始未改的主文件”读,同时写者的提交正在同时发生。
File Format §4 定义了具体格式:WAL 文件以 32
字节头部开始(4 字节魔数
0x377f0682/0x377f0683、文件格式版本、页大小、checkpoint
序号、两个随机
salt、两段头部校验和),随后是零到多个”帧”(frame),每帧 24
字节帧头 +
一个完整页大小的数据。帧头记录页号、(提交帧才有的)提交后数据库总页数、从
WAL 头复制过来的两个 salt、以及累积校验和。校验和链和 salt
共同保证:即使 WAL
文件被复用覆写,读者也能准确分辨哪些帧是当前有效内容、哪些是上一轮遗留的垃圾。
flowchart LR
subgraph main ["Main database file"]
orig["Original pages\n(unchanged by writers)"]
end
subgraph wal ["-wal file"]
h["WAL header\nmagic + page size + salt"]
f1["frame: page 3"]
f2["frame: page 7"]
f3["frame: page 3 (commit)"]
end
writer["Writer"] -->|"append frames"| wal
reader1["Reader (end mark after f2)"] -.->|"read page 3 from main"| orig
reader1 -.->|"read page 7 from wal"| f2
reader2["Reader (end mark after f3)"] -.->|"read page 3 from wal (latest)"| f3
常见误解
“SQLite 的 WAL 和 PostgreSQL 的 WAL 是同一个东西。” 两者只是同名。PostgreSQL 的 WAL 是redo 日志:崩溃恢复时重放日志把已提交但未刷盘的改动重新应用到数据文件,日志本身不是”当前数据”的来源,只是恢复手段。SQLite 的 WAL 里的帧本身就是当前有效数据的一部分——一次读取可能同时从主文件和 WAL 拼出最新内容(见下一节),WAL 文件不只是恢复用的日志,是常态化的数据存放位置之一。本系列第 1、16 篇已经预告过这条差异,这里给出具体机制依据。
“开启 WAL 之后就不用管 checkpoint 了。” 官方文档写明默认行为是”WAL 达到约 1000 页时自动触发 PASSIVE checkpoint”,不是”从不需要 checkpoint”。应用可以关掉自动 checkpoint,但那样做需要自己保证有别的机制定期搬运,否则 WAL 文件会无限增长(第三节展开)。
二、读者如何在主文件和 WAL 之间拼出一致视图
Write-Ahead Logging §2.2 描述了读事务开始时的动作:先记住 WAL 当前最后一条有效 commit 记录的位置,称为这次读事务的”end mark”。之后读任何一页,先查 WAL 里在 end mark 之前有没有这一页更新的版本,有就用它,没有才去读主文件里的原始内容——整个读事务期间 end mark 不变,所以能保证单次事务看到的是某一个时间点的一致快照。
为了不让每个读者都线性扫一遍可能几 MB 大的 WAL
文件,SQLite 维护一个”wal-index”(默认落在 -shm
共享内存文件里,不超过约 32 KiB,从不
fsync)加速页查找。共享内存意味着 WAL
只能在同一台主机上工作——这是它相对 rollback
journal(可以工作在网络文件系统上,尽管不推荐)新增的一条部署约束。
checkpoint 的推进方向也要尊重这个 end mark:Write-Ahead Logging §2.2 写道,checkpoint 可以和读者并发运行,但一旦追到某个读者的 end mark 之后,就必须停下——继续往后搬会把这个读者正在用的内容从 WAL 里”抽走”,checkpoint 会记住自己搬到哪,等下一次调用再继续。如果并发读者一直存在(“没有 reader gap”),checkpoint 就永远追不上,WAL 文件只会持续增长。
三、checkpoint:PASSIVE / FULL / RESTART 与 TRUNCATE
Write-Ahead Logging §3.2 给出三种主动 checkpoint 的官方定义:
| 模式 | 行为 | 能否保证跑完 |
|---|---|---|
| PASSIVE | 在不打扰其他连接的前提下尽量多搬;默认模式,自动 checkpoint 也用这个 | 不保证;有并发读写时可能只搬一部分 |
| FULL | 更努力地跑到完成,会等待写者,但不重置 WAL | 通常能完成,除非一直有读者卡着 end mark |
| RESTART | 在 FULL 基础上,完成后还要保证下一个写者能从 WAL 头部重新开始写 | 同上,且要求没有其他读者正在用 WAL |
| TRUNCATE | RESTART 之上再把 WAL 文件截断到 0 字节 | 同上;这是唯一会让 -wal 文件变小的模式 |
PRAGMA wal_checkpoint 与 C 接口
sqlite3_wal_checkpoint_v2()
返回三个整数:第一列是否被阻塞(0/1);第二列是 WAL
里的帧总数;第三列是已经成功搬回主文件的帧数。§6
的一条重要说明容易被忽略:PASSIVE/FULL/RESTART
正常情况下都不会截断 WAL
文件,只是把写指针”倒回”WAL
开头,让后续事务覆写旧内容——因为在大多数文件系统上覆写比追加更快。只有显式用
TRUNCATE 才会真正缩小文件体积。
3.1 checkpoint 与 fsync 的关系
Write-Ahead Logging §2.3 把 WAL 相对 rollback
journal 的一条性能优势归结为”更少的
fsync()“:synchronous=NORMAL
时,写事务提交只需要保证 WAL 里的内容顺序落盘,不需要像
rollback journal 那样在提交路径上做两次针对不同文件的
flush;真正需要 fsync 主文件的时机被推迟到了
checkpoint——文档原文强调,synchronous=NORMAL
下checkpoint 是唯一会发出 I/O barrier / sync
的操作。这意味着”提交延迟”和”checkpoint 延迟”在 WAL
模式下被拆成了两笔账,可以分别调度(例如把 checkpoint
放到独立线程),但也意味着如果应用只关注单次
COMMIT 的耗时,会低估 checkpoint 阶段集中出现的
I/O 成本。
四、实测:切换 WAL、文件出现、checkpoint 前后的真实帧数
环境:本机 sqlite3 3.53.2 +
同版本 Python sqlite3 模块;表
t(id INTEGER PRIMARY KEY, v TEXT)。复现脚本:reproduce/08-wal-checkpoint.sh。
journal_mode -> wal
after CREATE TABLE: ['sk-wal.db', 'sk-wal.db-shm', 'sk-wal.db-wal']
PRAGMA journal_mode=WAL 返回字符串
wal,和 Write-Ahead Logging §3
描述的成功返回值一致;-shm 和 -wal
在第一次写(这里是
CREATE TABLE)之后立即出现。插入约 200
行后、执行 checkpoint 之前:
sk-wal.db 4096 bytes
sk-wal.db-shm 32768 bytes
sk-wal.db-wal 28872 bytes
主文件大小完全没变(仍是初始的一页),所有改动都在 WAL 里。执行一次 PASSIVE checkpoint:
PASSIVE checkpoint -> busy=0 total_frames=7 checkpointed=7
sk-wal.db 20480 bytes
sk-wal.db-shm 32768 bytes
sk-wal.db-wal 28872 bytes
7 帧全部搬回主文件(busy=0
表示没有被阻塞),主文件从 1 页涨到 5
页(20480/4096),但WAL
文件大小丝毫未变——这正是上一节的关键点:PASSIVE
只是把内容搬走并允许后续覆写,不截断文件。再执行一次
TRUNCATE checkpoint:
TRUNCATE checkpoint -> busy=0 total_frames=0 checkpointed=0
sk-wal.db 20480 bytes
sk-wal.db-shm 32768 bytes
sk-wal.db-wal 0 bytes
此时 WAL 里已经没有待搬运的帧(上一次 PASSIVE
已经搬完),total_frames=0 与 C API
文档”TRUNCATE 成功完成后 *pnLog 和
*pnCkpt 都会置零”的描述吻合;-wal
文件被真正截断到 0 字节,-shm
大小不受影响。完整输出与脚本见 reproduce/08-wal-checkpoint.sh;换机器或换版本后具体帧数会因页面填充率不同而变化,机制结论(PASSIVE
不缩文件、TRUNCATE 才缩)不受影响。
4.1 -shm
为什么始终是 32768 字节
三次实测里 -shm 的大小从头到尾都是 32768
字节,不随插入行数或 checkpoint 变化。Write-Ahead
Logging §7 交代了原因:wal-index 被设计成”很少超过 32
KiB”,实现上是把这块共享内存映射到磁盘上的一个普通文件(-shm),而不是用
/dev/shm 之类的匿名共享内存——原因是后者在不同
chroot
根目录下的进程会看到不同的路径,容易导致多个进程各用各的
wal-index、互相看不见对方的更新,进而损坏数据库。用磁盘文件
mmap 出来的共享内存虽然理论上可能触发额外磁盘
I/O,但官方认为影响很小,因为这块内存”从不
fsync”,且最后一个连接断开时 -shm
会被删除,往往根本没有机会真正落盘。这是一个典型的”为可移植性牺牲一点理论上的
I/O 效率”的工程选择,不是性能上的疏忽。
sequenceDiagram
participant App
participant Wal as -wal file
participant Main as main db file
App->>Wal: PRAGMA journal_mode=WAL
App->>Wal: CREATE TABLE / INSERT (append frames)
Note over Wal: 28872 bytes, main file untouched
App->>Main: PRAGMA wal_checkpoint(PASSIVE)
Wal->>Main: copy 7 frames back
Note over Main: grows to 20480 bytes; -wal size unchanged
App->>Wal: PRAGMA wal_checkpoint(TRUNCATE)
Wal->>Wal: truncate to 0 bytes
常见误解(续)
“WAL 模式下主文件的大小就是数据库的真实大小。” 实测里主文件在插入 200 行之后仍然只有 4096 字节——真实数据全在 28872 字节的
-wal里。评估一个 WAL 模式数据库的磁盘占用,必须把-wal(和可能存在的-shm)一起算进去,尤其是禁用了自动 checkpoint 或长期有读者卡住 end mark 的场景。“最后一个连接关闭后,
-wal/-shm一定会被清理掉。” 通常会,官方文档写的是”最后一个连接关闭时会做一次 checkpoint 再删除-wal/-shm“,但这依赖”干净关闭”。如果进程崩溃或被kill -9,这次收尾动作不会发生,-wal/-shm会留在磁盘上,下一次打开时触发的是 WAL 崩溃恢复路径(持有 EXCLUSIVE 锁做 recovery),而不是第 7 篇的 hot journal 回放——两条恢复路径的锁与文件操作完全不同,不能混用同一套排障步骤。
五、与 rollback journal、与 PostgreSQL WAL 的边界
和第 7 篇的对照收在一张表里:
| 维度 | Rollback Journal(第 7 篇) | WAL(本篇) |
|---|---|---|
| 原始内容位置 | 备份进 -journal,主文件被直接改写 |
留在主文件不动 |
| 新内容位置 | 直接写主文件 | 追加进 -wal |
| 提交动作 | 删除/截断/清零 journal | 写入带 commit 标记的帧 |
| 读写并发 | EXCLUSIVE 锁持有期间新读者被挡 | 读写基本互不阻塞,但有第九节例外 |
| 额外持久化操作 | 无(journal 本身即恢复单元) | checkpoint,需要独立调度 |
| 网络文件系统 | 可用(不推荐) | 不支持(依赖共享内存) |
与 PostgreSQL 的一句话对照,呼应第 1、16 篇已预告的分工:PostgreSQL WAL 是 redo 日志,恢复时重放到堆文件,日志本身不参与正常读路径;SQLite WAL 的帧直接参与正常读路径(读者按 end mark 在 WAL 与主文件之间挑最新版本),checkpoint 才是把这些帧”退休”进主文件的动作。同名不同构造,第 16 篇会展开完整对照表,这里只钉住这一句判断依据。
WAL 还带来一条第 13 篇要用到的边界:Write-Ahead
Logging §1 在”劣势”列表里写明,涉及多个
ATTACH 数据库的事务,WAL
模式下每个数据库各自原子,但跨数据库整体不再保证原子——这和第
7 篇 rollback journal 靠 super-journal
实现的跨库原子提交不同,是选择 WAL
时需要明确知道的取舍,不是缺陷。
六、学术谱系、工程间隙与开放问题
谱系:把”改动先追加到日志、原地内容延迟更新”当作正常读路径的一部分,而不仅是恢复手段,这条思路和 LSM-Tree(O’Neil et al., Acta Informatica, 1996)“用顺序追加换随机写”的动机同源,但落地方式完全不同——LSM 是多层排序结构 + compaction,SQLite WAL 是单一追加文件 + wal-index 内存索引 + checkpoint,服务的仍然是原有的页式 B-Tree 存储,不改变第 4 篇讨论的存储结构本身。这是”日志优先”思想在不同存储引擎里的两条分叉:一条彻底重构存储层(LSM),一条只重构提交与并发路径(SQLite WAL)。
工程间隙:官方文档在 2026-03-03 披露的”WAL-Reset Bug”(Write-Ahead Logging §11)是一个很具体的例子——在两个及以上连接并发 checkpoint 与写事务、且时机严格重合的极端竞态下,wal-index 头部的一个字段可能被错误标记为”已 checkpoint”,导致后续 checkpoint 跳过实际未落盘的事务,造成数据库损坏。官方说明这个 bug 从 3.7.0(2010)一直存在到 3.51.2(2026-01-09),3.51.3 起修复,开发者承认”在实验室里从未能自然复现,只能通过测试钩子人为触发”。这条记录本身就是”论文/规范式并发正确性证明”与”真实实现的数据竞争”之间落差的活例子:文档描述的并发模型是正确的,但具体实现多年后才被发现存在这个窗口。
开放问题:读放大与 checkpoint
频率之间的取舍,官方文档给的是定性建议(“想要读快就勤
checkpoint,想要写快就让 WAL
长一点”),没有给出通用的最优周期公式;不同工作负载(写多读少
vs 读多写少、是否存在长事务卡住 end mark)需要不同的
wal_autocheckpoint 阈值与手工 checkpoint
调度策略,这属于第 17
篇选型讨论时仍然开放的运维问题,本篇不作跨库吞吐数字的引用,因为没有跑过口径一致的对比实验。
七、小结
三句话小结
- WAL 把 rollback journal
的读写关系整体反转:原始内容留在主文件,改动追加进
-wal,提交只是写入一条带 commit 标记的帧,不需要碰主文件,因此读写基本不互相阻塞。 - checkpoint 是 WAL
模式新增的必要动作:PASSIVE(默认、自动触发)尽量多搬但不缩文件,只有
TRUNCATE 会把
-wal截断到 0 字节;本机实测证实 PASSIVE 后主文件涨、WAL 文件大小不变,TRUNCATE 后 WAL 才真正归零。 - SQLite WAL 与 PostgreSQL WAL 只是同名:前者的帧参与正常读路径、需要独立的共享内存 wal-index 与 checkpoint 调度;后者是纯粹的恢复用 redo 日志——读写并发提升与读放大/checkpoint 调度之间的权衡仍是开放的运维问题,留给第 17 篇继续讨论。
参考资料
规范与官方文档(A 级)
- SQLite Documentation, Write-Ahead Logging(sqlite.org/wal.html),含 §11 The WAL-Reset Bug。
- SQLite Documentation, File Format For SQLite Databases,§4 The Write-Ahead Log(sqlite.org/fileformat2.html)。
- SQLite Documentation,
wal_checkpoint/journal_mode/wal_autocheckpointPRAGMA(sqlite.org/pragma.html)。 - SQLite C API,
sqlite3_wal_checkpoint_v2()(sqlite.org/c3ref/wal_checkpoint_v2.html)。
论文(A 级,谱系对照)
- O’Neil, P., Cheng, E., Gawlick, D. & O’Neil, E. The Log-Structured Merge-Tree (LSM-Tree). Acta Informatica, 1996(“日志优先”思路的对照谱系,不代表 SQLite WAL 采用 LSM 结构)。
实验
- 本机 SQLite 3.53.2 + 同版本 Python
sqlite3模块:journal_mode=WAL切换、-wal/-shm文件出现、PASSIVE/TRUNCATE checkpoint 前后帧数与文件大小(输出见第四节;脚本reproduce/08-wal-checkpoint.sh)。
站内
上一篇:Rollback Journal 模式 下一篇:锁状态与 shared cache
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【SQLite 内核】在线备份与 sqlite3_backup:与 cp 热拷贝的语义差
拆解 sqlite3_backup_init/step/finish 与 CLI .backup 如何在不停机的前提下拿到一致快照;用本机 3.53.2 实测 .backup 后备份库 count(*)=3、integrity_check=ok,并实测对比:在写事务打开期间用 cp 拷贝主文件,物理文件已经比头部记录的逻辑页数更大。
【SQLite 内核】嵌入式行存全景:单文件、单写者、零 IPC
定位单文件嵌入式行存在服务器行存与 LSM 嵌入 KV 之间的生态位;钉住 SQLite 架构约束、站内分工与 17 篇阅读路线,并以 Bayer/McCreight、官方 file format、PVLDB 2022 为学术锚点。
【SQLite 内核】Pager 与 Page Cache
拆解 Pager 作为 B-Tree 与操作系统文件之间的契约层:page cache 命中/未命中路径、脏页生命周期与提交前的可回滚保证,并与 PG shared_buffers、InnoDB Buffer Pool 的跨进程共享模型对照。
【SQLite 内核】Rollback Journal 模式:写前拷贝与提交点
钉住 SQLite 默认的原子提交机制:写前把原页拷进 -journal、DELETE/TRUNCATE/PERSIST 三种提交点如何实现、cache spill 何时把 journal 变成真正的 hot journal;用本机 3.53.2 实测崩溃恢复全过程,WAL 对照留给第 8 篇。