数据库基础体系 · 第 116/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
SQLite Backup API 与在线复制:快照、一致性、锁和恢复验证
一、先区分四个容易混淆的概念
在 SQLite 中,“复制数据库文件”“在线备份”“快照”和“在线复制”不是同一件事。
1. 文件复制
例如:
cp app.db backup.db
这只是操作系统层面的字节复制。它没有理解 SQLite 的页面、事务日志和锁状态。
当数据库使用 WAL 模式时,已经提交但尚未合并回主数据库文件的数据可能位于:
app.db-wal
app.db-shm
只复制 app.db,可能得到一个缺少已提交事务的旧状态。更糟糕的是,如果复制恰好发生在回滚日志或 WAL 状态变化的中间阶段,目标文件可能无法作为一致的 SQLite 数据库打开。
2. 在线备份
在线备份是指源数据库仍然被应用读写时,备份操作仍可进行。SQLite 的 Backup API 就是为此设计的。
它不是简单复制文件,而是:
- 通过 SQLite 连接打开源数据库和目标数据库;
- 按数据库页读取源库;
- 在 SQLite 的锁和事务规则下,将页面写入目标库;
- 使目标库成为一个内部一致的 SQLite 数据库。
3. 快照
快照是某个逻辑时刻的数据库状态。
如果源库在备份过程中继续变化,Backup API 的目标是生成一个一致的数据库副本,而不是让目标库出现“前半部分是旧状态、后半部分是新状态”的混合结构。
但这里需要严格区分两个保证:
- **一致性保证:**目标文件可以作为一个完整的 SQLite 数据库使用,内部事务状态不被破坏;
- **时间点保证:**目标文件是否严格对应某个指定时间、某个事务号或某个业务事件。
Backup API 提供前者,但它不是带日志位点的时间点复制协议,也不能直接给出类似数据库服务器中 LSN、SCN 的恢复位置。
4. 在线复制
在线复制通常意味着源库的变化持续传递到一个或多个目标库,例如:
源库提交事务
↓
复制日志或变更流
↓
目标库持续应用
SQLite 官方 Backup API 主要提供的是一次性或周期性的在线副本生成能力,不是持续流式复制协议。它可以用来实现:
每隔一段时间生成一个新的副本
但它本身不会:
- 自动持续监听每个提交;
- 提供复制槽或日志位点;
- 让目标库始终追随源库;
- 解决多主写入冲突;
- 自动提供增量日志传输。
因此,使用 Backup API 实现的是“在线备份”或“周期性副本”,而不是完整意义上的主从复制。
二、SQLite 文件为什么不能随意复制
SQLite 数据库通常由一个主数据库文件和可能存在的事务相关文件组成。
app.db
app.db-wal
app.db-shm
在传统 rollback journal 模式下,还可能出现:
app.db-journal
1. 数据库文件由页面组成
SQLite 的存储单位不是任意长度的记录,而是固定大小的数据库页。常见页大小为 4096 字节,但实际页大小由数据库文件决定,也可能是其他合法值。
逻辑上的表和索引由 B-tree 页面组成。事务修改数据时,SQLite 需要同时维护:
- 页面内容;
- 页面之间的引用关系;
- 数据库头部信息;
- 回滚日志或 WAL;
- 提交边界。
如果操作系统复制只完成了一部分页面,目标文件就可能包含不互相匹配的页面集合。
例如,一个事务需要同时修改页面 10 和页面 20:
事务开始:
页面 10:旧版本
页面 20:旧版本
事务写入中:
页面 10:新版本
页面 20:新版本
文件复制恰好发生在中间:
目标页面 10:新版本
目标页面 20:旧版本
这两个页面可能不构成一个合法的事务状态。SQLite 的事务日志机制可以防止正常数据库连接看到这种半提交状态,但操作系统级的裸复制并没有自动获得这种保护。
2. WAL 模式下,主文件不是全部数据库状态
WAL 模式的基本写入路径是:
读数据库页
↓
把修改后的页追加到 app.db-wal
↓
提交标记写入 WAL
↓
读事务根据 WAL 读取新页
在 checkpoint 之前,主数据库文件可能仍是旧版本,而 WAL 中已经包含已提交的新版本。
因此,下面的复制是不完整的:
cp app.db backup.db
即使 app.db 本身能够打开,也可能缺少 WAL 中的已提交事务。
把三个文件同时复制也不能简单解决问题,因为:
- 三个文件的复制不是一个原子操作;
- 复制时源库可能继续写入;
-shm是 WAL 的共享内存索引,不应被当成独立的持久化数据库内容;- 目标文件和目标 WAL 必须属于同一个一致的复制时刻。
结论不是“WAL 模式不能备份”,而是:应让 SQLite 负责读取和复制,而不是绕过 SQLite 直接复制文件。
三、Backup API 的对象和生命周期
SQLite C API 的核心接口是:
sqlite3_backup *sqlite3_backup_init(
sqlite3 *pDest,
const char *zDestName,
sqlite3 *pSource,
const char *zSourceName
);
int sqlite3_backup_step(sqlite3_backup *p, int nPage);
int sqlite3_backup_finish(sqlite3_backup *p);
int sqlite3_backup_remaining(sqlite3_backup *p);
int sqlite3_backup_pagecount(sqlite3_backup *p);
1. 源连接和目标连接
调用者需要分别创建:
- 源数据库连接
pSource; - 目标数据库连接
pDest。
例如:
pSource → app.db
pDest → backup.db
sqlite3_backup_init() 的参数顺序容易出错:
sqlite3_backup_init(
pDest, // 目标连接
"main", // 目标数据库名
pSource, // 源连接
"main" // 源数据库名
);
"main" 表示主数据库。如果使用 ATTACH DATABASE,也可以指定附加数据库名称。
源库和目标库不能是同一个数据库。Backup API 用来把一个 SQLite 数据库复制到另一个数据库连接,不能用来在同一文件上原地复制。
2. sqlite3_backup_step()
sqlite3_backup_step(p, nPage) 每次最多复制 nPage 个页面。
常见调用方式是分块复制:
while ((rc = sqlite3_backup_step(backup, 100)) == SQLITE_OK) {
/* 可以在这里让出线程、记录进度或处理取消请求 */
}
当复制完成时,返回:
SQLITE_DONE
nPage 不是“事务大小”,而是本次调用最多处理的数据库页数。将其设置为较小值,可以减少单次持锁时间,也方便:
- 更新进度;
- 处理取消;
- 配合忙等待;
- 避免长时间阻塞应用线程。
但分块并不改变备份的逻辑一致性。它只是改变执行过程和锁持有的粒度。
3. 进度计算
可以使用:
sqlite3_backup_remaining(backup)
sqlite3_backup_pagecount(backup)
计算近似进度:
其中:
remaining是尚未复制的页面数;pagecount是本次备份视图中的总页面数。
如果总页数为 1000,剩余 250 页,则:
这个进度用于显示和监控,不应被当成源库“复制到某个事务位点”的证明。它只表示页面复制进展。
四、Backup API 如何保证一致性
1. 目标不是拼接任意页面
设源数据库在某个时间的逻辑状态为:
其中 表示第 个数据库页在时间 的内容。
错误的裸文件复制可能得到:
且这些页面之间没有对应同一个已提交事务状态。
Backup API 的目标是使最终目标库 对应源库的某个一致状态:
这里的 表示某个逻辑一致点,而不是要求所有页面都在同一纳秒被操作系统读取。
更准确地说,Backup API 使用 SQLite 的 pager、锁和事务机制来读取源库,而不是把源文件当成普通字节数组。
2. 源库在备份过程中发生变化
在线备份的关键场景是:
T1:Backup API 开始
T2:备份复制一部分页面
T3:应用提交事务
T4:备份继续复制
T5:Backup API 完成
SQLite 会通过内部备份逻辑处理源库的变化,避免目标库因页面版本不匹配而变成损坏的数据库。
但是,源库持续变化会带来两个现实问题:
- 备份可能需要重新处理已经受影响的页面;
- 如果写入过于频繁,备份可能长时间处于忙碌状态,甚至难以完成。
因此,“在线”不等于“无成本”。在线备份仍然消耗源库读资源、目标库写资源和 I/O 带宽。
3. 一致性不等于业务静态
Backup API 保证的是数据库层面的一致性。例如:
- B-tree 页面引用关系合法;
- SQLite 能够打开数据库;
- 已提交事务不会被拆成半提交结构;
- 未提交事务不会以提交状态出现在副本中。
但它不保证应用层面的业务动作已经完整完成。例如,应用可能分两次提交:
事务 1:创建订单
事务 2:写入异步支付状态
备份副本可能只包含事务 1。这个副本在 SQLite 意义上完全一致,但在业务意义上可能表示“订单已创建、支付状态尚未写入”。
如果业务需要在某个明确边界备份,应该在应用层定义边界,例如:
- 暂停某类业务写入;
- 提交一个表示业务批次完成的标记;
- 记录备份开始前后的数据库版本;
- 之后再通过 Backup API 生成副本。
Backup API 不会自动理解业务事务边界。
五、锁、WAL 和并发行为
理解锁行为,必须先区分源库和目标库。
1. 源库上的锁
备份需要读取源数据库。它不能在源库完全没有任何读保护的情况下复制页面,否则源库可能在读取期间随意改变。
在 rollback journal 模式下,源库的读事务可能影响写事务的提交过程:
备份读取源库
↓
持有源库读锁
↓
写事务可以开始,但提交可能需要等待
这并不意味着备份期间所有写入都会立即失败,但长时间的读取可能让写事务在某个阶段遇到:
SQLITE_BUSY
在 WAL 模式下,读者和写者通常具有更好的并发性:
读事务读取旧的 WAL 快照
写事务继续追加新的 WAL 内容
但 WAL 也有两个相关影响:
- 备份使用的读快照可能阻碍 checkpoint 回收旧 WAL 页面;
- 长时间备份可能导致 WAL 文件增长。
因此,在 WAL 模式下,在线备份可能不明显阻塞写入,却可能增加 WAL 保留压力。
2. 目标库上的锁
目标库不是只读输出流。Backup API 需要向目标数据库写入页面,因此目标连接通常需要目标数据库的写事务或排他性访问。
如果目标文件同时被其他连接使用,可能遇到:
SQLITE_BUSY
SQLITE_LOCKED
典型原因包括:
- 目标数据库正在被另一个连接写入;
- 目标数据库有未完成的读事务;
- 目标文件被其他进程占用;
- 目标数据库处于不允许写入的状态。
生产系统通常会将备份目标写到一个专用临时文件,而不是直接覆盖正在被应用读取的目标副本。
3. SQLITE_BUSY 与 SQLITE_LOCKED 不是一回事
粗略区分如下:
SQLITE_BUSY:当前资源被其他连接或进程占用,等待后可能成功;SQLITE_LOCKED:通常表示同一进程内存在冲突的连接或未完成操作,简单延时未必能解决;SQLITE_IOERR:底层文件系统 I/O 失败;SQLITE_READONLY:目标或源库以不允许当前操作的方式打开;SQLITE_NOMEM:内存分配失败;SQLITE_DONE:备份完成。
不能把所有错误都转换成“睡眠后重试”。例如,目标路径没有写权限时,反复重试不会解决问题。
六、一个可运行的 Python 在线备份示例
Python 的 sqlite3.Connection.backup() 是对 SQLite Backup API 的封装。下面的示例使用源库只读连接,并将目标写入一个临时文件。
#!/usr/bin/env python3
import os
import sqlite3
import sys
import time
source_path = "app.db"
temp_path = "backup.db.tmp"
backup_path = "backup.db"
if not os.path.exists(source_path):
raise SystemExit(f"source database does not exist: {source_path}")
# URI 的 mode=ro 使该连接不会意外修改源库。
source_uri = f"file:{os.path.abspath(source_path)}?mode=ro"
src = sqlite3.connect(
source_uri,
uri=True,
timeout=5.0,
)
# 新建目标文件。目标连接由本程序独占使用。
dst = sqlite3.connect(
temp_path,
timeout=5.0,
)
# 对一个新建目标库,使用普通回滚日志模式更容易把最终产物管理成单文件。
# 如果目标库已经存在且被其他连接使用,切换模式可能失败。
dst.execute("PRAGMA journal_mode=DELETE")
dst.execute("PRAGMA synchronous=FULL")
def progress(status, remaining, total):
if total:
copied = total - remaining
percent = copied * 100.0 / total
print(
f"\rbackup: {percent:6.2f}% "
f"({copied}/{total} pages)",
end="",
flush=True,
)
try:
src.backup(
dst,
pages=100, # 每次最多复制 100 页
progress=progress,
sleep=0.05, # 遇到忙状态时,封装层等待后继续
)
print()
# backup() 返回时,SQLite 层面的页面复制已经完成。
# 关闭前显式提交/关闭目标连接,确保目标库完成正常收尾。
dst.commit()
finally:
src.close()
dst.close()
# 在目标连接已经关闭后再替换最终文件。
# 同一文件系统内 os.replace 通常提供原子路径替换语义。
os.replace(temp_path, backup_path)
print(f"backup created: {backup_path}")
这个示例每一步为什么成立
第一步:源库以只读方式打开
source_uri = "file:...?...mode=ro"
这不是一致性的核心保证。真正的核心仍然是 Backup API,但只读连接可以避免备份程序误写源库,也能更清晰地表达职责。
如果源库不存在、权限不足或 URI 写法错误,连接阶段就会失败。
第二步:先写临时文件
backup.db.tmp
不要直接把备份写入正在被其他程序读取的 backup.db。否则读取者可能观察到一个尚未完成的目标文件。
正确的数据流是:
app.db
↓ Backup API
backup.db.tmp
↓ 验证
backup.db
第三步:分批复制
pages=100
每次复制 100 页,使进度可观测,也减少一次调用可能占用的资源。
页数不是固定性能答案。页大小、数据库大小、存储设备、写入竞争都会影响结果。应通过实际负载测试选择,而不是套用某个固定数字。
第四步:等待忙状态
sleep=0.05
这只能处理暂时性的竞争。若源库持续高频写入,或者目标库一直被其他连接持有,备份仍可能失败或耗时很长。
第五步:关闭目标连接后再替换
目标连接关闭后,SQLite 有机会完成日志收尾。随后用 os.replace() 替换最终路径,可以避免其他读取者看到“正在生成中的文件”。
但是,原子路径替换不等于所有部署都自动安全:
- Windows 上可能有打开文件的进程阻止替换;
- 目标文件所在目录必须允许重命名;
- 应用必须重新打开新路径,而不是继续使用旧连接;
- 如果文件还有
-wal或-shm,必须按 WAL 规则一起考虑,不能只替换主文件。
七、C API 的完整生命周期和错误处理
下面是核心调用结构。它展示 API 生命周期,不包含路径解析等业务代码。
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
static int run_backup(sqlite3 *src_db, sqlite3 *dst_db) {
sqlite3_backup *backup = NULL;
int rc;
backup = sqlite3_backup_init(
dst_db, "main",
src_db, "main"
);
if (backup == NULL) {
rc = sqlite3_errcode(dst_db);
fprintf(stderr, "backup_init failed: %s\n",
sqlite3_errmsg(dst_db));
return rc;
}
for (;;) {
rc = sqlite3_backup_step(backup, 100);
if (rc == SQLITE_DONE) {
break;
}
if (rc == SQLITE_OK) {
int remaining = sqlite3_backup_remaining(backup);
int total = sqlite3_backup_pagecount(backup);
if (total > 0) {
printf("progress: %d/%d pages\n",
total - remaining, total);
}
/*
* 这里可以:
* 1. 让出线程;
* 2. 检查取消标记;
* 3. 记录监控信息。
*/
continue;
}
if (rc == SQLITE_BUSY || rc == SQLITE_LOCKED) {
/*
* 实际程序应使用有上限的退避策略。
* 不能无限循环,也不能把所有 LOCKED 都当作可恢复。
*/
sqlite3_sleep(50);
continue;
}
fprintf(stderr, "backup_step failed: %s\n",
sqlite3_errmsg(dst_db));
/*
* 即使 step 出错,也必须调用 finish。
* finish 的返回值可能包含更准确的最终错误。
*/
break;
}
{
int finish_rc = sqlite3_backup_finish(backup);
if (rc == SQLITE_DONE) {
rc = finish_rc;
}
}
return rc;
}
sqlite3_backup_finish() 必须调用
无论 sqlite3_backup_step() 是成功、失败还是被取消,都需要调用:
sqlite3_backup_finish(backup);
原因包括:
- 释放 Backup API 的内部对象;
- 释放相关锁;
- 返回最终错误状态;
- 完成资源清理。
忽略 finish() 会造成资源泄漏,也可能使连接继续处于不应有的锁定状态。
忙等待必须有边界
一个可用的重试策略至少应包含:
重试次数上限
总等待时间上限
取消标记
错误分类
日志记录
例如:
第 1 次等待 10 ms
第 2 次等待 20 ms
第 3 次等待 40 ms
最多等待 5 秒
但指数退避不是 SQLite Backup API 的规范要求,而是工程策略。真正重要的是:SQLITE_BUSY 可能暂时恢复,SQLITE_READONLY、SQLITE_IOERR 等错误通常不应盲目重试。
八、备份目标的原子发布
“备份完成”与“备份文件可以安全交付给恢复程序”之间还隔着一个发布步骤。
推荐的数据流如下:
源库 app.db
│
│ sqlite3_backup / Connection.backup
▼
临时文件 backup.db.tmp
│
│ 完整性检查、重新打开、业务校验
▼
关闭连接、完成持久化收尾
│
│ 原子替换
▼
正式文件 backup.db
为什么不能直接覆盖正式备份
假设备份目标就是:
backup.db
而另一个进程正在读取它:
读取 backup.db
备份程序同时改写 backup.db
读取者可能得到:
- 旧页面和新页面混合的逻辑结果;
- 目标库锁冲突;
- 读取失败;
- 读取到一个尚未完成的备份。
即使 SQLite 能保护单个连接的读写,也不应让多个角色直接共享一个“正在被重新生成”的备份目标。
临时文件本身也要处理异常
备份进程崩溃后,可能留下:
backup.db.tmp
它不能自动被当成正式备份。启动清理程序时,应根据文件名、修改时间和校验结果决定是否删除,而不是扫描目录后随意删除所有类似文件。
九、恢复验证:复制成功不等于恢复成功
恢复验证至少有三个层次。
1. SQLite 结构完整性
可以使用 SQLite CLI:
sqlite3 backup.db "PRAGMA quick_check;"
正常情况下输出:
ok
quick_check 比 integrity_check 更快,适合频繁备份后的快速检查。
更彻底的检查是:
sqlite3 backup.db "PRAGMA integrity_check;"
正常情况下同样应输出:
ok
如果发现问题,可能看到类似:
*** in database main ***
Page 123 is never used
row 5 missing from index ...
这些输出表示数据库结构或索引关系可能存在问题。
需要注意:
quick_check和integrity_check是数据库结构检查;- 它们不是业务数据校验;
- 输出
ok不能证明备份文件已经被复制到异地介质; - 也不能证明应用一定能完成启动和恢复流程。
2. 打开和读取验证
恢复测试应在一个独立目录完成:
rm -rf restore-test
mkdir restore-test
cp backup.db restore-test/app.db
sqlite3 restore-test/app.db <<'SQL'
PRAGMA quick_check;
SELECT name
FROM sqlite_master
WHERE type IN ('table', 'index')
ORDER BY name;
SELECT COUNT(*) FROM orders;
SQL
验证的重点是:
- 能否重新打开;
- 是否能读取 schema;
- 关键表是否存在;
- 关键索引是否存在;
- 关键表的记录数是否处于合理范围。
如果备份目标使用 WAL 模式并且关闭时仍留下 backup.db-wal,则不能只复制主文件进行测试。应先让目标库正常关闭并确认日志处理完成,或者把主文件和必要的日志文件作为一个整体处理。为了简化可搬运的单文件备份,备份目标通常使用专用连接和合适的 journal 模式,并在关闭后再验证。
3. 业务不变量验证
假设应用有以下业务不变量:
orders.id 唯一
order_items.order_id 必须引用 orders.id
订单金额不能为负数
每个已完成订单必须有对应的支付记录
可以编写专门的 SQL 检查:
PRAGMA foreign_key_check;
SELECT COUNT(*) AS invalid_amounts
FROM orders
WHERE amount < 0;
SELECT COUNT(*) AS completed_without_payment
FROM orders AS o
WHERE o.status = 'completed'
AND NOT EXISTS (
SELECT 1
FROM payments AS p
WHERE p.order_id = o.id
);
这些检查的结果不应只打印到终端,还应纳入备份任务的成功条件。例如:
结构检查失败 → 备份失败
关键表不存在 → 备份失败
业务不变量违反 → 标记为不可恢复备份
4. 真正执行一次恢复演练
最有价值的验证不是只在备份源上执行检查,而是:
生成备份
↓
复制到独立恢复目录或测试主机
↓
使用真实应用启动
↓
执行只读启动检查
↓
执行关键查询和版本迁移检查
↓
记录恢复耗时
恢复时间目标可以形式化为:
如果从未做过恢复演练,就无法仅凭“备份文件存在”推导出实际 RTO。
十、PRAGMA integrity_check 不能发现所有问题
数据库完整性检查非常重要,但它的覆盖范围有边界。
它通常可以发现:
- 页面结构异常;
- B-tree 指针不一致;
- 索引与表之间的结构问题;
- 未使用页面或页面引用异常。
它不能自动证明:
- 所有业务记录都存在;
- 备份时间符合业务要求;
- 文件没有被错误地放到了错误租户目录;
- 应用版本能够读取该 schema;
- 外部对象存储中的备份内容没有被截断;
- 密钥仍然可用;
- 备份文件没有被替换成另一份合法但错误的数据库。
因此,生产备份验证应同时考虑:
SQLite 结构
+ schema 版本
+ 关键数据统计
+ 业务不变量
+ 文件大小与校验和
+ 实际恢复启动
校验和的作用是验证传输和存储完整性。例如:
sha256sum backup.db > backup.db.sha256
sha256sum -c backup.db.sha256
但校验和只能说明“当前文件与生成时的字节一致”,不能说明源文件本身业务正确。
十一、WAL 模式下的 checkpoint 与备份
1. 备份不要求先 checkpoint
使用 Backup API 时,不需要为了备份而强制执行:
PRAGMA wal_checkpoint(TRUNCATE);
SQLite 会通过自己的数据库访问机制处理主数据库和 WAL 中的可见状态。
强制 checkpoint 可能:
- 增加 I/O;
- 等待长期读事务;
- 与高并发写入竞争;
- 让备份任务反而更慢。
如果只是为了生成一致副本,不能把“先 checkpoint”当成 Backup API 的前置条件。
2. 长读事务会影响 checkpoint
WAL 中的读者通常读取某个快照。只要某个读事务仍然需要旧的 WAL 页面,checkpoint 就不能任意回收这些页面。
在线备份如果持续时间较长,可能形成如下关系:
Backup API 保持读快照
↓
旧 WAL 页面仍可能被需要
↓
checkpoint 无法完全回收
↓
app.db-wal 增长
诊断时可以检查:
PRAGMA journal_mode;
PRAGMA wal_checkpoint(PASSIVE);
CLI 中还可以观察文件系统上的 -wal 文件大小变化。
不过,wal_checkpoint 的返回值和行为要结合当前读者、写者以及 SQLite 版本语义解释,不能看到 WAL 文件存在就直接认定数据库损坏。
3. 不要用 checkpoint 代替备份协议
checkpoint 的作用是把 WAL 中的页面合并或复制回主数据库文件,它不是:
- 一个备份;
- 一个原子文件快照;
- 一个恢复点标记;
- 一个持续复制机制。
即使 checkpoint 成功,之后直接用 cp 复制主文件,仍然可能受到新的并发写入、文件系统缓存和复制时序影响。
十二、Backup API 与 VACUUM INTO 的区别
SQLite 还提供:
VACUUM INTO 'backup.db';
它可以生成一个一致的数据库副本,并且能够整理页面、回收空间、重建数据库文件。
但它和 Backup API 的用途不同。
Backup API 更适合
- 在线备份;
- 按页分批执行;
- 显示进度;
- 在应用中控制忙等待;
- 生成周期性副本;
- 复制到一个已经通过 SQLite 连接管理的目标库。
VACUUM INTO 更适合
- 生成压缩或整理后的新数据库文件;
- 在迁移时重新构建数据库;
- 希望目标文件不保留源库的碎片布局。
VACUUM INTO 通常是一次性操作,不提供 Backup API 那样的逐步 step() 控制。它也不是增量复制协议。
选择时应考虑目标:
只想可靠复制当前数据库状态 → Backup API
想同时重建并压缩数据库文件 → VACUUM INTO
想实时复制每个事务 → 需要另行设计变更传输机制
十三、周期性在线复制的真实语义
可以用 Backup API 实现如下任务:
每 10 分钟:
生成一个临时副本
完成结构和业务验证
发布为 backup-时间戳.db
删除过期副本
这提供的是一种离散备份序列:
如果备份间隔是 10 分钟,故障发生在两次备份之间,那么理论上最多可能丢失接近 10 分钟的已提交数据。这个恢复点目标可粗略表示为:
其中:
RPO是可接受的数据丢失时间范围;备份间隔是两次备份启动之间的时间;任务延迟包括排队、复制、验证和上传时间。
这不是 Backup API 自动提供的数学保证,而是由调度策略和任务成功率共同决定的运营指标。
如果目标是更小的 RPO,需要额外设计:
- 更高频的备份;
- 应用级变更日志;
- 事件表或审计表;
- 事务级日志传输;
- 上层同步协议。
但是,应用级变更日志必须与业务事务在同一个 SQLite 事务中写入,否则可能出现:
业务表已提交,变更日志未提交
或反过来:
变更日志已提交,业务表未提交
这会使后续复制流无法可靠重放。
十四、典型失败场景与诊断路径
场景一:直接复制 WAL 模式主文件
现象:
备份文件能打开,但缺少刚提交的数据
诊断:
sqlite3 app.db "PRAGMA journal_mode;"
ls -l app.db app.db-wal app.db-shm
如果源库为 WAL 模式并且存在较大的 app.db-wal,直接复制主文件就是高风险操作。
修复:
- 使用 Backup API;
- 或在明确控制并发、日志和关闭生命周期的条件下使用 SQLite 提供的其他备份方式;
- 不要把
cp app.db当成通用在线备份方案。
场景二:目标库返回 SQLITE_BUSY
可能原因:
- 目标文件被其他程序读取或写入;
- 目标目录或文件权限不足;
- 备份直接覆盖了应用正在使用的目标;
- 源库存在长事务或写入竞争。
诊断:
lsof backup.db
fuser backup.db
Linux 工具是否安装取决于系统环境。Windows 上应使用对应的文件占用诊断工具。
修复思路:
- 使用独占的临时目标;
- 给 Backup API 设置有上限的等待;
- 记录源库和目标库的错误消息;
- 区分暂时锁冲突和永久 I/O 错误;
- 避免在业务线程中无限重试。
场景三:备份长期不完成
可能原因:
- 源库写入非常频繁;
- 源库存在长期读写事务;
- 目标存储很慢;
- 备份线程被调度器长期延迟;
- WAL 旧页面受读快照影响无法回收。
诊断内容应包括:
备份开始时间
已复制页数和总页数
每次 step 返回码
BUSY/LOCKED 次数
源库 WAL 大小
目标文件写入速度
备份线程等待时间
不要只记录:
backup failed
这种日志无法区分数据库锁、磁盘错误和程序逻辑错误。
场景四:integrity_check 通过,但恢复后应用失败
可能原因:
- 应用版本不兼容 schema;
- 缺少外部配置或密钥;
- 数据库内没有满足业务要求的数据;
- 备份属于错误的租户或错误环境;
- 应用启动时依赖了数据库外部的文件。
修复:
- 将应用启动测试纳入恢复演练;
- 验证数据库 schema 版本;
- 验证关键业务表和计数;
- 验证配置、密钥和外部资源;
- 记录从备份到服务可用的完整耗时。
十五、恢复时的文件替换边界
恢复不是简单地执行:
cp backup.db app.db
更安全的流程是:
停止或隔离所有使用 app.db 的连接
↓
确认没有残留写事务
↓
将备份复制到恢复临时路径
↓
执行 quick_check/integrity_check
↓
完成权限、所有者和标签设置
↓
原子替换数据库路径
↓
重新启动应用
↓
执行启动后验证
如果数据库使用 WAL 模式,必须特别注意:
app.db
app.db-wal
app.db-shm
在没有任何 SQLite 连接使用数据库时,才可以安全地清理不再属于当前数据库状态的旧 sidecar 文件。不能在数据库仍被进程打开时随意删除 -wal 或 -shm,否则可能破坏正在运行的连接或丢失尚未合并的状态。
恢复后,应用必须重新打开数据库连接。旧连接可能仍持有:
- 旧文件描述符;
- 旧 pager 状态;
- 旧 WAL 快照;
- 旧 schema 缓存。
替换路径不会自动把已有连接切换到新文件。
十六、安全边界:备份文件也是敏感数据
备份副本通常包含完整业务数据,因此需要与源库同等级别的保护:
- 文件系统权限;
- 进程访问控制;
- 加密存储;
- 传输加密;
- 密钥生命周期管理;
- 备份保留周期;
- 删除和销毁策略;
- 审计日志。
如果使用 SQL 或文件路径接收外部输入,不应直接拼接路径:
backup_path = "/backup/" + user_input + ".db"
这可能引入路径穿越、覆盖任意文件或软链接攻击。备份服务应:
- 使用受控目录;
- 规范化并校验路径;
- 拒绝
..和绝对路径输入; - 使用安全创建方式;
- 对最终文件执行权限和所有者校验;
- 不把备份路径当成普通用户输入处理。
备份验证程序也应以最小权限运行。对一个损坏或恶意构造的 SQLite 文件执行复杂读取时,减少进程权限和网络权限可以降低风险。
十七、规范保证、实现现象和工程建议
最后需要把三类结论分开。
SQLite API 的规范语义
可以依赖:
- Backup API 通过 SQLite 连接执行数据库复制;
sqlite3_backup_step()支持分步复制;SQLITE_DONE表示复制完成;sqlite3_backup_finish()必须调用;remaining/pagecount可用于进度;- API 的目标是生成一致的数据库副本,而不是裸文件拼接。
常见实现现象
不能把它们当成无条件保证:
- WAL 模式下读写通常可以并发;
- checkpoint 通常不会阻塞所有读写;
- 小批量
step()通常更容易控制锁和延迟; - 新建目标库通常更容易管理为单文件;
- 关闭目标连接后通常更容易得到可搬运的目标文件。
这些行为仍然会受 SQLite 编译选项、操作系统、文件系统、连接配置和当前并发状态影响。
工程层面的建议
属于应用设计,而非 SQLite API 自动提供的能力:
- 先写临时文件,再验证和发布;
- 对
BUSY/LOCKED使用有上限的重试; - 将结构检查、业务检查和恢复启动测试结合起来;
- 保存多个带时间和版本标识的备份;
- 对备份文件计算校验和;
- 记录备份耗时、页数、错误码和 WAL 大小;
- 定期执行真实恢复演练;
- 不把周期性 Backup API 误称为实时复制。
SQLite Backup API 解决的是“如何在数据库仍在线使用时,得到一个 SQLite 层面一致的副本”。它不会替应用决定复制频率、恢复点、业务边界、备份发布方式和灾难恢复流程。
真正可恢复的系统必须同时满足:
缺少其中任何一项,备份都可能只是一个看起来存在、但无法在故障时使用的文件。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:SQLite JSON、FTS5 与扩展:虚拟表、Tokenizer 和安全加载
- 下一篇:SQLite 安全边界:文件权限、注入、扩展、加密选择和不可信数据库
- 延伸:SQLite 事务与 WAL:锁状态、并发读写、Checkpoint 和忙等待
- 延伸:SQLite 工程实践:嵌入应用、备份、迁移、损坏恢复与安全
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论