备份一直在跑,我第一次真的恢复了一遍:只复制数据文件,25 次里 12 次拿到的是坏库
我一直觉得备份这事儿没什么可想的:定时把数据文件复制一份,放到另一个地方,完事。真要出事,把文件拷回去覆盖一下就行。
上周我打算把这套做法写成脚本固化下来,写到一半卡住了——我发现自己从来没验证过拷回去的那份到底能不能用。备份任务每天都在跑,可从没有一次是真的"恢复"过的。于是我没继续写脚本,先做了一次完整的恢复演练。
结果不太好看。
演练环境:为什么我用 SQLite
我这台机器上没有 MySQL、没有 SQL Server,只有 Python 自带的 SQLite 3.53.1。所以以下所有数字都是我在 SQLite 上真跑出来的,不是查来的。
好在备份这件事的核心矛盾跟数据库品牌关系不大:写操作先落日志、再合并进主文件;备份要拿到某个时刻的一致状态;备份副本要能真的还原。SQLite 把这些环节都暴露得挺直白,反而更适合拿来把问题看清楚。
换成其他数据库的做法我会放在最后说,那部分我没在这台机器上跑过,只写思路。
第一刀:只复制主库文件,拿到的是上一次合并时的状态
我建了一张 15 万行的表,然后开一个后台线程模拟业务持续写入,在写入进行到一半的时候做备份。四种做法各来一次:
备份方式 耗时s 大小MB 行数(索引) 行数(全扫) RPO秒 撕裂?
--------------------------------------------------------------------------------
A 只cp主库 0.010 14.64 150000 150000 4.25 no
B cp主库+wal 0.023 14.64 174000 174000 1.01 no
C backup API 0.247 17.29 177000 177000 0.82 no
D VACUUM INTO 0.278 17.60 184500 184500 0.29 noA 那一行让我愣了一下:150000 行,正好等于我的基线行数。也就是说,整个备份期间的写入,一行都没进到我的"备份"里。
原因很清楚:WAL 模式下,新的写入先进 -wal 文件,等攒够了再合并(checkpoint)回主库。我只复制主库文件,拿到的就是"最后一次合并完成时"的样子。我顺手查了下默认值,wal_autocheckpoint 是 1000 页——什么时候合并由它决定,跟我的备份任务什么时候跑没有任何关系。
RPO 那列是我用真实时钟算的:备份里最新一条数据的时间戳,跟库里最新一条的差值。只复制主库是 4.25 秒,而且是"整个备份窗口内的写入全丢"这种丢法。
更难受的是这四种备份的体检结果:
A 只cp主库 integrity_check=ok 内容指纹==最终? False 比最终少 39000 行
B cp主库+wal integrity_check=ok 内容指纹==最终? False 比最终少 15000 行
C backup API integrity_check=ok 内容指纹==最终? False 比最终少 12000 行
D VACUUM INTO integrity_check=ok 内容指纹==最终? False 比最终少 4500 行四个全是 ok。四个全都不是最新的。
这就是我觉得备份最容易骗人的地方:它能打开,结构检查也过了,但它就是旧的。你要是不去比对内容,永远发现不了。
第二刀:复制的时候正好在合并,一半概率拿到坏库
上一轮四种做法都没出现"打不开"的情况,我还以为"复制会拿到坏库"这个说法是危言耸听。于是我加了一轮专门找它麻烦的测试:让后台线程不停做 checkpoint,同时反复复制主库文件,每次复制完立刻体检。
一、撕裂测试:业务还在写,反复 copy 数据文件
==============================================================================
WAL 模式,只拷主库 :拷 25 次 → 正常×25
WAL + 后台不停 checkpoint :拷 25 次 → 正常×13 / 打不开:database disk image is malformed×12
DELETE 模式(回滚日志) :拷 25 次 → 正常×16 / 打不开:database disk image is malformed×8 / 结构损坏×125 次里 12 次,副本直接打不开了,报 database disk image is malformed。换成老的回滚日志模式(DELETE),25 次里 9 次有问题,其中 1 次是结构损坏但还能打开——这种更难发现。
对照一下,同样的写入压力下用官方的 backup API 拷 15 次:
backup API(一次性拷完)拷 15 次,异常 0 次零异常。SQLite 官方文档里其实早就写明了老办法的三个毛病:备份期间写入方要等锁、不能用于内存数据库、复制过程中掉电或系统故障会让备份损坏。我这次算是亲手撞上了第三条。
第三刀:数据被人改了,完整性检查照样放行
这一条是我觉得最该记住的。
我往备份文件里插了一行带哨兵字符串的记录,然后直接改文件字节,把 CANARY-MEMO-0001 等长替换成 CANARY-MEMO-9999。注意是等长替换,页结构、btree 一个字节都没动。
在备份文件里定位到哨兵数据,字节偏移 = 18128481
改完之后的 integrity_check = ok
改完之后那行读出来 = CANARY-MEMO-9999
应用层指纹与原始备份比 = 不同(checksum 抓到了)PRAGMA integrity_check 说 ok。它只管结构是不是自洽,不管你存的业务数据对不对。数据被篡改、被部分覆盖、磁盘静默翻转位(bit rot),它一概不管。
我在备份脚本里加的那层应用层 checksum,这回抓到了。
但我也不想把它吹成万能药。反过来做一次破坏结构的测试,结果正好相反:
PRAGMA integrity_check = *** in database main *** Tree 3 page 3: btreeInitPage() returns error code 11
应用层 checksum = 算得出(内容层面看不出)结构坏了,完整性检查立刻报警;但内容层面照样能把数据扫出来算指纹。所以这俩是互补的,只做其中任何一个都有盲区。
我最后落地的脚本
演练做完,我把备份这件事拆成了三段,缺一段都不算完。下面这个是可直接跑的精简版(Python 标准库,零第三方依赖):
def backup(tag):
"""备份 + 结构检查 + 内容指纹,三样一起落盘"""
ts = time.strftime('%Y%m%d-%H%M%S')
dst = os.path.join(BKDIR, '%s-%s.db' % (tag, ts))
t0 = time.time()
src = sqlite3.connect(DB)
out = sqlite3.connect(dst)
src.backup(out) # 在线备份,源库不用停
n, fp = fingerprint(dst)
ic = out.execute('PRAGMA integrity_check').fetchone()[0]
out.close(); src.close()
meta = {'file': os.path.basename(dst), 'rows': n, 'sha256': fp,
'integrity_check': ic, 'seconds': round(time.time() - t0, 3)}
json.dump(meta, open(dst + '.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
return meta关键在 fingerprint():它不是看文件能不能打开,而是把表里的数据逐行喂进 sha256。备份做好的那一刻就把指纹记进 json,之后校验时拿新算的指纹跟它比。
跑起来是这样:
备份完成:auto-20261019-182918.db
行数 50000 / 指纹 e5527fb808851ea9... / 结构 ok / 耗时 0.377s / 1.24MB
--- 场景 1:备份完原样校验,应该通过 ---
auto-20261019-182918.db → 没问题
--- 场景 2:人为改掉备份里的一行数据,再校验 ---
auto-20261019-182918.db → 内容指纹变了(数据被改过)
(顺带看一眼结构检查:ok ← 结构层面它照样放行)
--- 场景 3:恢复演练 ---
从 drill-20261019-182919.db 恢复到临时库:50000 行,耗时 0.281s,数据一致? 是场景 3 是我以前从来没做过的那一步:定期把备份真的还原出来读一遍。备份、校验、恢复演练是三件事,我以前只做了第一件。
顺手验证了一条官方警告,没复现出来
文档里有一句挺吓人的:备份过程中源库被写,SQLite 会重启备份,"如果重启太频繁,它可能永远跑不完"。我拿高频写入(每 0.02 秒提交一批)去撞分页备份:
高频写入(0.02s) + 分页备份 0.09s 完成(249 次迭代,重启 1 次)
高频写入(0.02s) + 一次拷完 0.08s 完成(1 次迭代,重启 0 次)
低频写入(0.5s) + 分页备份 0.07s 完成(137 次迭代,重启 0 次)重启确实发生了(249 次迭代里重启 1 次),但备份 0.09 秒就跑完了,没撞上死循环。原因大概是我的库太小,备份一轮比写入间隔还短。这条警告在大库上大概率会成真,我没复现出来不代表它不存在——这点我如实记着,等哪天拿几十 GB 的库再试一次。
另外 VACUUM INTO 在同一场景下 0.10 秒完成,副本体检正常。它是 3.27.0(2019-02-07)才加进来的,好处是顺带整理碎片、备份体积最小,代价是不能增量、CPU 开销比 backup API 高。两个我都留着:日常用 backup API,需要一份干净紧凑的归档时用 VACUUM INTO。
我的判断
备份的价值不在于"有",而在于"能恢复"。 这两件事之间的差距,我这次量出来是 39000 行、4.25 秒、以及 25 次里 12 次打不开。
如果只改一件事,我建议是把恢复演练加上:每个月挑一份备份,真还原、真读一遍、真比对指纹。这一步花不了几分钟,但它能把"我有备份"变成"我的备份能用"。这一步没做,前面所有的备份任务都只是在消耗磁盘。
第二件事是别只做结构检查。这是我这次最意外的收获——一个被改了数据的备份,integrity_check 会笑眯眯地告诉你一切正常。
至于换到其他数据库:MySQL 是 mysqldump(逻辑)或 XtraBackup(物理),PostgreSQL 是 pg_basebackup 加 WAL 归档,SQL Server 是完整 + 差异 + 日志的备份链。这几个我在这台机器上没跑过,命令和参数以各自官方文档为准,但那三件事——一致性快照、内容校验、定期真恢复——到哪儿都一样。
上面这些数字都是我在本机 SQLite 3.53.1 上跑出来的,脚本在上面,可以直接复现。
我引用的官方说法来自 SQLite 在线备份 API 文档 与
VACUUM 文档,VACUUM INTO 的引入版本见
3.27.0 release log。核对日期 2026-10-19。
