工程
SQLite 备份的正确姿势:.backup、VACUUM INTO 与热备份
SQLite 不是简单复制一个文件就算完成备份。本文区分 .backup、VACUUM INTO 与停机文件复制的边界,给出 WAL 模式下可落地的热备份与恢复流程。
SQLite 的备份最容易被低估,因为它看起来只是一个文件。很多人的第一反应是定时 cp app.db backup.db,或者让对象存储同步整个数据目录。这个做法在停机状态下没问题,但只要线上进程还在写入,尤其已经开启 WAL,单纯复制文件就可能拿到一个不一致的快照:主库文件、-wal 文件、-shm 文件并不一定处在同一个时间点。
真正可靠的 SQLite 备份要同时回答三个问题:备份时业务能不能继续写,备份文件是不是一个可独立打开的数据库,恢复流程有没有被实际验证过。.backup、VACUUM INTO 和在线备份 API 解决的是不同场景,不该混用成一个模糊的「导出数据库」。
不要把 cp 当热备份
SQLite 默认的 rollback journal 模式下,写事务会临时生成 journal 文件;WAL 模式下,新增写入先追加到 app.db-wal。如果只复制 app.db,你可能漏掉最近提交的数据;如果同时复制 app.db、app.db-wal、app.db-shm,也无法保证三个文件是在同一瞬间被复制的。
停机维护时,文件复制当然可用:
systemctl stop myapp
cp app.db backups/app-2026-06-16.db
systemctl start myapp
但这不是热备份,而是短暂停机备份。它的优点是简单、可解释;缺点是需要维护窗口。只要你希望备份过程不影响线上写入,就应该使用 SQLite 自己提供的一致性备份机制。
首选 .backup:小成本拿到一致快照
sqlite3 命令行内置的 .backup 使用 SQLite Online Backup API。它从源库读出一个一致快照,写入新的目标库文件,备份结果是一个普通 SQLite 数据库,可以直接拿来恢复。
mkdir -p backups
sqlite3 app.db ".backup 'backups/app-$(date +%F-%H%M%S).db'"
如果线上库开启了 WAL,.backup 仍然是正确选择。它看到的是某个时间点的一致视图,而不是粗暴地复制底层文件。备份期间写事务仍然可以继续提交,只是备份进程需要读完整个数据库;库很大时,磁盘读 IO 会和业务争资源,所以应放在低峰期,并限制备份频率。
一个更稳的脚本会先备份到临时文件,校验通过后再原子改名:
#!/usr/bin/env bash
set -euo pipefail
src="/srv/app/app.db"
dir="/srv/app/backups"
ts="$(date +%F-%H%M%S)"
tmp="$dir/app-$ts.db.tmp"
dst="$dir/app-$ts.db"
mkdir -p "$dir"
sqlite3 "$src" ".backup '$tmp'"
check=$(sqlite3 "$tmp" "PRAGMA quick_check;")
if [[ "$check" != "ok" ]]; then
echo "backup integrity check failed: $check" >&2
rm -f "$tmp"
exit 1
fi
mv "$tmp" "$dst"
find "$dir" -name 'app-*.db' -mtime +14 -delete
PRAGMA quick_check 本身不会用非零退出码报错,它的结果是一行文本——必须把输出抓出来比较,否则 set -e 抓不到坏库,半截或损坏的文件会被原子改名为最新备份,把真正能用的旧备份顶掉。顺序也很关键:先生成完整备份,再做一致性检查,最后才让备份文件进入可被恢复流程看到的目录。
如果备份由后台管理界面触发,也应沿用同一套语义:不要让 Web 进程自己拼 cp 命令,而是调用驱动暴露的 online backup 能力,或者把备份请求投递给受控的后台任务。这样可以统一限流、审计和失败告警,也避免用户连续点击按钮时启动多份大 IO 任务。
VACUUM INTO:适合做压缩后的干净副本
VACUUM INTO 也能生成一致的数据库文件,但它的语义不同:它会把源库重新整理后写成一个新库。删除过大量数据、页碎片明显、希望拿到更小的归档副本时,它比 .backup 更合适。
VACUUM INTO '/srv/app/backups/app-compact.db';
也可以从命令行执行:
sqlite3 app.db "VACUUM INTO 'backups/app-compact-$(date +%F).db';"
它的代价也更高。VACUUM INTO 需要完整扫描并重写数据库,耗时和临时磁盘压力通常大于 .backup。如果你的目标是高频热备份,比如每小时一次,优先用 .backup;如果目标是每天或每周做一份归档、顺便清理空闲页,再考虑 VACUUM INTO。
还有一个容易忽略的点:VACUUM INTO 和 .backup 一样产出的是完整数据库文件,不是增量备份。对几十 MB 的内容站很轻松,对几十 GB 的业务库就必须认真评估 IO 峰值、备份窗口和存储成本。
WAL 模式下的检查点策略
备份前不需要强行关闭 WAL。相反,线上库通常应该保持 WAL,因为它让读写互不阻塞,适合 Web 应用。需要关注的是 checkpoint:如果长时间没有 checkpoint,app.db-wal 可能持续增长,备份读取的数据量和恢复时的认知负担都会变大。
可以把 checkpoint 做成低峰期维护任务:
sqlite3 app.db "PRAGMA wal_checkpoint(PASSIVE);"
PASSIVE 不会为了 checkpoint 强行等待活跃读写,适合作为日常保守动作。如果你确定进入维护窗口,可以使用更激进的 TRUNCATE:
sqlite3 app.db "PRAGMA wal_checkpoint(TRUNCATE);"
但不要在高峰期机械执行 TRUNCATE。它需要更强的锁协调,遇到长查询或写入压力时可能卡住。对内容站、后台系统这类写入不密集的应用,常见策略是:业务继续使用 WAL;每晚先跑 .backup;备份完成后做一次 PASSIVE checkpoint,防止 -wal 文件越长越大;每周低峰期再做 VACUUM INTO 归档。
恢复比备份更重要
没有演练过恢复的备份,只能算心理安慰。SQLite 的恢复流程应该足够朴素:停止应用,把当前库移走,把备份库复制回原路径,启动应用,再跑一组业务级检查。
systemctl stop myapp
mv /srv/app/app.db /srv/app/app.db.broken
cp /srv/app/backups/app-2026-06-16-030000.db /srv/app/app.db
chown myapp:myapp /srv/app/app.db
sqlite3 /srv/app/app.db "PRAGMA quick_check;"
systemctl start myapp
如果应用使用 WAL,恢复时只放回备份出来的单个 .db 文件即可,不要把旧目录里的 app.db-wal 和 app.db-shm 一起带回来,让 SQLite 在启动后重新创建它们。旧 WAL 文件来自另一个时间线,混进去反而会制造难以判断的问题。
一个可落地的备份分层
小型自托管站点可以从三层开始:
- 每小时或每六小时跑一次
.backup,保留最近 48 小时。 - 每天凌晨把一份
.backup上传到异地对象存储,保留 30 天。 - 每周跑一次
VACUUM INTO,生成体积更小的长期归档,保留 3 到 6 个月。
这个方案没有复杂组件,恢复时也不需要先重建数据库服务。它真正依赖的是纪律:备份脚本失败要立即告警,备份文件要做一致性校验,恢复流程要定期在临时目录演练一次。
SQLite 的优势是单文件、低运维,但「单文件」不等于可以随便复制。日常热备份用 .backup,压缩归档用 VACUUM INTO,维护窗口内才用文件级复制。把这三个边界分清楚,SQLite 的备份会非常简单,也足够可靠。