土法炼钢兴趣小组的算法知识备份

【SQLite 内核】Rollback Journal 模式:写前拷贝与提交点

文章导航

分类入口
databasestorage
标签入口
#sqlite#rollback-journal#atomic-commit#journal-mode#hot-journal#crash-recovery#file-locking

源码下载

本文相关源码已整理,共 1 个文件。

打开下载目录 →

目录

前两篇拆完了”一条语句怎么被编译、怎么被执行”。执行到写操作时,真正决定”崩溃后数据库还完整不完整”的是另一层机制: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”选项只是性能调优的同义词。本文按四件事推进:

  1. 单文件写事务的完整锁状态机与写路径。
  2. journal 文件格式、DELETE/TRUNCATE/PERSIST 三种提交点的真实区别——用本机实测的文件大小和字节内容说话。
  3. cache spill 何时把一个”内存里的事务”变成磁盘上真正的 hot journal,以及崩溃后如何恢复。
  4. 与 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 synchronouscache_size)会改变具体触发时机。


一、写路径:五级锁状态机 + journal 写前拷贝

File Locking And Concurrency 把单进程视角下的数据库文件锁分成五种状态:

状态 含义
UNLOCKED 未持有任何锁,默认状态
SHARED 可读不可写;可与其他 SHARED 共存
RESERVED 声明”稍后要写”,仍允许新的 SHARED 加入;同一时刻只能有一个 RESERVED
PENDING 等现有 SHARED 清空以升级到 EXCLUSIVE;此状态下不再接受新的 SHARED
EXCLUSIVE 独占,写主文件的唯一窗口;与任何其他锁都不能共存

Atomic Commit In SQLite 把单文件提交的步骤具体化为:

  1. 获取 SHARED 锁,读 schema 与需要的页。
  2. 升级到 RESERVED 锁,表示”打算写了”。
  3. 创建 -journal 文件;把将要改的页的原始内容写进去(先写内存,日志本体落盘可能被 OS 延迟)。
  4. 修改在内存(user space)里进行,主文件暂时不动,其他进程仍能读到未改的原始内容。
  5. flush(fsync)journal 到磁盘——这一步通常要两次落盘:先写页记录本体,再回写 header 里的页计数,让 header 单独占一个扇区。
  6. 升级到 PENDING 再到 EXCLUSIVE 锁(PENDING 是为防止大量并发读者导致写者永远抢不到独占锁而设的中间态)。
  7. 把内存里改过的页写进主文件。
  8. flush 主文件到磁盘。
  9. 删除(或截断/清零)journal 文件——这是事务真正提交的瞬间。
  10. 释放 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 原文写得很直接——“这是事务提交的瞬间。如果在此之前崩溃,恢复逻辑会让数据库看起来好像什么都没发生过;如果在此之后崩溃,看起来就像所有改动都已落盘。” 文件是否存在,这个判断在用户态进程眼中是原子的,所以事务看起来也是原子的。

常见误解

  1. “rollback journal 是先写日志、后写数据的 WAL 式设计。” 顺序刚好相反。WAL 是”原始内容留在主文件,新内容追加到独立日志”;rollback journal 是”原始内容备份到日志,新内容直接改主文件”。两者的读写并发特性因此完全不同,第 8 篇会展开对比。

  2. “只要 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,直接破坏主数据库。

常见误解(续)

  1. “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

常见误解(续)

  1. “崩溃后主文件里的数据就是崩溃时刻写到哪就是哪,需要人工修复。” 只要 journal 是 hot 的,恢复是自动且发生在下一次打开时的读之前,不需要人工介入。真正需要警惕的是 Atomic Commit §9.5 提到的场景:崩溃后有人手工把 xxx.db-journal 当”垃圾文件”删掉——这会让一份本该被回放的 hot journal 消失,主文件就永久停留在崩溃时的中间状态。

  2. “没看到 -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 的日志与恢复机制时,会重新审视这条”单写者 + 影子页”路线在多写者场景下的可扩展性边界。


六、小结

三句话小结

  1. Rollback journal 的原子提交靠一个判断收尾:journal 文件是否存在且头部合法——存在即未提交,不存在(或头部失效)即已提交;DELETE/TRUNCATE/PERSIST 只是实现这个判断的三种不同手段。
  2. 只有当事务改动的脏页因为 cache 溢出被迫提前写入磁盘(cache spill)时,才会产生一份头部合法、真正”hot”的 journal;小事务可能全程只停留在内存里。
  3. 本机 3.53.2 实测证实:hot journal 崩溃恢复发生在下一次打开数据库、读操作真正执行之前,全程自动,恢复后数据库回到崩溃前的一致状态;WAL 如何用完全不同的思路做到读写并发提升,是第 8 篇的内容。

参考资料

规范与官方文档(A 级)

  1. SQLite Documentation, Atomic Commit In SQLite(sqlite.org/atomiccommit.html)。
  2. SQLite Documentation, File Locking And Concurrency In SQLite Version 3(sqlite.org/lockingv3.html)。
  3. SQLite Documentation, File Format For SQLite Databases,§3 The Rollback Journal(sqlite.org/fileformat2.html)。
  4. SQLite Documentation, journal_mode PRAGMA(sqlite.org/pragma.html#pragma_journal_mode)。

论文(A 级,谱系锚点)

  1. 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 直接实现该论文算法)。

实验

  1. 本机 SQLite 3.53.2 + Python sqlite3 模块(同版本 3.53.2):默认 journal_mode、DELETE/TRUNCATE/PERSIST 提交点、cache spill 触发的 hot journal 崩溃恢复(输出见第二、三节;脚本 reproduce/07-rollback-journal.sh)。

站内

  1. SQL 编译管线VDBE 字节码执行
  2. 本系列 indexPLAN.md

上一篇SQL 编译管线 下一篇WAL 与 checkpoint

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-18 · database / storage

【SQLite 内核】WAL 与 checkpoint:追加日志与读写并发

钉住 SQLite WAL 模式如何反转 rollback journal 的读写关系:原始内容留在主文件、新内容追加到 -wal,checkpoint 才把帧搬回主文件;用本机 3.53.2 实测 journal_mode=WAL 切换与 passive/truncate checkpoint 的真实帧数,读放大与 checkpoint 频率的取舍留作开放问题。

2026-07-18 · database / storage

【SQLite 内核】锁状态与 shared cache:五态锁阶梯与 writer starvation

钉住官方 File Locking And Concurrency 文档定义的 UNLOCKED/SHARED/RESERVED/PENDING/EXCLUSIVE 五态锁阶梯:谁能与谁共存、PENDING 如何防写者饿死、Pager 为何只跟踪四态;用本机 3.53.2 真实双连接冲突实验验证,shared cache 与 WAL 差异只点边界,留给第 8、10 篇。

2026-07-18 · database / storage

【SQLite 内核】ATTACH / 多库边界:跨库事务的原子性在 WAL 下会退化

钉住 ATTACH DATABASE 的命名空间规则、super-journal 如何让跨文件事务在 rollback journal 模式下保持原子,以及官方文档明确写明的一条反常识边界:main 库或任一 attached 库切到 WAL 后,跨库事务只在单文件粒度原子,崩溃中间态可能只改了一部分文件;本机 3.53.2 实测跨库 JOIN、跨库事务提交与 DETACH 锁冲突。


By .