第 14
篇钉住了”备份优先于修复”这条原则,但没回答一个更基础的问题:怎么在数据库还在被读写的时候,安全地拿到一份能拿来恢复的备份文件?直觉上最简单的做法是
cp 一下数据库文件,官方 Online Backup
API
文档一开头就点名了这个直觉的问题所在——历史上的做法是”拿共享锁、用外部工具
cp
复制、释放锁”,这个流程在拿锁期间会阻塞所有想写的连接,而且一旦复制过程中出现断电或系统故障,备份本身就可能是坏的。
常见误区是把 cp/rsync
当成”够用”的备份手段,或者以为 WAL
模式下只要拷走主文件就完事。本文钉三件事:
sqlite3_backup_init/step/finish三个 C API 与 CLI.backup命令的对应关系,以及它们和cp在语义上到底差在哪。- 本机 3.53.2 实测:
.backup一个安静的数据库后,备份库count(*)=3、integrity_check=ok;以及一次更有意思的对照实验——在一个打开的写事务期间用cp拷贝主文件,拷到的副本物理体积已经比文件头记录的逻辑页数大。 - WAL 模式下备份要连
-wal一起拷,以及这件事如何与第 8、14 篇已经拆过的机制闭合。
本文是「SQLite 内核」系列第 15 篇(共 17 篇)。→ 系列目录
篇目 核心内容 第 14 篇 · 完整性检查与损坏恢复 integrity_check、常见损坏来源、.recover边界第 15 篇 · 在线备份与 sqlite3_backup Backup API、与 cp的语义差、WAL 备份注意点第 16 篇 · 与 PG / InnoDB 机制对照 进程、WAL、锁、缓冲池对照
版本锚定:官方 SQLite Backup API(sqlite.org/backup.html)、How To Corrupt An SQLite Database File §1.2(sqlite.org/howtocorrupt.html)、Write-Ahead Logging(sqlite.org/wal.html)、C API
sqlite3_backup_init/_step/_finish(sqlite.org/c3ref/backup_finish.html)。实测锚定本机 SQLite 3.53.2。本文不讨论任何云备份产品或托管服务,只讨论 SQLite 自身提供的机制。
一、sqlite3_backup_*:三段式
API 与 CLI .backup
SQLite Backup API 文档把在线备份 API 的效果概括为一句话:完成整套调用后,目标数据库会变成”源数据库在拷贝开始那一刻的逐位(bit-wise)一致副本”——文档管这个结果叫”snapshot”。三个函数各管一段:
sqlite3_backup_init(pDest, "main", pSrc, "main"):创建一个 backup 对象,绑定目标/源两个数据库连接。sqlite3_backup_step(pBackup, nPage):拷贝nPage页;传-1表示一次性拷完整个数据库。sqlite3_backup_finish(pBackup):释放 backup 对象占用的资源。
CLI 的 .backup FILENAME
命令就是对这三步的封装:内部等价于打开
FILENAME、调用
backup_init、backup_step(-1)、backup_finish。官方
Example
2(在线备份一个正在被使用的数据库)展示的是更细粒度的用法:每次只
backup_step(5) 拷 5 页,然后睡眠 250ms
再继续——睡眠期间不持有任何源数据库的锁,也不持有源连接的
mutex,这段时间里其他线程和进程可以正常读写源库。
sequenceDiagram
participant App
participant Backup as sqlite3_backup object
participant Src as Source connection
participant Dst as Destination file
App->>Backup: sqlite3_backup_init(dst, "main", src, "main")
loop until done
App->>Backup: sqlite3_backup_step(N pages)
Backup->>Src: brief read lock, copy N pages
Backup->>Dst: write N pages
Note over App,Src: lock released between steps;<br/>other connections can read/write
end
App->>Backup: sqlite3_backup_finish()
Note over Dst: destination is now a bit-wise<br/>snapshot as of copy start
File and Database Connection Locking
一节补充了一条例外:如果源数据库不是内存数据库,且写入来自同一进程、同一个数据库连接(也就是发起
backup 的那个 pDb),那么 backup
目标会跟着这次写入自动同步更新,备份流程可以在
sqlite3_sleep()
之后接着跑,不需要重启;但如果写入来自外部进程、外部线程用的不同连接、或者源本身是内存数据库,backup
会检测到源已经变化并重启整个拷贝。这意味着如果备份期间源库写入非常频繁,backup_step
可能反复重启、迟迟跑不完——这是用增量
backup_step
换取”不长时间阻塞其他连接”所付出的代价,官方文档原话就直接提到了这个风险:“If
the backup process is restarted frequently enough it may
never run to completion”。
1.1
VACUUM INTO:另一种一次性快照
官方文档在列举”安全的备份手段”时,除了 backup API
还提到了 VACUUM INTO filename——SQLite
Backup API 页面把它归为”Other Backup
Techniques”之一:
The VACUUM INTO command will make a vacuumed copy of a live SQLite database into a separate file.
VACUUM INTO 和
sqlite3_backup_step(-1)
的效果类似(都能在源库正常使用的情况下产生一份安全快照),但多做了一件事——顺带重建页面、清理空闲页、按
sqlite_schema 里的顺序重排表和索引,这正是常规
VACUUM
命令做的事情,只是输出目标换成了一个新文件而不是原地重写。这意味着
VACUUM INTO
更适合”顺便整理碎片”的场景,如果只是单纯要一份原样快照、不想承担重建索引的额外
I/O,backup API
更直接。两者都不支持增量:每次调用都是一次完整的全量拷贝,这一点和第五节要讨论的开放问题是同一个话题。
三种手段的对比收在一张表里:
| 手段 | 是否安全(并发写入下) | 是否需要额外拷贝
-wal/-journal |
顺带整理碎片 | 增量支持 |
|---|---|---|---|---|
cp / 文件级 rsync |
不安全,正确性依赖内部写入时序(第三节实测) | 需要,且容易被遗漏 | 不会 | 不支持 |
sqlite3_backup / .backup |
安全,通过 pager 读取 | 不需要,pager 自动处理 WAL 帧 | 不会 | 不支持,每次全量 |
VACUUM INTO |
安全,等价于一次 VACUUM 输出到新文件 |
不需要 | 会 | 不支持,每次全量 |
二、实测:.backup
一个安静的数据库
环境:本机 sqlite3
3.53.2。复现脚本:reproduce/15-backup.sh。建一张三行的表,用
CLI 的 .backup 命令拷一份:
=== Part 1: .backup a quiescent database ===
backup row count:
3
backup integrity_check:
ok
备份库
count(*)=3,PRAGMA integrity_check
返回
ok——这是”没有并发写入干扰”这个最简单场景下的预期结果,本身不新鲜。真正说明问题的是下一节的对照实验。
三、cp
热拷贝踩在写事务中间:实测一次真实的时序缝隙
第 14 篇引用过官方 How To Corrupt 的这句话:
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.
这句话描述的是最坏情况,但”可能”(might)两个字也说明结果和具体的写入时序有关,不是每次都会看到明显的损坏。用一次真实实验去看这条时序缝隙到底长什么样:让一个连接开一个
BEGIN IMMEDIATE 事务、往表里插 2000
行且不提交,同时把 PRAGMA cache_size 设到
1。这一步依赖的是 cache_spill
这个默认开启的机制——官方文档写明:
The cache_spill pragma enables or disables the ability of the pager to spill dirty cache pages to the database file in the middle of a transaction. Cache_spill is enabled by default… The number of pages in cache must exceed both the cache_spill threshold and the maximum cache size set by the PRAGMA cache_size statement in order for spilling to occur.
把 cache_size 压到 1
页之后,插入第二行开始就会触发溢写,脏页在事务提交前就被
pager 写进主文件——文档还补了一句容易被忽略的副作用:“a cache
spill has the side-effect of acquiring an EXCLUSIVE lock on
the database
file”,也就是说这次实验里的写连接在提交前就已经短暂拿到了第
9 篇锁阶梯里最高的 EXCLUSIVE
状态,只是这一层细节不影响本节要观察的现象。在事务还开着、脏页已经溢写的这个窗口里,直接
cp 主文件:
main file + journal size while transaction is open:
-rw-r--r-- 1 ltl ltl 36864 ... sk15-live.db
-rw-r--r-- 1 ltl ltl 9728 ... sk15-live.db-journal
事务还没提交,主文件已经涨到 36864 字节(9
页)——说明部分脏页确实在提交前就被 pager
溢写到了主文件里。把这一刻的主文件 cp
一份,检查这份”半成品”副本:
naive copy: physical file size vs. header page-count field
-rw-r--r-- 1 ltl ltl 36864 ... sk15-midtx-cp.db
2
naive copy: row count + integrity_check
3
ok
副本的物理文件大小是 36864
字节,但文件头里”数据库逻辑页数”字段(偏移 28,4
字节)读出来仍然是 2——也就是说,这份
cp
拷贝到的文件,物理体积已经比头部承认的逻辑范围大了 7
页,只是这次恰好是”新分配但尚未链接进
B-Tree、也没更新头部页数”的叶子页在提交前先落了地,根/schema
页还没被改写,所以 integrity_check
依然只认头部记录的前 2
页,看到的是一个自洽但过期的快照(3
行,不是插入后的 2003 行)。事务提交之后,源库正确地涨到了
2003 行:
source database after commit: row count
2003
这个结果需要如实说清楚它证明了什么、没证明什么:它没有证明”cp
在事务中间拷是安全的”——它只说明这一次的写入顺序恰好是”先写叶子页、后更新头部页数”,cp
踩中的时间点刚好在这两步之间,所以拿到的是一份滞后但内部自洽的旧快照,而不是文档描述的”新旧内容混杂”的真正损坏。写入顺序完全不受调用者控制:如果
cp
踩中的是头部页数已经更新、但某些叶子页还没来得及写完的窗口,读到的就会是”头部说有
9
页,但其中几页内容还是旧的或半写的”,那才是官方原文说的”old
and new content”混合,integrity_check
大概率会报错。cp
的正确性完全是运气,取决于一个应用层完全看不到、也无法控制的内部写入时序;而
backup API 因为是通过 pager
走正常的读路径拷贝,天然不会遇到这个问题——它拷贝到的每一页都是
pager
认为”当前有效”的页,不会出现”物理存在但逻辑上不属于当前页数”的中间态。
flowchart LR
subgraph naive ["Naive cp"]
c1["Open fd on main file"] --> c2["Copy raw bytes"]
c2 --> c3["Result depends on writer's<br/>internal flush order<br/>(lucky: stale-but-consistent;<br/>unlucky: torn/corrupt)"]
end
subgraph api ["sqlite3_backup API"]
b1["backup_step reads through pager"] --> b2["Copies pages pager considers valid"]
b2 --> b3["Result: consistent snapshot<br/>as of copy start, always"]
end
四、WAL 模式下:不能只拷主文件
第 8 篇已经拆过 WAL 的读路径——读者按”end mark”在主文件和
-wal 之间挑最新版本的页,最近提交但还没
checkpoint 的改动只存在于 -wal
里,主文件本身在这些改动被 checkpoint
搬回去之前完全不变。这条机制直接决定了 WAL 模式下
cp 式热拷贝的一个额外风险:只拷主文件、不管
-wal,拷到的是一个”看起来完整”但丢失了所有未
checkpoint 提交的旧版本。官方 How To
Corrupt §1.4 把这类问题归纳为一句通用原则:
If the previous write transaction failed, then it is important that any rollback journal (the -journal file) or write-ahead log (the -wal file) be copied together with the database file itself.
也就是说,-wal 和第 7 篇的
-journal
在这条规则里是同一类东西:主文件单独拷贝没有意义,必须连同当前存在的日志文件一起拷,否则恢复时用的是”主文件
+ 别的日志”或者”主文件 +
没有日志”,两种情况都不能保证正确。sqlite3_backup
API 不受这个限制,因为它是通过源连接的 pager
读数据(会正确地在主文件和 -wal
之间拼出当前有效内容),不是对文件系统层面的原始字节做拷贝。
WAL 模式还有一条容易被忽略的具体限制,Write-Ahead Logging 文档写得很明确:
It is not possible to change the page_size after entering WAL mode, either on an empty database or by using VACUUM or by restoring from a backup using the backup API. You must be in a rollback journal mode to change the page size.
如果目标库处于 WAL 模式,用 backup API
往里恢复数据时不能顺手改
page_size——想改页大小必须先把目标切回
rollback journal 模式。这是一条具体到”某个 PRAGMA
在某种日志模式下是否生效”的边界,不是一般性的备份注意事项,但一旦踩到会让”备份恢复流程”卡在一个不直观的报错上。
五、与第 8、14 篇闭合,工程间隙与开放问题
闭合:第 8 篇讲过 WAL 的 checkpoint
需要”追不上正在进行的读事务的 end mark
就必须停下”;备份本质上也是一种长时间的只读事务,如果备份进行期间有人在跑
checkpoint,两者遵守的是同一套 end mark 规则——checkpoint
不会把备份正在读的内容抽走。第 14
篇讲的”备份优先于修复”到这里补上了具体做法:安全的备份手段是
sqlite3_backup/.backup/VACUUM INTO,不是文件级
cp;integrity_check
应该在备份完成后跑一次,确认拿到的确实是一份结构自洽的快照,而不是默认信任”命令没报错就等于备份正确”。
工程间隙:backup API
每次调用都是一次全量拷贝,没有增量/差量备份的概念——sqlite3_backup_step(-1)
会把源数据库从头拷到尾,即使上一次备份和这一次之间只改了几行。对于体积不大的嵌入式数据库这不是问题,但数据库越大,每次全量备份的
I/O 和时间成本越高。较新的官方工具
sqlite3_rsync(3.47.0,2024-10-21
起提供)针对的是”通过 SSH 远程同步”这个场景,用类似 rsync
的协议减少网络传输的数据量,但它解决的是传输效率问题,不是本地增量备份问题,也不是一个可以像
backup API 一样嵌入应用内部调用的 C 接口。
开放问题:SQLite 目前没有官方的”基于 WAL
帧的增量备份”机制(对照服务器数据库常见的”基础备份 + WAL
归档回放”模式)——想要频繁备份一个较大的嵌入式数据库,只能反复付出全量拷贝的代价,或者自己在应用层设计增量方案(例如基于
sqlite3_changeset 之类的变更追踪
API)。这是否值得官方投入做一个增量备份
API,文档和源码里都没有路线图,留给第 17
篇讨论选型运维成本时继续展开。
六、常见误解
「
cp拷贝和sqlite3_backup效果一样,只是cp更快更省事。」 不对,见第三节:cp拷到的内容完全依赖 SQLite 内部脏页刷盘的时序,这个时序调用者看不到也控制不了;本文的实测里侥幸拿到了一份”过期但自洽”的快照,但换一个时间点完全可能拿到”头部与叶子页不匹配”的真正损坏。sqlite3_backup通过 pager 读取,拷贝到的永远是 pager 认可的有效页,不存在这类运气成分。「WAL 模式下只需要拷主文件,
-wal可以不管,反正 checkpoint 迟早会把它清空。」 不对。第 8 篇已经说明 WAL 帧本身是正常读路径的一部分,最近提交但未 checkpoint 的数据只存在于-wal。官方 How To Corrupt 把-journal和-wal并列,明确要求”必须和主文件一起拷贝”,只拷主文件相当于主动丢弃所有还没 checkpoint 的已提交事务。「在线备份 API 会长时间锁住源数据库,其他连接的读写都要等备份跑完。」 不对。第一节引用的官方 Example 2 展示的是分批
backup_step+ 睡眠的模式:锁只在实际拷贝那几页的短暂窗口内持有,睡眠期间完全释放,其他连接可以正常工作。只有像 CLI.backup这种一次性backup_step(-1)的简化用法,才会在整个拷贝期间持有读锁——这是调用方式的选择,不是 API 本身的限制。「备份到 WAL 模式的目标库之后,可以顺手用 backup API 把页大小改成自己想要的值。」 不对,见第四节引用的 Write-Ahead Logging 原文:WAL 模式下无法通过 backup API 恢复时顺带修改
page_size,必须先把目标切回 rollback journal 模式才能改页大小。
七、小结
sqlite3_backup_init/step/finish(CLI 对应.backup)通过源数据库的 pager 拷贝页面,可以分批进行、间隔释放锁,最终得到”拷贝开始那一刻”的逐位一致快照;本机 3.53.2 实测:.backup一个安静数据库后,备份库count(*)=3、integrity_check=ok。cp之类的文件级热拷贝没有这层保证——本文的实测显示,在一个打开的写事务期间cp主文件,拿到的副本物理体积已经比文件头记录的逻辑页数大,这一次恰好落在”叶子页已溢写、头部尚未更新”的窗口里,读出来是自洽但过期的旧快照;如果时序换一个位置,结果就会是官方文档描述的”新旧内容混杂”的真正损坏。cp的正确性完全依赖调用者无法观察和控制的内部写入顺序。- WAL 模式下备份必须把
-wal和主文件一起拷,只拷主文件会丢失未 checkpoint 的已提交事务;backup API 因为走正常读路径不受这个限制。备份完成后应该跑一次integrity_check确认结果,与第 14 篇”备份优先于修复”的原则闭合;增量备份目前没有官方 API,是留给第 17 篇讨论的开放运维成本问题。
参考资料
规范与官方文档(A 级)
- SQLite Documentation, SQLite Backup API(sqlite.org/backup.html),含在线备份两个示例与锁语义说明。
- SQLite C API,
sqlite3_backup_init()/sqlite3_backup_step()/sqlite3_backup_finish()(sqlite.org/c3ref/backup_finish.html)。 - SQLite Documentation, How To Corrupt An SQLite Database File §1.2、§1.4(sqlite.org/howtocorrupt.html)。
- SQLite Documentation, Write-Ahead Logging(sqlite.org/wal.html),页大小限制条款。
- SQLite Documentation, PRAGMA
Statements,
cache_size/cache_spill章节(sqlite.org/pragma.html)。
实验(A 级,本机实测)
- 本机
sqlite33.53.2:.backup一个安静数据库的行数与integrity_check;在打开的写事务期间cp主文件,比较物理文件大小与头部逻辑页数字段。脚本reproduce/15-backup.sh,输出见第二、三节。
站内
- 完整性检查与损坏恢复——备份与修复的分工边界。
- WAL 与 checkpoint、Rollback Journal 模式——本文 WAL 备份注意点依赖的日志机制。
- 本系列 index、PLAN.md。
上一篇:完整性检查与损坏恢复 下一篇:与 PG / InnoDB 机制对照
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【SQLite 内核】WAL 与 checkpoint:追加日志与读写并发
钉住 SQLite WAL 模式如何反转 rollback journal 的读写关系:原始内容留在主文件、新内容追加到 -wal,checkpoint 才把帧搬回主文件;用本机 3.53.2 实测 journal_mode=WAL 切换与 passive/truncate checkpoint 的真实帧数,读放大与 checkpoint 频率的取舍留作开放问题。
【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 内核】ATTACH / 多库边界:跨库事务的原子性在 WAL 下会退化
钉住 ATTACH DATABASE 的命名空间规则、super-journal 如何让跨文件事务在 rollback journal 模式下保持原子,以及官方文档明确写明的一条反常识边界:main 库或任一 attached 库切到 WAL 后,跨库事务只在单文件粒度原子,崩溃中间态可能只改了一部分文件;本机 3.53.2 实测跨库 JOIN、跨库事务提交与 DETACH 锁冲突。