前两篇拆完了”一条语句怎么被编译、怎么被执行”。执行到写操作时,真正决定”崩溃后数据库还完整不完整”的是另一层机制:rollback
journal。SQLite 默认的原子提交做法和 WAL(第 8
篇)正好相反——原始内容留在主文件不动,被改的页先备份到
-journal,提交点就是让这份备份失效。官方
Atomic Commit In SQLite
把这套机制的每一步都画成了图,File Locking And
Concurrency In SQLite Version 3
定义了配合它的五级锁状态机。
常见误区是把 rollback journal 想象成”先写日志再写数据”的
WAL 式思维,或者以为 PRAGMA journal_mode
三种”非
DELETE”选项只是性能调优的同义词。本文按四件事推进:
- 单文件写事务的完整锁状态机与写路径。
- journal 文件格式、DELETE/TRUNCATE/PERSIST 三种提交点的真实区别——用本机实测的文件大小和字节内容说话。
- cache spill 何时把一个”内存里的事务”变成磁盘上真正的 hot journal,以及崩溃后如何恢复。
- 与 WAL 的分工边界,细节留给下一篇。
本文是「SQLite 内核」系列第 7 篇(共 17 篇)。→ 系列目录
篇目 核心内容 第 6 篇 · SQL 编译管线 prepare 内部四段管线、schema cookie 第 7 篇 · Rollback Journal 模式 写前拷贝、提交点、hot journal 恢复 第 8 篇 · WAL 与 checkpoint -wal/-shm、checkpoint
版本锚定:官方 Atomic Commit In SQLite(sqlite.org/atomiccommit.html);File Locking And Concurrency In SQLite Version 3(sqlite.org/lockingv3.html);File Format For SQLite Databases §3(sqlite.org/fileformat2.html)。journal 头部字段、锁状态名随主线源码稳定,但
journal_mode默认值、cache spill 阈值等运行时行为按本机 SQLite 3.53.2 实测;不同编译选项(如PRAGMA synchronous、cache_size)会改变具体触发时机。
一、写路径:五级锁状态机 + journal 写前拷贝
File Locking And Concurrency 把单进程视角下的数据库文件锁分成五种状态:
| 状态 | 含义 |
|---|---|
| UNLOCKED | 未持有任何锁,默认状态 |
| SHARED | 可读不可写;可与其他 SHARED 共存 |
| RESERVED | 声明”稍后要写”,仍允许新的 SHARED 加入;同一时刻只能有一个 RESERVED |
| PENDING | 等现有 SHARED 清空以升级到 EXCLUSIVE;此状态下不再接受新的 SHARED |
| EXCLUSIVE | 独占,写主文件的唯一窗口;与任何其他锁都不能共存 |
Atomic Commit In SQLite 把单文件提交的步骤具体化为:
- 获取 SHARED 锁,读 schema 与需要的页。
- 升级到 RESERVED 锁,表示”打算写了”。
- 创建
-journal文件;把将要改的页的原始内容写进去(先写内存,日志本体落盘可能被 OS 延迟)。 - 修改在内存(user space)里进行,主文件暂时不动,其他进程仍能读到未改的原始内容。
- flush(fsync)journal 到磁盘——这一步通常要两次落盘:先写页记录本体,再回写 header 里的页计数,让 header 单独占一个扇区。
- 升级到 PENDING 再到 EXCLUSIVE 锁(PENDING 是为防止大量并发读者导致写者永远抢不到独占锁而设的中间态)。
- 把内存里改过的页写进主文件。
- flush 主文件到磁盘。
- 删除(或截断/清零)journal 文件——这是事务真正提交的瞬间。
- 释放 EXCLUSIVE 锁,退回 SHARED。
flowchart TB
s0["UNLOCKED"] --> s1["SHARED\nread schema + pages"]
s1 --> s2["RESERVED\ncreate -journal, copy original pages"]
s2 --> s3["flush journal to disk\n(two fsyncs: body, then header)"]
s3 --> s4["PENDING\nwait for other SHARED to drain"]
s4 --> s5["EXCLUSIVE\nwrite changed pages into main file"]
s5 --> s6["flush main file to disk"]
s6 --> commit["delete / truncate / zero journal header\n= COMMIT POINT"]
commit --> s1b["back to SHARED, then UNLOCKED"]
第 9 步是全篇的核心断言:Atomic Commit 原文写得很直接——“这是事务提交的瞬间。如果在此之前崩溃,恢复逻辑会让数据库看起来好像什么都没发生过;如果在此之后崩溃,看起来就像所有改动都已落盘。” 文件是否存在,这个判断在用户态进程眼中是原子的,所以事务看起来也是原子的。
常见误解
“rollback journal 是先写日志、后写数据的 WAL 式设计。” 顺序刚好相反。WAL 是”原始内容留在主文件,新内容追加到独立日志”;rollback journal 是”原始内容备份到日志,新内容直接改主文件”。两者的读写并发特性因此完全不同,第 8 篇会展开对比。
“只要 journal 文件存在,就说明有事务没提交完。” 不一定。TRUNCATE 模式提交后 journal 文件依然存在(截断为 0 字节),PERSIST 模式提交后 journal 文件大小不变、只是头部被清零——文件存在与否不等价于”未提交”,第二节实测会展示。
二、DELETE / TRUNCATE / PERSIST:同一个提交点的三种实现
File Format §3 定义了 journal 的头部格式:8
字节魔数
0xd9 0xd5 0x05 0xf9 0x20 0xa1 0x63 0xd7,随后是页计数、校验随机数、原始数据库页数、扇区大小、页大小。Atomic
Commit §3.11 说明:判定”journal
有效”的标准是它存在且头部合法,所以提交点可以用三种等价的方式实现——删除文件、截断到
0
字节、或者把头部覆写成全零(首字节一旦被清零,头部就不再合法)。这三种方式分别对应
journal_mode 的 DELETE、TRUNCATE、PERSIST。
实测(本机 3.53.2,reproduce/07-rollback-journal.sh):
=== 新建数据库的默认 journal_mode ===
delete
=== DELETE 模式:journal 文件事务中出现,提交后消失 ===
mid-transaction files: ['sk-jr-delete.db', 'sk-jr-delete.db-journal']
post-commit files: ['sk-jr-delete.db']
=== 三种模式提交后的文件状态 ===
delete post-commit files=['sk-jr-mode.db'] journal_size_after_commit=None
truncate post-commit files=['sk-jr-mode.db', 'sk-jr-mode.db-journal'] journal_size_after_commit=0
persist post-commit files=['sk-jr-mode.db', 'sk-jr-mode.db-journal'] journal_size_after_commit=8720
PERSIST 模式提交后 journal 文件仍有 8720 字节,但头部已被清零:
$ xxd -l 16 sk-jr-mode.db-journal
00000000: 0000 0000 0000 0000 0000 0000 0000 0000 ................
对照第一节的魔数,全零头部意味着这份 journal
不再”合法”,下次崩溃恢复时不会被当成 hot journal
播放。Atomic Commit §7.6 交代了 PERSIST/TRUNCATE
的动机:删除文件在很多系统上比较慢(要改目录项、释放磁盘块),截断或清零可以省掉这部分开销;PERSIST
进一步省掉”缩短文件”这一步,下次事务直接覆写已有内容,覆写通常比追加快。代价很直白:journal
文件长期占着磁盘空间,且只有用 DELETE
模式提交一次事务才能真正删掉它,用其他手段(比如手工
rm)删除可能删掉一个仍然 hot 的
journal,直接破坏主数据库。
常见误解(续)
- “TRUNCATE/PERSIST
模式性能一定更好,应该默认开启。” Atomic
Commit §7.6
明确写了权衡的另一面:在同步文件系统上,TRUNCATE
的后续事务比 PERSIST 慢,因为 TRUNCATE
每次都要重新追加内容,PERSIST 才是覆写。而 PERSIST
又要求”绝不能用非 SQLite 手段清理 journal
文件”,运维风险更高。默认 DELETE 模式换来的是”journal
不存在=最直观的已提交状态”,本机实测默认值确实是
delete。
三、Hot journal:cache spill 决定它何时真的危险
File Locking And Concurrency §4.0 给出 hot journal 的精确判定条件:journal 文件存在、大小超过 512 字节、头部合法未清零、(如果引用了 super-journal)super-journal 也存在、且主数据库文件上没有 RESERVED 锁。只要这些条件同时成立,下一个打开数据库的进程就会在读之前先把它播放回主文件。
这里有一个容易被忽略的细节:Atomic Commit §6.3 “Cache Spill Prior To Commit” 说明,如果一个事务改动的页能装进内存缓存,SQLite 会把改动一直留在内存里,直到真正 COMMIT 才去走”flush journal → 拿 EXCLUSIVE 锁 → 写主文件”的完整流程;只有当缓存被改动撑满、必须溢出(spill)到磁盘时,才会提前把这套流程走一遍(保留 EXCLUSIVE 锁,继续接受后续改动)。也就是说:一个很小的未提交事务,可能从头到尾都没有产生一份”头部合法”的 journal,因为它的脏页始终待在内存里,从未被迫溢出。
用本机实测验证这条差异(reproduce/07-rollback-journal.sh):把
cache_size 调到很小(2
页),在一个显式事务里插入约 500 行后 kill -9
模拟崩溃:
-- 事务未提交、已发生 cache spill 时的文件状态 --
-rw-r--r-- 1 ltl ltl 61440 ... sk-jr-crash.db
-rw-r--r-- 1 ltl ltl 9728 ... sk-jr-crash.db-journal
-- journal 头部魔数(应为文件格式的 8 字节魔数,不是全零)--
00000000: d9d5 05f9 20a1 63d7 .... .c.
-- kill -9 模拟崩溃 --
-- 崩溃后文件原样保留(hot journal) --
-rw-r--r-- 1 ltl ltl 61440 ... sk-jr-crash.db
-rw-r--r-- 1 ltl ltl 9728 ... sk-jr-crash.db-journal
-- 下次打开时读操作之前先自动回滚 --
orig
1
-- 回滚完成后:journal 已删除,主文件缩回崩溃前大小 --
-rw-r--r-- 1 ltl ltl 8192 ... sk-jr-crash.db
魔数确实是合法值,说明这份 journal 已经完成了 §3.7 的
flush,主文件也已经被 cache spill 写脏(61440
字节,远大于插入前的 8192
字节)。下一次打开数据库时,读之前的 hot journal
检查发现条件全部成立,自动进入 Atomic Commit
§4.3–4.6 描述的恢复流程:拿 EXCLUSIVE 锁、把 journal
里的原始页写回主文件、把主文件截断回 journal
头部记录的原始大小、flush、删除 journal、释放锁。恢复完成后
SELECT 看到的是崩溃前的旧值 orig
和 1 行——插入的约 500 行和那次 UPDATE
全部消失,如同事务从未发生。
sequenceDiagram
participant W as Writer (crashed)
participant Disk as Database + -journal
participant R as Next reader/writer
W->>Disk: RESERVED, write original pages to -journal
W->>Disk: flush journal (valid header + magic)
W->>Disk: cache spill: EXCLUSIVE, write dirty pages into main file
Note over W: kill -9 before COMMIT
R->>Disk: open, acquire SHARED lock
R->>R: detect hot journal (valid header, no RESERVED lock)
R->>Disk: acquire PENDING then EXCLUSIVE
R->>Disk: replay original pages from -journal into main file
R->>Disk: truncate main file to journal's recorded size, flush
R->>Disk: delete -journal
R->>R: drop to SHARED, continue read
常见误解(续)
“崩溃后主文件里的数据就是崩溃时刻写到哪就是哪,需要人工修复。” 只要 journal 是 hot 的,恢复是自动且发生在下一次打开时的读之前,不需要人工介入。真正需要警惕的是 Atomic Commit §9.5 提到的场景:崩溃后有人手工把
xxx.db-journal当”垃圾文件”删掉——这会让一份本该被回放的 hot journal 消失,主文件就永久停留在崩溃时的中间状态。“没看到
-journal文件,就说明没有正在进行的写事务。” 见上一节:小事务如果全程没有触发 cache spill,可能连有效头部的 journal 都不会落盘,-journal文件甚至可能完全不出现在文件系统里(一个新建的空文件在很多桌面操作系统上只存在于 OS 磁盘缓存,直到系统”有空”才真正落盘)。journal 是否存在,只在崩溃恢复语义上有意义,不能拿来判断”当前有没有写者”——判断这个应该看锁状态(第 9 篇)。
四、与 WAL 的分工边界
Rollback journal
模式下,读者始终能看到主文件里未被覆盖的旧内容,直到写者拿到
EXCLUSIVE 锁那一刻——但拿到 EXCLUSIVE
锁到释放锁之间,新的读者完全被挡住,因为
EXCLUSIVE 不能和任何其他锁共存。这是 rollback journal
模式”写会短暂阻塞读”的直接原因。WAL
模式把这个窗口几乎消除:原始内容留在主文件不动,写者只追加到
-wal,读者和写者可以真正同时进行——具体机制、checkpoint
如何把 WAL 内容搬回主文件,留给第 8 篇。
| 维度 | Rollback Journal(本篇) | WAL(第 8 篇) |
|---|---|---|
| 原始内容存放位置 | 备份进 -journal,主文件被直接改写 |
留在主文件不动 |
| 新内容存放位置 | 直接写主文件 | 追加进 -wal |
| 提交点 | 删除/截断/清零 journal | 在 WAL 里写入 commit 标记帧 |
| 写者对读者的阻塞窗口 | EXCLUSIVE 锁持有期间完全阻塞新读者 | 官方文档称”读者不阻塞写者、写者不阻塞读者”,但有例外(第 8 篇) |
五、学术谱系、工程间隙与开放问题
谱系:影子页(shadow paging)/写前拷贝式回滚是数据库崩溃恢复的经典手段之一,和 ARIES 式的”写前日志 + REDO/UNDO”(Mohan et al., 1992)代表了两条不同的恢复设计路线——ARIES 假设有独立的日志空间和检查点机制来支持细粒度并发恢复,rollback journal 走的是更简单的”每次写事务备份原页、提交时销毁备份”,用单写者约束换掉了对细粒度日志记录和并发恢复的需求。SQLite 选择后者,和它单进程、单写者的定位一致——第 9–10 篇会继续展开这个约束对事务模型的影响。
工程间隙:Atomic Commit
全文反复强调它依赖一组”硬件假设”——扇区写线性但不原子、文件删除对用户态进程原子、fsync/FlushFileBuffers
会等到数据真正落盘。文档自己也承认:Windows 和 Linux
的某些版本上这些系统调用”没有像文档说的那样工作”,SQLite
对此无能为力,只能假设操作系统按承诺行事。这是论文/规范式恢复保证与真实硬件/操作系统行为之间的典型落差——本篇的实测复现的是”操作系统按预期工作”的正常路径,不能证明所有平台上的
fsync 语义都可靠。
开放问题:cache spill 阈值、扇区大小假设(历史上长期硬编码 512 字节,参见 Atomic Commit §2)在现代 NVMe/云盘场景下是否仍然是合理的保守假设,官方文档也承认这是留给未来 VFS 层继续调整的方向,没有给出”已解决”的结论。第 16 篇对照 PG/InnoDB 的日志与恢复机制时,会重新审视这条”单写者 + 影子页”路线在多写者场景下的可扩展性边界。
六、小结
三句话小结
- Rollback journal 的原子提交靠一个判断收尾:journal 文件是否存在且头部合法——存在即未提交,不存在(或头部失效)即已提交;DELETE/TRUNCATE/PERSIST 只是实现这个判断的三种不同手段。
- 只有当事务改动的脏页因为 cache 溢出被迫提前写入磁盘(cache spill)时,才会产生一份头部合法、真正”hot”的 journal;小事务可能全程只停留在内存里。
- 本机 3.53.2 实测证实:hot journal 崩溃恢复发生在下一次打开数据库、读操作真正执行之前,全程自动,恢复后数据库回到崩溃前的一致状态;WAL 如何用完全不同的思路做到读写并发提升,是第 8 篇的内容。
参考资料
规范与官方文档(A 级)
- SQLite Documentation, Atomic Commit In SQLite(sqlite.org/atomiccommit.html)。
- SQLite Documentation, File Locking And Concurrency In SQLite Version 3(sqlite.org/lockingv3.html)。
- SQLite Documentation, File Format For SQLite Databases,§3 The Rollback Journal(sqlite.org/fileformat2.html)。
- SQLite Documentation,
journal_modePRAGMA(sqlite.org/pragma.html#pragma_journal_mode)。
论文(A 级,谱系锚点)
- Mohan, C., et al. ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging. ACM TODS, 1992(write-ahead 恢复的对照谱系,不代表 SQLite WAL 直接实现该论文算法)。
实验
- 本机 SQLite 3.53.2 + Python
sqlite3模块(同版本 3.53.2):默认journal_mode、DELETE/TRUNCATE/PERSIST 提交点、cache spill 触发的 hot journal 崩溃恢复(输出见第二、三节;脚本reproduce/07-rollback-journal.sh)。
站内
上一篇:SQL 编译管线 下一篇:WAL 与 checkpoint
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【SQLite 内核】WAL 与 checkpoint:追加日志与读写并发
钉住 SQLite WAL 模式如何反转 rollback journal 的读写关系:原始内容留在主文件、新内容追加到 -wal,checkpoint 才把帧搬回主文件;用本机 3.53.2 实测 journal_mode=WAL 切换与 passive/truncate checkpoint 的真实帧数,读放大与 checkpoint 频率的取舍留作开放问题。
【SQLite 内核】锁状态与 shared cache:五态锁阶梯与 writer starvation
钉住官方 File Locking And Concurrency 文档定义的 UNLOCKED/SHARED/RESERVED/PENDING/EXCLUSIVE 五态锁阶梯:谁能与谁共存、PENDING 如何防写者饿死、Pager 为何只跟踪四态;用本机 3.53.2 真实双连接冲突实验验证,shared cache 与 WAL 差异只点边界,留给第 8、10 篇。
【SQLite 内核】ATTACH / 多库边界:跨库事务的原子性在 WAL 下会退化
钉住 ATTACH DATABASE 的命名空间规则、super-journal 如何让跨文件事务在 rollback journal 模式下保持原子,以及官方文档明确写明的一条反常识边界:main 库或任一 attached 库切到 WAL 后,跨库事务只在单文件粒度原子,崩溃中间态可能只改了一部分文件;本机 3.53.2 实测跨库 JOIN、跨库事务提交与 DETACH 锁冲突。
【SQLite 内核】嵌入式行存全景:单文件、单写者、零 IPC
定位单文件嵌入式行存在服务器行存与 LSM 嵌入 KV 之间的生态位;钉住 SQLite 架构约束、站内分工与 17 篇阅读路线,并以 Bayer/McCreight、官方 file format、PVLDB 2022 为学术锚点。