备份文件已经生成,打开也没报错,最近新增的记录却不见了。
如果你的 Python 小工具、桌面应用或个人项目用了 SQLite,先看看备份脚本是不是只有一行文件复制。数据库仍在运行时,尤其开启 WAL 模式后,这一步可能没有拿到完整的数据状态。
更容易误判的是:这种副本甚至可能通过数据库结构检查。
1. 已经提交的数据,不一定都在 .db 里
WAL 是 SQLite 的一种日志模式。发生修改时,新内容可以先写入 notes.db-wal;后续通过 checkpoint,也就是检查点操作,再写回 notes.db 主文件。
因此,程序执行了 commit(),不意味着相关内容已经全部写回主文件。SQLite 官方也明确提醒:将数据库与对应 WAL 分离,可能丢失已提交的事务,甚至造成损坏。SQLite WAL 文档

为演示这个问题,配套实验先写入第一条记录并完成 checkpoint,再提交第二条记录,让它留在 WAL 中,保持数据库连接打开。实际运行结果如下:
| 检查对象 | 查询到的记录数 | quick_check 结果 |
|---|---|---|
| 源数据库 | 2 | ok |
| 只复制 .db 的副本 | 1 | ok |
| 使用 backup() 的副本 | 2 | ok |
这里故意控制了写入顺序,方便复现;不是说每次复制都会刚好少一条。
也别把处理方式改成"依次复制 .db、-wal、-shm"。应用还在写入时,几个文件的复制时点仍可能不同。对于运行中的数据库,优先使用 SQLite 提供的备份接口。
2. 用 Python 标准库完成备份
Python 的 sqlite3.Connection.backup() 支持在数据库被其他客户端访问时创建备份,不需要额外安装第三方包。Python sqlite3 文档
下面是一份完整脚本,适用于 Python 3.10 及以上。保存为 backup_sqlite.py:
python
import argparse
import sqlite3
import tempfile
import time
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path
def backup_sqlite(source, output_dir):
source = Path(source).resolve(strict=True)
if not source.is_file() or source.stat().st_size == 0:
raise ValueError("源文件必须是已有的非空 SQLite 数据库")
output_dir = Path(output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
prefix = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ-")
folder = Path(tempfile.mkdtemp(prefix=prefix, dir=output_dir))
partial = folder / "backup.partial"
deadline = time.monotonic() + 60
def progress(status, remaining, total):
if status != sqlite3.SQLITE_DONE and time.monotonic() > deadline:
raise TimeoutError("备份等待过久,请在低峰期重试")
# mode=ro 避免路径写错时自动新建空源库。
with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src:
with closing(sqlite3.connect(partial)) as dst:
src.backup(dst, pages=256, progress=progress, sleep=0.1)
# 只调整备份副本,便于作为独立文件保存。
mode = dst.execute("PRAGMA journal_mode=DELETE").fetchone()[0]
if mode != "delete":
raise RuntimeError("无法将备份副本切换为独立文件模式")
# 关闭后重新打开副本,检查实际写出的文件。
with closing(sqlite3.connect(partial.as_uri() + "?mode=ro", uri=True)) as check:
if check.execute("PRAGMA quick_check").fetchall() != [("ok",)]:
raise RuntimeError("副本结构检查失败")
result = folder / "backup.db"
partial.rename(result)
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="备份 SQLite,不覆盖已有备份")
parser.add_argument("source", help="源数据库路径")
parser.add_argument("output_dir", help="备份保存目录")
args = parser.parse_args()
try:
result = backup_sqlite(args.source, args.output_dir)
except (OSError, sqlite3.Error, ValueError, RuntimeError) as exc:
parser.exit(1, f"备份失败:{exc}\n")
print(f"备份完成:{result}")
在 Windows PowerShell 中,切换到脚本所在目录,再执行;把源路径换成你已有的数据库:
py .\backup_sqlite.py ".\data\notes.db" ".\backups"
没有 py 命令、但已经配置好 Python 时,可以把它换成 python。macOS/Linux 使用:
python3 ./backup_sqlite.py ./data/notes.db ./backups
成功后,终端会显示实际副本路径。每次都创建带 UTC 时间和随机后缀的新目录,旧备份保留。
这段代码还有两个容易忽略的细节:源库按 mode=ro 打开;副本先使用 .partial 文件名,关闭连接、重新打开并完成检查后,才改名为 backup.db。失败留下的 .partial 文件不能当成有效备份。
with sqlite3.connect(...) 本身不负责关闭连接,所以这里使用 contextlib.closing(),避免文件仍被占用时就尝试改名。Python 连接上下文管理说明
3. 为什么检查结果是 ok,仍然要看业务数据?
前面的实验已经给出了答案:旧副本可以在结构上完全正常,同时缺少最新记录。
quick_check 做的是结构检查,而且没有覆盖唯一约束及索引内容与表内容的一致性。更完整的结构检查可以使用 integrity_check;外键还需另外检查。它们都不能替你判断"某笔订单或某条笔记本来应该存在"。SQLite PRAGMA 文档
我更建议定期把副本放进隔离的测试环境:打开关键页面,核对几条已知记录,再试一次常用查询。涉及近期数据,要明确这份备份对应的时间范围,不能拿备份后新增的记录要求它补齐。

配套代码里的 demo_wal.py 可以复现前面的表格:
py .\demo_wal.py
它只在临时目录创建模拟数据库,结束后自动清理,不读取你的真实业务库。demo_wal.py 和 backup_sqlite.py 需要放在同一目录。
4. 这份脚本适合用到哪里?
适合本机磁盘上的普通 SQLite 数据库,例如个人项目、小工具和桌面应用的数据文件。它备份的是当前连接的主数据库;应用附件、配置文件,以及额外附加的数据库需要分别规划。
在线备份也有成本。其他连接持续高频写入时,复制过程可能重启,严重时长时间无法结束。上面的脚本会在复制进度回调中检查 60 秒阈值;这是分步检查,不是覆盖磁盘阻塞和后续校验的整个脚本硬超时。正式安排定时任务时,还要配置任务超时和失败告警。SQLite 在线备份说明
源库使用 WAL 且权限受限时,还需要能够读取相关文件,或具备 SQLite 正常创建辅助文件所需的目录权限。遇到打不开的问题,应检查权限,不要删除 WAL,也不要把仍在变化的数据库标成 immutable 来强行读取。
另外,同一块硬盘上的备份只能应对部分误操作,防不了硬盘损坏。确认副本可用后,再保存到另一存储位置,并妥善控制访问权限。
我会把"备份完成"的判断放在最后:拿到一致的数据库副本,再确认它能恢复需要的数据。 文件复制进度走到 100%,只能证明复制操作结束了。