备份一直在跑,我第一次真的恢复了一遍:只复制数据文件,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 no

A 那一行让我愣了一下: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 / 结构损坏×1

25 次里 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。
最后修改:2026 年 10 月 19 日
如果觉得我的文章对你有用,请随意赞赏