原文发布于 quant67.com,转载请保留出处。
前两篇拆完了"一条语句怎么被编译、怎么被执行"。执行到写操作时,真正决定"崩溃后数据库还完整不完整"的是另一层机制: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。
第 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):
text
=== 新建数据库的默认 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 字节,但头部已被清零:
text
$ 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 模拟崩溃:
text
-- 事务未提交、已发生 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 全部消失,如同事务从未发生。
常见误解(续)
-
"崩溃后主文件里的数据就是崩溃时刻写到哪就是哪,需要人工修复。" 只要 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