第 7、8 篇钉住了两条自动恢复路径:hot rollback journal
回放、WAL
崩溃恢复,两者都不需要用户干预,官方文档也强调这一点——应用崩溃或断电后,“partially
written transaction should be automatically rolled back the
next time the database file is
accessed”。但自动恢复只覆盖”最近一次事务是否完整落盘”这一个问题,不覆盖磁盘位翻转、错误工具误操作、文件系统
bug 造成的结构损坏。这些情况没有自动路径,只能靠
PRAGMA integrity_check 主动去查,查出问题后再靠
.recover 之类的工具去抢救。
常见误区是把”完整性检查通过”当成”数据绝对没问题”的证明,或者以为损坏恢复工具能把损坏前的数据原样找回来。本文只钉三件事:
integrity_check与quick_check到底检查什么、明确不检查什么,复杂度差在哪。- 本机 3.53.2
上,一个完好数据库与一个被刻意翻转字节的数据库,
integrity_check的真实返回内容分别是什么;.recover和.dump在数据已经丢失的情况下各自的真实行为。 - 官方 How To Corrupt Your Database Files 列出的常见损坏来源如何对应第 2、7、8、9 篇已经拆过的文件格式与并发机制,以及修复工具的能力边界。
本文是「SQLite 内核」系列第 14 篇(共 17 篇)。→ 系列目录
篇目 核心内容 第 13 篇 · ATTACH / 多库边界 跨库事务与锁、原子性张力 第 14 篇 · 完整性检查与损坏恢复 integrity_check/quick_check、损坏来源、.recover边界第 15 篇 · 在线备份与 sqlite3_backup Hot copy API、与 cp的语义差
版本锚定:官方 PRAGMA statements(sqlite.org/pragma.html,
integrity_check/quick_check章节)、How To Corrupt An SQLite Database File(sqlite.org/howtocorrupt.html)、Recovering Data From A Corrupt SQLite Database(sqlite.org/recovery.html)、The Checksum VFS Shim(sqlite.org/cksumvfs.html)。实测锚定本机 SQLite 3.53.2。
一、integrity_check
与 quick_check:查什么、不查什么
官方 PRAGMA statements 文档对
integrity_check
的定义很具体:它做的是”数据库的低级格式与一致性检查”,查找:
- 表或索引条目顺序错乱;
- 记录格式错误;
- 缺失页、多余页或重复使用同一页;
- 缓存一致性问题。
检查全部通过时返回单行字符串
ok;有问题则每条问题各占一行,最多返回
N 条(默认 100
条,可传参调整)。文档同时明确写了两条边界:
PRAGMA integrity_check does not find FOREIGN KEY errors. Use the PRAGMA foreign_key_check command to find errors in FOREIGN KEY constraints.
See also the PRAGMA quick_check command which does most of the checking of PRAGMA integrity_check but runs much faster.
quick_check 跳过的是 UNIQUE
约束校验和”索引内容是否与表内容一致”这两项,因此复杂度从
integrity_check 的 \(O(N \log N)\) 降到 \(O(N)\)(\(N\)
为总行数)——省掉的正是需要排序/比对索引与表的那部分工作。这意味着
quick_check
通过不能替代”索引没有和表数据脱节”这条结论,只有
integrity_check 才检查这一点。
flowchart TD
q["Suspect corruption?"] --> qc["PRAGMA quick_check<br/>O(N), skips UNIQUE + index-vs-table"]
qc -->|"errors found"| stop1["Stop: database has structural problems"]
qc -->|"ok"| ic["PRAGMA integrity_check<br/>O(N log N), full structural + index check"]
ic -->|"errors found"| stop2["Stop: run .recover, do not keep writing"]
ic -->|"ok"| fk["PRAGMA foreign_key_check<br/>FK constraints (separate from integrity_check)"]
fk --> done["Structural + FK checks passed<br/>(not a content-checksum guarantee, see section 6)"]
需要强调:这两个 PRAGMA 检查的是结构一致性——B-Tree 的页链接、cell 边界、索引与表的对应关系是否自洽,不是”文件里存的每一个字节有没有被意外改动”。第六节会展开这条边界为什么容易被误解。
二、实测:完好库、人为损坏、修复工具的真实行为
环境:本机 sqlite3
3.53.2。复现脚本:reproduce/14-integrity.sh。
2.1 完好库:两个 PRAGMA
都返回 ok
=== healthy database: integrity_check / quick_check ===
ok
ok
三行数据、两页大小的最小数据库,integrity_check
和 quick_check 都返回单行
ok,符合文档描述的”无错误”格式。
2.2 制造一次真实的结构损坏
不伪造输出,而是真的在文件里翻转字节:用
PRAGMA page_count/page_size
确认这个库是两页(页大小 4096 字节),hexdump page 2
后定位到页内偏移 0x0fee(文件绝对偏移
8174)开始的 18
字节正是三条表行记录(cell)在页尾的实际存储位置,对这 18
字节做异或翻转,不触碰文件头和 schema 页。结果:
=== corrupted copy: integrity_check ===
Error in 2nd command line argument: database disk image is malformed
*** in database main ***
Tree 2 page 2 cell 2: Extends off end of page
Tree 2 page 2 cell 1: Extends off end of page
Tree 2 page 2 cell 0: Extends off end of page
quick_check
在同一份文件上给出完全相同的三行错误——因为这次损坏影响的正是
cell 边界本身,属于 quick_check
也会检查的基础结构问题,不属于它跳过的
UNIQUE/索引一致性范畴。直接 SELECT * FROM t;
则直接报错退出,读不到任何一行:
=== corrupted copy: SELECT * FROM t ===
Error in 2nd command line argument: database disk image is malformed
2.3
.recover 与 .dump
在数据已丢失时的真实差异
对这份损坏文件分别跑 .recover 和
.dump,两者 exit code 都是
0,都不会因为部分数据不可读而报错退出——这是需要单独强调的一点,下面详细区分行为差异。
.recover 生成的 SQL:
.dbconfig defensive off
BEGIN;
PRAGMA writable_schema = on;
PRAGMA foreign_keys = off;
PRAGMA encoding = 'UTF-8';
PRAGMA page_size = '4096';
PRAGMA auto_vacuum = '0';
PRAGMA user_version = '0';
PRAGMA application_id = '0';
CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT);
PRAGMA writable_schema = off;
COMMIT;把这份 SQL 重新加载进一个新数据库后:
-- rows recovered --
-- row count --
0
-- integrity_check of recovered database --
ok
.recover
只救回了表结构(CREATE TABLE t),三行数据一行都没有——因为我们翻转的正是这三行数据实际存储的字节,内容已经被真正破坏,不是”位置乱了但内容还在”,.recover
没有办法从物理上不存在的字节里生成数据。重新加载后的数据库本身是结构完好的(integrity_check
返回
ok),但这只能证明”新库自洽”,不能证明”数据完整”。
再看 .dump(对同一份损坏文件):
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT);
COMMIT;.dump 同样只输出了表结构,没有任何一条
INSERT,也没有打印任何错误或警告,退出码是
0,看起来像是”成功导出了一个空表”。这一点比
.recover 更值得警惕:.recover
至少是专门设计来在结构层面之下工作的抢救工具(底层用
sqlite_dbdata/sqlite_dbptr
虚表直接扫描裸页,不依赖正常的 B-Tree
遍历);.dump 走的是普通 SELECT
路径,一旦某张表的页读不出来,.dump
只是安静地跳过这部分内容,不会像交互式
SELECT * FROM t; 那样报错——如果有人把”跑一次
.dump
没报错”当作”这份数据库没问题”的信号,这次实测的结果就是一个反例。
三、常见损坏来源:官方文档与本系列已拆过的机制对应
官方 How To Corrupt An SQLite Database File 用一整篇文档列举了历史上真实出现过的损坏路径。挑四类和本系列直接相关的:
| 来源 | 官方文档原话(节选) | 与本系列的关系 |
|---|---|---|
| 应用绕开 SQLite 直接写裸文件 | “SQLite database files are ordinary disk files. That means that any process can open the file and overwrite it with garbage. There is nothing that the SQLite library can do to defend against this.”(§1) | 第 2 篇的文件格式没有任何权限保护,谁都能用普通文件 I/O 改写 |
| 备份/热拷贝踩在事务中间 | “Systems that run automatic backups in the background might try to make a backup copy of an SQLite database file while it is in the middle of a transaction. The backup copy then might contain some old and new content, and thus be corrupt.”(§1.2) | 第 15 篇要展开的 cp 与
sqlite3_backup 语义差,根源就是这一条 |
| 断电 + 关掉保护性 PRAGMA | “Using PRAGMA journal_mode=OFF or PRAGMA journal_mode=MEMORY and taking an application crash in the middle of a write transaction.”(§7);“Setting PRAGMA synchronous=OFF can cause the database to go corrupt if there is an operating-system crash or power failure”(§7) | 第 7 篇的 rollback journal、第 8 篇的 WAL 都依赖日志和
sync 语义;关掉
journal_mode/synchronous
等于主动拆掉这两篇讲的保护机制 |
| 网络文件系统锁语义不可靠 | “SQLite depends on the underlying filesystem to do locking as the documentation says it will. But some filesystems contain bugs in their locking logic… This is especially true of network filesystems and NFS in particular.”(§2.1) | 第 9 篇已经引用过这条,用来解释为什么官方建议不要在 NFS 上跑 SQLite——五态锁阶梯的正确性建立在”文件锁原语可靠”这个前提上 |
这四类损坏来源指向同一个结论:SQLite
对”正常路径内的崩溃”(进程崩溃、断电、期间没人乱动文件)有完整的自动恢复能力,但对”绕开正常路径”的操作没有任何防御——不是设计缺陷,官方原文就是这么写的:“There
is nothing that the SQLite library can do to defend against
this”。integrity_check
存在的意义,正是给这类”防御范围之外”的损坏提供一个事后能主动查出来的手段。
sequenceDiagram
participant App as Application
participant Pager as SQLite Pager
participant Disk as Database file
Note over App,Disk: Normal path: crash/power-loss mid-transaction
App->>Pager: write via SQLite API
Pager->>Disk: journal / WAL (per article 7-8)
Note over Pager,Disk: crash here -> automatic recovery on next open
Note over App,Disk: Bypass path: no automatic defense
App->>Disk: raw file write / cp mid-transaction / NFS lock race
Note over Disk: no journal/WAL entry protects this write
Note over App: only PRAGMA integrity_check can catch it after the fact
四、修复边界:抢救,不是恢复
.recover(CLI 的 .recover
命令,底层是 recovery
API:sqlite3_recover_init() +
sqlite3_recover_step())的官方定位说得很直接:
The recovery API does as good of a job as it can at restoring a database, but the results will always be suspect.
文档列出的几种典型退化情况:
- 部分内容永久丢失、无法恢复(本文第二节的实测就是这一种)。
- 已删除的旧内容可能被”复活”——
DELETE之后 SQLite 通常不会立刻覆写被删的字节,只是标记为可复用,恢复时可能把这些本该消失的内容重新捞出来。 - 恢复出来的值可能被篡改:
48可能变成49,整数可能变成字符串,NULL可能变成整数。 CHECK/FOREIGN KEY/UNIQUE/STRICT表的类型约束在恢复后不保证仍然成立。- 内容可能被错误地归到另一张表里。
官方把这类工作定性为”salvage”(打捞),不是”restore”(还原):能救多少算多少,救回来的东西还需要人工核对和测试,不能直接当作恢复前的原始数据继续用。CLI
里 .recover
支持的几个参数(--ignore-freelist 避免把
freelist
里的旧内容误当作有效数据复活;--lost-and-found TABLE
给找不到归属表的内容一个落脚点;--no-rowids
控制 rowid
提取行为)本质上都是在”抢救更多内容”和”避免引入更多错误数据”之间做取舍,不存在一个默认参数组合能保证结果正确。
备份优先于修复:以上所有边界共同指向一个操作原则——integrity_check
发现问题后,第一反应应该是”停止继续写入、保留现场、检查有没有可用的备份”,而不是直接对生产文件跑
.recover 期待它把数据完整找回来。第 15
篇要讲的在线备份 API
正是用来在损坏发生前建立这个安全网,.recover
只是在没有备份时的最后手段。
五、学术谱系、工程间隙与开放问题
谱系:integrity_check
检查的是应用层的 B-Tree
结构自洽性,和更底层的存储介质校验(文件系统 checksum、RAID
校验)是两个不同层次的防线,彼此不替代。SQLite
官方在这条边界之上又补了一层:The Checksum VFS
Shim(cksumvfs,3.32.0
起随源码提供的可选扩展)在每个页末尾追加 8
字节校验和,专门用来”detect database corruption caused by
random bit-flips in the mass storage device”——这恰好是
integrity_check
结构检查覆盖不到的那类损坏(内容被悄悄改了一个字节,但结构仍然自洽,见第六节误解
1)。三层检查(文件系统级、cksumvfs
页级、integrity_check
结构级)覆盖的问题域互不重叠,不能用其中一层的”通过”去代表另外两层。
工程间隙:文档反复强调”recovery API
的结果始终存疑”,这与很多用户期待的”有损坏恢复工具=数据保险”存在明显落差——本文第二节
.dump 在数据丢失时静默退出 0
的实测,正是这个落差在实际操作中最容易踩到的坑:工具没有义务在”部分内容读不出来”时报错,因为对
.dump/.recover
而言,“跳过读不出来的内容”本身就是设计好的正常行为,不是异常。
开放问题:官方文档没有给出”多久应该跑一次
integrity_check“的通用策略——integrity_check
是 \(O(N \log N)\)
的全表扫描,对于移动端、边缘设备上体积不大的数据库可以频繁跑,但对几十
GB 的嵌入式数据库,全量检查的 I/O 与 CPU
成本、要不要在线跑(跑的时候和其他连接的锁交互如何)都没有官方给出的运维基准,需要应用自己按数据规模和访问模式决定检查节奏,这属于第
17 篇讨论选型运维成本时仍然开放的问题。
六、常见误解
「
integrity_check通过 = 数据绝对没被改动过。」 不对。integrity_check验证的是 B-Tree 结构自洽——页链接、cell 边界、索引与表是否对应,不是逐字节校验内容。一次不破坏结构的字节翻转(比如把某个 TEXT 值中间的一个字符改掉)完全可能在integrity_check眼里”什么都没发生”。想检测这类静默位翻转需要额外加载 cksumvfs(见第五节),integrity_check本身不提供这层保护。「删掉
-wal文件总是安全的,反正主文件才是真正的数据。」 第 8 篇已经说明 WAL 帧本身参与正常读路径,不只是恢复用的日志。官方 How To Corrupt 把-journal和-wal明确并列为同一类风险:热日志文件被移动、删除或改名之后,“automatic recovery will not work and the database may go corrupt”。只要上一次异常退出留下的是热-wal(还没做完 checkpoint),删掉它就等于放弃了那批尚未落盘的已提交事务,具体后果取决于当时的 checkpoint 进度,不能一概而论为”安全”。「
.recover或者.dump能把损坏前的数据完整找回来。」 见第二、四节:本文实测里.recover和.dump都只救回了表结构,三行原始数据一行没有——因为损坏的字节就是数据本身所在的位置,工具没有办法凭空补出已经不存在的信息。官方文档把这类工具定性为”salvage”,明确列出内容丢失、旧数据复活、值被篡改、约束失效等多种退化模式,不是一个可以无脑信任的”一键恢复”。「
quick_check通过就等于integrity_check通过,反正官方说’大部分检查都做了’。」quick_check明确跳过 UNIQUE 约束校验和”索引内容是否与表内容一致”这两项检查,复杂度也因此从 \(O(N \log N)\) 降到 \(O(N)\)。如果损坏恰好发生在索引与表数据的对应关系上(例如索引指向了一个已经不存在的 rowid),quick_check可能不会发现,只有跑一次完整的integrity_check才能确认。
七、小结
integrity_check检查 B-Tree 结构自洽性(页链接、cell 边界、索引与表的对应关系),不检查 FOREIGN KEY,也不是内容级校验和;quick_check跳过 UNIQUE 与索引一致性检查换取 \(O(N)\) 的速度,两者不能互相替代。本机 3.53.2 实测:完好库两个 PRAGMA 都返回ok;对页内 cell 内容做字节级翻转后,两者都报出一致的”Extends off end of page”结构错误。- 官方 How To Corrupt 列出的损坏来源里,绕开
SQLite 直写裸文件、事务中途热拷贝、断电叠加
journal_mode=OFF/synchronous=OFF、NFS 锁语义不可靠,分别对应第 2、7、8、9 篇已经拆过的文件格式与并发机制——SQLite 对”正常路径内的崩溃”有完整自动恢复,对”绕开正常路径”的操作没有任何防御,这是文档原话,不是设计缺陷。 .recover与.dump在数据已经丢失的情况下都会静默跳过、exit code 为 0,不会主动报错——本文的实测复现了这一点:两者都只救回表结构,三行实际数据全部丢失。修复工具的定位是”尽力打捞”,不是”保证还原”;备份永远应该在损坏发生之前建好,这是第 15 篇的主题。
参考资料
规范与官方文档(A 级)
- SQLite Documentation, PRAGMA
Statements,
integrity_check/quick_check章节(sqlite.org/pragma.html)。 - SQLite Documentation, How To Corrupt An SQLite Database File(sqlite.org/howtocorrupt.html)。
- SQLite Documentation, Recovering Data From A Corrupt
SQLite Database(sqlite.org/recovery.html),含
.recoverCLI 用法与 Recovery API。 - SQLite Documentation, The Checksum VFS Shim(sqlite.org/cksumvfs.html)。
实验(A 级,本机实测)
- 本机
sqlite33.53.2:完好库integrity_check/quick_check;对页内 cell 字节翻转后的结构错误报告;.recover与.dump在数据丢失场景下的真实退出码与输出对比。脚本reproduce/14-integrity.sh,输出见第二节。
站内
- Rollback Journal 模式、WAL 与 checkpoint——本文”断电 + 关闭保护性 PRAGMA”一节依赖的日志机制。
- 锁状态与 shared cache——NFS 锁语义不可靠的前序讨论。
- 单文件格式与页面头——本文字节级损坏实验定位 cell 位置所依赖的页面布局。
- 本系列 index、PLAN.md。
上一篇:ATTACH / 多库边界 下一篇:在线备份与 sqlite3_backup
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【SQLite 内核】嵌入式行存全景:单文件、单写者、零 IPC
定位单文件嵌入式行存在服务器行存与 LSM 嵌入 KV 之间的生态位;钉住 SQLite 架构约束、站内分工与 17 篇阅读路线,并以 Bayer/McCreight、官方 file format、PVLDB 2022 为学术锚点。
【SQLite 内核】单文件格式与页面头
拆解 SQLite 单文件格式:100 字节 database header 与 B-Tree 页面头的逐字段布局,用本机 3.53.2 CLI 实测 hexdump 核对 magic、page size、页类型标志,并钉住 file format 官方规范与 Bayer/McCreight 谱系。
【SQLite 内核】Pager 与 Page Cache
拆解 Pager 作为 B-Tree 与操作系统文件之间的契约层:page cache 命中/未命中路径、脏页生命周期与提交前的可回滚保证,并与 PG shared_buffers、InnoDB Buffer Pool 的跨进程共享模型对照。
【SQLite 内核】B-Tree 遍历与分裂:表 B-Tree、索引 B-Tree 与 cell 布局
钉住 SQLite table b-tree 与 index b-tree 的字节布局差异、cell pointer array 如何做到逻辑有序物理可乱序、overflow 阈值与 balance() 分裂/合并触发条件;源码以 btree.c 函数签名为准,学术锚点是 Bayer & McCreight 1972。