SQLite 在线热备:为什么不能直接 cp 数据库文件
生产环境里跑着一个小面板,数据库是 SQLite,不到 3MB。每次升级前我都会先备份一份。有一回图省事,直接 cp 了 db 文件,升级出事想回滚,把备份贴回去,启动就报 database disk image is malformed。数据没全丢,但丢了一整个下午的写入。
那次之后我把备份流程重写了。下面把这件事说清楚:为什么 cp 会坏,以及三种正确做法怎么选。
一、cp 为什么会拿到坏快照
SQLite 在 WAL 模式下工作,一个库对应至少三个文件:主库 .db、预写日志 .db-wal、共享内存索引 .db-shm。
写入不是直接改主库,而是先追加到 WAL,之后某个时刻才 checkpoint 回主库。这意味着两件事:
第一,任何时刻主库文件本身只是"某个历史状态",最新数据还在 WAL 里。只 cp 主库,等于丢掉 checkpoint 之后的所有写入。
第二,checkpoint 是分页搬运的。你在搬运到一半时 cp,主库里就是一半新页、一半旧页的混合体——页校验能过,但语义已经错了,恢复出来是"能打开但数据错乱"。这种情况最坑,因为它不一定当场报错,可能几天后才在对账时暴露。
二、三种做法
做法一:sqlite3 的 .backup 点命令
sqlite3 db ".backup out.db",内部调的就是 backup API,一致性没问题。代价是机器上得有 sqlite3 命令行,容器镜像里经常没有,为了备份再装一个包不值。
做法二:停服后 cp
先停服务,再 cp 三个文件,然后启动。一致性有保证(前提是确实没进程在写),但服务要停,而且容易漏掉 -wal。适合半夜跑批,不适合"升级前顺手备一下"。
做法三:Python 的 backup API(推荐)
标准库自带,零依赖:
import sqlite3, os
def hot_backup(src, dst):
if not os.path.exists(src):
raise FileNotFoundError(src)
s = sqlite3.connect(src)
d = sqlite3.connect(dst)
try:
with d:
s.backup(d) # 在线备份,自动处理 WAL
finally:
s.close()
d.close()
三个细节值得说:
一是 s.backup(d) 会在源库上起一个读事务、页级加锁,不阻塞正常写入(WAL 模式下读不挡写),所以不用停服。
二是目标连接写出的是单文件、不依赖 WAL 的完整库——这一点比 cp 三个文件强得多,拷到别的机器直接能用。
三是备份后要跑一次 PRAGMA integrity_check,返回 ok 才算这份备份可信。
还有个变体:VACUUM INTO 'out.db'(SQLite 3.27+)。一条 SQL 搞定,还顺带压缩空闲页,代价是重建整个库,大库上比 backup API 慢。
三、备份之外别忘的四件事
权限。备份文件里有全量业务数据,落盘就 chmod 600。数据目录里通常还有 JWT 密钥这类单文件,一起备,否则恢复出来的服务谁也登不进去。
保留策略。只留最近 10 份,多的删掉。备份把磁盘写满,是比不备份更常见的故障。
校验而不是"看起来成功"。脚本返回 0 不等于备份可用。至少要 integrity_check 通过、文件大小非零、能 SELECT count(*) 出主表行数。
跨版本。SQLite 向后兼容读旧库,但新版本写的库旧版本可能打不开。升级前留的备份配旧二进制没问题,反过来"用新备份恢复到旧程序"要先确认。
四、现在的升级流程
备份(在线,不停服)→ 改权限并跑完整性检查 → 拉新镜像 → 重建容器 → 健康检查,失败就用备份回滚。
整个流程里,第一步是唯一能在出事之后把你捞回来的东西。它只花两秒,但必须是对的。cp 出来的那一份,往往在最需要它的时候才告诉你 malformed。
- 点赞
- 收藏
- 关注作者
评论(0)