← 返回首页

SQLite 热备 11 轮实测:能打开的备份丢了 14500 行

SQLite备份恢复数据库运维

有读者问我一个问题:备份脚本每天定时跑,日志全绿,为什么真出事那天恢复不出来。我把这句话拆成了一个可以量化的实验——同一份 SQLite 库,四种常见的「备份」做法,各跑 5 到 6 轮,全程保持写入,然后逐行对账。

结论比预想的更难看:能打开的备份,不等于对的备份。最常用的那种做法,也就是直接把 .db 文件拷走,在 5 轮里有 4 轮丢掉了已提交数据,最惨一轮少了 14,500 行,而 PRAGMA integrity_check 五轮全部返回 ok。

这篇是我实际测试的全过程记录,环境是本机 Windows + CPython 3.13 内置的 sqlite3 3.53.1,没有 Docker、没有云厂商控制台,所有数字都能在你自己机器上复现。正文不含任何商品推广链接(联盟链接),也没有商品推荐;页面底部那个书单模块由站点统一挂载,和本文选题无关。

先给结论

三句话,后面每句都有对应的实测数据:

1. 单文件拷贝是「一致但过期」。它拿到的往往是过去某一时刻的合法状态,所以任何结构校验都会通过。你唯一的发现方式是拿它跟现网对账,而不是拿它跟自己比。

2. 三文件拷贝(.db + -wal + -shm)不会丢数据,但它会随机失败。5 轮里 1 轮抛 PermissionError,6 轮里 2 轮抛,错误原文是 [WinError 33] 另一个程序已锁定文件的一部分。

3. 把 checkpoint 频率调高之后,单文件拷贝从「过期」变成「损坏」。6 轮里 2 轮恢复出来直接报 database disk image is malformed。

正确的做法只有两种:sqlite3 的 backup API,或者 VACUUM INTO。前者在 43 MB 库上平均 386 ms,后者 460 ms,两者在全部轮次里都拿到了零丢失的快照。

实测环境与「一致」怎么判定

先把判定标准说清楚,否则后面的表格没法读。

库是一张单表 events(id INTEGER PRIMARY KEY, ts INTEGER, payload TEXT),payload 是 200 字节的填充串。初始 20 万行、43.3 MB。写端在独立子进程里跑,500 行一批,批间隔 50 ms,每批一次 commit,折算下来约 10,000 行/秒。备份动作在主进程里,趁写端还在跑的时候取快照,取完再让写端自然结束。

判定「这份快照是不是一致的」,我用的是一个可以被证明的性质:写端是整批提交的,所以任何时刻的一致快照必然是一段连续前缀——id 从 0 到某个值没有空洞,行数是 500 的整数倍,且 max(id) + 1 等于行数。

偏差那一列 = 快照行数 − 取快照前一刻查到的行数。负数表示丢了已提交事务,正数只是拷贝过程中写端又写进去了几批,属于正常。

顺带把校验本身的成本也测了:43 MB 的库,PRAGMA quick_check 用了 70 到 120 ms,PRAGMA integrity_check 用了 71 到 108 ms。也就是说,在几十 MB 这个量级上,「每次备份都跑一遍完整校验」的成本是可以忽略的——这个前提很重要,它意味着如果你的演练没做校验,那不是因为贵。

单文件拷贝:一致,但可能停在 1.45 秒之前

这是最普遍的做法:shutil.copy2(db_path, backup_path),或者 cp、rsync 单文件、或者云盘同步一个 .db。默认 checkpoint 配置下,5 轮结果:

轮次拷贝前可见行数快照行数偏差integrity_check
1203,500200,000−3,500ok
2232,000232,0000ok
3262,500248,000−14,500ok
4292,500287,000−5,500ok
5323,000319,000−4,000ok

4/5 轮丢数据,最差 −14,500 行。按 10,000 行/秒的写入速率折算,这份「备份」停在 1.45 秒之前。第 2 轮偏差为 0,是因为那一轮拷贝恰好落在一次 checkpoint 刚结束的窗口里——这说明它不是「偶尔不准」,而是「大部分时候不准,偶尔撞对」。

原因在官方文档里写得很直白。WAL 模式的库由主库文件加 -wal、-shm 组成,其中 WAL 文件的定位是:

The WAL file is part of the persistent state of the database and should be kept with the database if the database is copied or moved.

紧接着那句是后果:

If a database file is separated from its WAL file, then transactions that were previously committed to the database might be lost, or the database file might become corrupted.

官方给出的默认 checkpoint 阈值是 1000 页、约 4 MB,达到就自动把 WAL 内容搬回主库并回收。所以主库文件里装的是「上一次 checkpoint 时刻」的内容,WAL 里装的是之后的提交。你只拷主库,等于只拷了上一次 checkpoint 的快照。

危险的地方在于它完全合法。这不是损坏,是时间旅行——它确实是某个真实时刻的完整数据库,所以 integrity_check 会说 ok,页结构、索引、约束全都对。任何只看「能不能打开、结构对不对」的验证都会被它骗过去。

顺带说一个同源坑:如果你打算「把 WAL 删掉再拷」,官方只承认一种安全删法——用 sqlite3_open() 打开再立刻 sqlite3_close(),让 SQLite 自己做完最后一次 checkpoint 再删。

三文件拷贝:丢 0 行,但会随机失败

既然单文件不行,那把三个文件一起拷呢?5 轮里数据一行没丢,但有一轮直接抛异常:

PermissionError: [WinError 33] 另一个程序已锁定文件的一部分,进程无法访问。

在「每事务 checkpoint」那一组 6 轮里,同样的错误出现了 2 次。原因是 Windows 对 -wal 这类正在被写入进程持有的文件会加字节范围锁,shutil.copy2 打开它的时候就被挡住了。Linux 上通常不会报这个错,但也别高兴太早——cp 三个文件不是原子操作,主库、-wal、-shm 是在三个不同瞬间被读的。写端在两次 copy 之间提交了一批,你拿到的三个文件就来自三个时刻。这种拼接出来的组合能不能打开,取决于 WAL 帧头里的校验和与盐值对不对得上,属于概率问题。

实测里它 5 轮都对,我不打算因此推荐它。一个会随机抛异常、且异常时你已经覆盖了一半目标文件的备份方式,在脚本里就是个定时炸弹:cp a b 失败后 b 可能已经是半截文件,而你的脚本如果只判断退出码、不判断产物,就会把半截文件当成今天的备份留着。

把 checkpoint 调激进之后,单文件拷贝直接损坏

上面两组都是默认配置。但很多项目会为了「WAL 别涨太大」把 wal_autocheckpoint 调到 1,或者业务侧频繁手动 PRAGMA wal_checkpoint(TRUNCATE)。我把写端的 wal_autocheckpoint 设成 1(每个事务都 checkpoint),跑 6 轮:

结果轮数说明
快照正常、偏差 04/6主库几乎总是最新,反而「对」了
DatabaseError: database disk image is malformed2/6打开就报错,连 integrity_check 都跑不了

同一个操作,在两种 checkpoint 配置下呈现出两种完全不同的故障形态:一种静默过期,一种直接损坏。而「激进 checkpoint」恰恰是很多团队为了防止 WAL 膨胀而主动做的优化——优化完备份就坏了,而且坏得毫无规律。

这一条是我这轮实验里最想强调的:备份正确性依赖的是运行期配置,不是备份脚本本身。你改了一个看起来无关的 PRAGMA,备份的可靠性就换了档。

两种官方推荐的做法,代价可以量出来

sqlite3 官方给了两条正路,都支持在库被使用时取快照。

backup API。官方对它的保证是逐比特一致:

The effect of completing the backup call sequence is to make the destination a bit-wise identical copy of the source database as it was when the copying commenced. (The destination becomes a "snapshot.")

Python 里就是三行:

src = sqlite3.connect(db_path)
dst = sqlite3.connect(backup_path)
src.backup(dst)
dst.close(); src.close()

实测 43 MB 库平均 386 ms;在 82.5 MB、38 万行的库上,单次复制 438 ms,折算 188 MB/s。5 轮全部零丢失。另外官方提到,如果备份期间源库被改,SQLite 会检测到并「usually restarts the backup process」——所以它在写很频繁的库上可能反复重来,「If the backup process is restarted frequently enough it may never run to completion」,这一点要有超时兜底。

VACUUM INTO。官方明确说它是 backup API 的替代品:

The VACUUM command with an INTO clause is an alternative to the backup API for generating backup copies of a live database.

取舍也写了:产物体积最小、已删内容被彻底清除、不留取证痕迹;代价是更吃 CPU、不能增量。实测 43 MB 库平均 460 ms,5 轮零丢失。代价比 backup API 高 19% 左右,换来的是产物更小、且删掉的数据不会留在备份里——如果备份要交给外部或要长期归档,这个差别是实质性的。

它有一个必须知道的限制:目标文件必须不存在或者是空文件,否则直接失败。我拿一个 1 KB 的普通文件当目标,报的是:

DatabaseError: file is not a database

这个报错信息很容易误导人——它看起来像「源库坏了」,实际是「目标路径上有个不是数据库的东西」。

恢复演练:340 毫秒跑完,但九成的演练验错了东西

把备份做对之后,还有第二道关:恢复演练本身验什么。

先给成本,因为它决定了你有没有借口不做。82.5 MB、38 万行的制品,还原到新路径并完成「打开 + 计数 + 完整校验」的全流程:

340 毫秒。这个量级下,「演练太贵所以只在季度做一次」是不成立的,你完全可以每次备份后立刻跑一遍。

但重点不是快,是验的东西对不对。两个陷阱我都实测了:

陷阱一:0 字节文件也能拿到 ok。 我用 open(path, "wb").close() 造了个空文件,sqlite3.connect() 打开后:

也就是说,如果你的演练只做「打开 + integrity_check」,一个 0 字节的空文件能拿满分。真实的空备份最常见成因是:备份任务以 > 重定向创建了目标文件,然后中途失败退出,留下一个 0 字节文件,而你的告警只看「任务是否退出 0」。

陷阱二:过期的快照也是 ok。 这在第一节已经证明过——少 14,500 行的那份快照,integrity_check 五轮全绿。结构校验回答的问题是「这个文件内部自洽吗」,不是「它是不是我想要的那一刻」。

所以演练要验的是三个能对账的数字,而不是「能不能打开」:

1. 行数:快照行数 vs 现网行数(或上一个已知基线),差异要在预期窗口内;

2. 最大主键:SELECT MAX(id),它比行数更能暴露「尾部丢失」;

3. 业务时间戳:表里最大写入时间距今多久,这个数字直接翻译成「如果现在恢复,会损失多少分钟的数据」,也就是 RPO。

再加一条只读查询,确认表结构和索引都在。四条加起来还是几百毫秒,但它能同时抓住空文件、过期快照、结构损坏三种情况——这三种恰好是实测里出现过的全部故障形态。

一份可以直接抄的演练检查单

把上面的东西固化成脚本,大概是这样:

1. 取快照用 src.backup(dst) 或 VACUUM INTO,不要用文件拷贝;

2. 快照落盘后立刻记一条元数据:行数、MAX(id)、MAX(ts)、文件大小、生成耗时;

3. 把快照还原到临时路径(不要覆盖现网),打开它;

4. PRAGMA integrity_check 必须返回单行 ok;返回多行说明有结构问题,且默认最多只报 100 条;

5. 行数、MAX(id)、MAX(ts) 三项与现网对账,任一项对不上就告警——这一步才是真正的验证;

6. 跑一条业务只读查询(不是 SELECT 1),确认表、索引、视图都在;

7. 记录总耗时,它就是你的 RTO 下限。

第 4 步和第 5 步的分工要说清楚:integrity_check 查的是格式与一致性(页顺序、记录格式、缺失页、索引项、UNIQUE/CHECK/NOT NULL、freelist、重复使用区段),它不查外键,官方原文是「PRAGMA integrity_check does not find [FOREIGN KEY] errors」,那要用 PRAGMA foreign_key_check。它也不查「数据新旧」——因为它压根不知道现网长什么样。

顺带说一句性能选择:quick_check 不验证 UNIQUE 约束、不验证索引内容与表内容一致,官方给的时间复杂度是 O(N) 对 O(N log N)。在 43 MB 上两者都是 100 ms 上下,看不出差别;但如果你的库到了几十 GB,日常巡检用 quick_check、发布前用完整 integrity_check 是合理的分层。

踩坑与报错

这一节的四条报错都是本机实测复现的,原文照录。

PermissionError: [WinError 33] 另一个程序已锁定文件的一部分,进程无法访问。

出现在拷贝 -wal 时。Windows 上活跃 WAL 文件被写端持有字节范围锁。这不是可以靠重试稳稳绕过的错误——重试成功只说明你挑到了写端两次提交之间的空隙,不代表三文件来自同一时刻。正确解法是换 backup API 或 VACUUM INTO。

DatabaseError: database disk image is malformed

出现在「每事务 checkpoint + 单文件拷贝」的 6 轮里 2 轮。打开快照就报这个,连校验都跑不起来。根因是主库正在被就地改写,你拷到的是改到一半的页。遇到这个错误先别怀疑源库——先确认备份方式。

DatabaseError: file is not a database

两个来源。一是把 -wal 当成数据库打开(我拿一个 136.8 KB 的真实 WAL 文件试的);二是 VACUUM INTO 的目标路径上已经有非空文件。后者尤其容易误判,因为报错文本描述的是「文件不是数据库」,而问题出在参数上。

OperationalError: no such table: events

这是「恢复成功」的假象:文件能打开、integrity_check 返回 ok、但库里一张表都没有。0 字节文件就会走到这里。如果你的恢复脚本在 integrity_check 之后就打印「恢复成功」,这个错误永远抓不到——它只会在你真正要查数据的那天才出现。

这套结论的边界

说清楚我测了什么、没测什么:

最后

这轮实验里最贵的一课不是「文件拷贝不行」,而是验证手段和故障形态不匹配。我们习惯用「能不能打开」证明备份可用,可实测里全部三种故障——0 字节、过期 1.45 秒、页级损坏——没有一种是被「能打开」拦下来的;前两种连 integrity_check 都拦不住。

真正拦住它们的是对账:行数、最大主键、最大时间戳,跟现网比。这三个数字不需要额外工具,一条 SQL 就够,成本几百毫秒。把备份从「任务成功」改成「对账成功」,中间差的不是技术,是那句一直被跳过的检查。

如果你的备份脚本现在只判断退出码,建议今晚就把它改成判断产物——先查行数,再查 MAX(id),最后才是 integrity_check。顺序反了也能用,但先跑对账能让你更早看到问题。

👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务

👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速

📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页