SQLite 备份直接复制文件为什么可能丢数据
数据库跑得好好的,定时任务每天凌晨 cp app.db app.db.bak,真到要恢复那天,把备份文件拷回去一查,表没了,或者只剩几天前的数据。文件明明在,大小也不为零,数据却丢了。问题不在 cp,也不在磁盘,出在 SQLite 的 WAL 模式上:最新写进去的数据根本不在 .db 文件里,还躺在 -wal 文件里没落盘。
现象:备份文件拷回去,表直接没了
拿一个干净的库复现。开一个连接,把日志模式切到 WAL,建表插 100 行,提交,然后趁连接还开着,只复制 .db 文件:
import sqlite3, shutil, subprocess, os
con = sqlite3.connect('/tmp/w.db')
con.execute("PRAGMA journal_mode=WAL")
con.execute("CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)")
con.executemany("INSERT INTO t VALUES(?,?)", [(i, f"v{i}") for i in range(1, 101)])
con.commit()
print("wal size:", os.path.getsize('/tmp/w.db-wal'))
shutil.copy('/tmp/w.db', '/tmp/w_copy.db')
out = subprocess.run(['sqlite3', '/tmp/w_copy.db', 'SELECT count(*) FROM t;'],
capture_output=True, text=True)
print("copy-only db count:", out.stdout.strip(), out.stderr.strip())
真实输出:
wal
wal size: 12392
copy-only db count: Error: in prepare, no such table: t
注意第一行输出 wal,那是 PRAGMA journal_mode=WAL 的返回值,说明日志模式确实切过去了。这时候磁盘上有三个文件:w.db 8192 字节,w.db-wal 12392 字节,还有一个 w.db-shm。100 行数据和建表语句全在 12392 字节的 -wal 文件里,.db 文件里还是空的。只拷 .db,等于只拷了一个空壳,连表结构都没有,查表直接报 no such table: t。
这个结果比“丢几行”更狠:刚建的表整个消失。线上常见的版本没这么极端——表是几天前建的,老数据早已落盘,丢的只是最近几小时的写入——但丢数据的机制是同一个:凡是还没 checkpoint 的写入,都只在 -wal 里。
再看一组更贴近线上的数字。w.db 只有 8192 字节,也就是两个 page;-wal 12392 字节,比主文件还大。备份脚本只拷 .db,拷走的是旧快照,丢掉的是最新、最值钱的那部分数据。
判据:三条命令判断备份有没有丢数据
备份有没有丢,不用等到恢复那天才知道,三条命令当场就能判。
判据一:看目录里有没有 -wal 文件。备份前先 ls -la 看数据库所在目录:
-rw-r----- 1 root root 8192 Nov 8 01:08 w.db
-rw-r----- 1 root root 12392 Nov 8 01:08 w.db-wal
-rw-r----- 1 root root 32768 Nov 8 01:08 w.db-shm
只要 -wal 文件存在且大小不为零,就说明有写入还没回写到主文件。这时候只拷 .db 一定丢数据。-wal 为零或者不存在,才是安全的复制窗口。
判据二:看日志模式。连上库跑一句:
PRAGMA journal_mode;
返回 wal,说明库处在 WAL 模式,写入先写 -wal;返回 delete,说明是传统的回滚日志模式,事务提交后主文件就是最新的,直接复制的风险小得多。很多应用在启动时悄悄开了 WAL,自己都不知道,判据二就是用来戳破这个的。
判据三:拿备份文件本身做恢复演练。把备份拷到一个临时目录,用只读方式打开查行数,和线上库的行数对一下:
cp app.db /tmp/restore_check.db
sqlite3 /tmp/restore_check.db "SELECT count(*) FROM t;"
sqlite3 app.db "SELECT count(*) FROM t;"
两个数字不一样,备份就是坏的。这个演练的成本是一条命令,恢复演练的成本是半夜被叫起来。判据三最笨,也最可靠。
根因:WAL 模式下写入先落 -wal,主文件是旧快照
SQLite 默认的回滚日志模式(journal_mode=delete)是原地写:事务提交时,数据直接写进 .db 文件。WAL 模式反过来:写入先追加到 -wal 文件,主文件不动;读数据时,SQLite 把主文件和 -wal 里的内容合并起来看,应用看到的永远是最新数据。
这个设计是为了并发:读和写不再互相堵,读操作不用等写锁。但代价是磁盘上的 .db 文件不再是数据的完整真相,真相是 .db 加 -wal 的合集。-shm 文件是共享内存索引,只在多进程访问时起作用,重启后可以重建,不参与数据恢复。
-wal 里的数据什么时候回到主文件?靠 checkpoint。触发 checkpoint 的时机有几个:最后一个连接关闭时、WAL 文件攒到 1000 个 page(默认阈值,PRAGMA wal_autocheckpoint)、手动跑 PRAGMA wal_checkpoint(TRUNCATE)、以及新连接打开时满足特定条件。也就是说,只要还有连接开着、写入量没到阈值,数据就可以一直在 -wal 里待着。定时任务凌晨 3 点 cp 的那一刻,应用连接还开着,WAL 里攒了一白天的数据,备份拷走的只是几天前的旧快照。
还有一个更隐蔽的坑:复制 .db 的同时应用还在写。cp 不是原子操作,读到一半主文件变了,拷出来的备份可能连 SQLite 自己都打不开,报 database disk image is malformed。WAL 模式放大了这个窗口,因为复制期间 -wal 也在变。直接复制文件做备份,在 WAL 模式下既丢数据,又可能拷出损坏文件,两个坑占全了。
更麻烦的是,丢数据的量每天都不一样。写入量小的时候,WAL 没到 1000 个 page 的自动 checkpoint 阈值,数据能攒一整天;写入量大的时候,阈值一到就自动 checkpoint,备份又恰好是好的。备份时好时坏,查问题的人很容易得出“备份脚本没问题,是那天运气不好”的结论,然后继续用同一套脚本等下一次丢数据。时好时坏的备份,比一直坏的备份更难查,因为它每次都能拿出“上次明明是好的”当证据。
那为什么很多人直接复制用了很久都没出事?三个巧合叠加:库用的是默认 delete 模式而不是 WAL;备份跑在应用停机窗口,连接已关闭,WAL 已 checkpoint;或者数据量小,写入刚好在阈值内被自动 checkpoint 掉了。三个条件缺一个,丢数据只是时间问题。
修法:备份用 .backup 或 VACUUM INTO,别直接 cp
正确的备份姿势有两种,都走 SQLite 自己的接口,备份过程里应用可以继续写,拷出来的文件一定是完整一致的。
修法一:命令行 .backup,适合写进定时任务:
sqlite3 /data/app.db ".backup /backup/app-$(date +%F).db"
.backup 是 SQLite 的在线备份 API,它按 page 一把一把地拷,过程中应用的写入不会破坏备份的一致性。备份出来的文件就是一个干净的普通数据库文件,拿它恢复不需要 -wal 和 -shm。验证方法很直接,拿备份文件查行数:
sqlite3 /backup/app-2026-11-08.db "SELECT count(*) FROM t;"
行数和线上一致,备份就是好的。
修法二:Python 里用连接对象的 backup() 方法,适合应用内自己做备份:
import sqlite3
src = sqlite3.connect('/tmp/w.db')
dst = sqlite3.connect('/tmp/w_bak.db')
src.backup(dst)
dst.close()
src.close()
在前面那个复现场景里复用这段代码,同样是连接还开着、WAL 里躺着 100 行数据的状态,备份出来的文件查出来是:
backup api count: 100
100 行一分不少。同一份数据,直接复制是 no such table,走备份接口是 100 行,差别就在于是不是走了 SQLite 自己的备份路径。
如果备份目标是归档而不是恢复演练,还有一个更稳妥的组合:先手动 checkpoint,再备份。PRAGMA wal_checkpoint(TRUNCATE) 会把 WAL 里的内容全部写回主文件并把 WAL 清空,之后再用 .backup,备份文件里的数据就是最新的。注意 checkpoint 需要短暂的写锁,执行时间通常在毫秒级,放在业务低峰期跑就行。
修法三是给实在想继续用 cp 的人留的:先停应用、等所有连接关闭,确认目录里没有 -wal 文件了,再复制三个文件一起拷,或者只拷 .db。这条路能走,但等于把备份窗口绑死在停机窗口上,凌晨 3 点停服务就为了拷个文件,得不偿失。.backup 才是正解。
验证:同一份数据,两种备份方法当场对比
把修法落到可复现的验证上,一共四步,每一步都有明确的预期输出。
第一步,建一个 WAL 模式的库,插 100 行,保持连接打开:
con = sqlite3.connect('/tmp/w.db')
con.execute("PRAGMA journal_mode=WAL")
con.execute("CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)")
con.executemany("INSERT INTO t VALUES(?,?)", [(i, f"v{i}") for i in range(1, 101)])
con.commit()
预期:PRAGMA journal_mode=WAL 返回 wal,SELECT count(*) FROM t 返回 100,目录里出现 w.db、w.db-wal、w.db-shm 三个文件。
第二步,只复制 .db,查备份:
cp /tmp/w.db /tmp/w_copy.db
sqlite3 /tmp/w_copy.db "SELECT count(*) FROM t;"
预期:报错 no such table: t。这一步不是为了看热闹,是为了确认手里的库确实处在“数据在 WAL 里”的状态,复现成立,后面的对比才有意义。
第三步,用备份接口再备一份,查备份:
dst = sqlite3.connect('/tmp/w_bak.db')
con.backup(dst)
dst.close()
sqlite3 /tmp/w_bak.db "SELECT count(*) FROM t;"
预期:返回 100。同一个库、同一个时间点,两种方法的结果一个是报错、一个是 100 行,结论不用再解释。
第四步,把这个对比写进备份脚本的自检里。每次备份完,自动拿备份文件跑 SELECT count(*) 和线上库对数,对不上就告警,不要等到恢复那天才发现。备份脚本的最后一行应该是验证,不是复制命令本身。
一个顺手能用的检查清单:备份前确认 journal_mode 是不是 wal;备份时用 .backup 或 backup(),不用 cp;备份后拿备份文件查行数,和线上对一遍;目录里出现 -wal 文件时,任何只拷 .db 的操作都先停下。按这个清单走,WAL 模式的备份就不会再丢数据。
- 点赞
- 收藏
- 关注作者
评论(0)