用 Python 备份 SQLite 数据库:WAL 快照与独立恢复验证
批处理把进度保存在 SQLite 中,想备份时直接复制了 .sqlite 文件,恢复后却发现最近提交的记录不见了。WAL 模式下,新数据可能还在 -wal 文件里。本文用 Python 的备份 API 生成独立快照,再把它复制到全新目录查询,确认备份真的能读取。
适合初学者到中级读者,承接SQLite 批处理断点恢复。演示不接触真实数据库,也不需要第三方 Python 包。
1. 方法:通过 SQLite 连接备份
SQLite WAL 文档说明,WAL 文件属于数据库的持久状态;只复制主文件可能遗漏已提交数据。运行中的数据库也不适合逐个复制主文件和旁边的日志文件,因为复制期间状态可能变化。
使用 Python Connection.backup(),由 SQLite 读取数据库状态。本文只备份 main 数据库,不处理其他 ATTACH 数据库或外部结果文件。准备一个可信、可写的本地目录,文件系统需要支持硬链接。
流程是:备份到同目录下的临时目录 → 切换备份的日志模式 → 检查结构 → 关闭连接 → 创建最终文件名。源连接用 mode=ro 打开,不执行写入 SQL;目标已存在时拒绝覆盖。
2. 准备环境
需要 Python 3.10 或更新版本,且包含标准库 sqlite3。本文在 Linux、Python 3.11.4 上验证。先在新的工作目录下载 backup_sqlite.py、demo_wal.py 和 verify_backup.py,然后执行:
python3 --version
python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'
python3 backup_sqlite.py --help
第二条命令显示 Python 实际链接的 SQLite 版本。并发 WAL 应用应使用包含官方修复的构建;SQLite 的 WAL-reset 修复说明列出 3.51.3、3.50.7、3.44.6 等修复版本,发行版也可能回移补丁。本文演示不并发执行写入和检查点。
3. 完整备份脚本
backup_sqlite.py 的内容如下:
"""Back up one SQLite database without overwriting a destination. Python 3.10+."""
import argparse
from contextlib import closing
import os
from pathlib import Path
import sqlite3
import sys
import tempfile
def backup_database(source, destination):
source = Path(source).resolve(strict=True)
destination = Path(destination).absolute()
if not source.is_file():
raise ValueError("source must be a regular file")
if os.path.lexists(destination):
raise FileExistsError(f"destination already exists: {destination}")
if not destination.parent.is_dir():
raise ValueError("destination parent directory must exist")
# A sibling temporary directory keeps the hard link on one filesystem.
with tempfile.TemporaryDirectory(prefix=".sqlite-backup-", dir=destination.parent) as work:
temporary = Path(work) / "backup.sqlite"
with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True,
isolation_level=None)) as src:
with closing(sqlite3.connect(temporary, isolation_level=None)) as dst:
src.backup(dst, pages=256, sleep=0.1)
# Make the result a standalone file even when the source uses WAL.
mode = dst.execute("PRAGMA journal_mode=DELETE").fetchone()[0]
if mode.lower() != "delete":
raise ValueError("could not select DELETE journal mode")
checks = dst.execute("PRAGMA quick_check").fetchall()
if checks != [("ok",)]:
raise ValueError(f"quick_check failed: {checks}")
os.chmod(temporary, 0o600)
# Unlike replace(), link() fails if another process created the destination.
os.link(temporary, destination)
return destination
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
try:
destination = backup_database(args.source, args.destination)
except (OSError, ValueError, sqlite3.Error) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("INTERRUPTED", file=sys.stderr)
return 130
print(f"BACKUP OK: {destination}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
resolve(strict=True) 和只读 URI 防止把拼错的源路径当作新数据库。isolation_level=None 让这里的连接不自动开启隐式写事务,也兼容 Python 3.10/3.11。
os.link() 为完整临时文件创建最终名称;如果另一进程抢先创建了目标,它会失败而不会覆盖。临时目录和目标在同一文件系统,结束后临时名称被清理。最终备份权限为 0600,Linux 上仅文件所有者可读写。该流程没有做断电持久性测试。
4. 构造有真实 WAL 数据的示例
将下面代码保存为 demo_wal.py。它保留源连接,提交两条记录后启动备份子进程,备份完成才向源库加入第三条记录。
"""Keep committed rows in WAL while a separate backup process runs."""
from pathlib import Path
import sqlite3
import subprocess
import sys
folder = Path("sqlite-backup-demo")
folder.mkdir(exist_ok=True)
source = folder / "live.sqlite"
if source.exists():
raise SystemExit("Use a fresh demo directory")
con = sqlite3.connect(source, isolation_level=None)
try:
assert con.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal"
con.execute("PRAGMA wal_autocheckpoint=0")
con.execute("CREATE TABLE jobs (id INTEGER PRIMARY KEY, status TEXT NOT NULL)")
con.executemany("INSERT INTO jobs VALUES (?, ?)", [(1, "done"), (2, "done")])
assert Path(str(source) + "-wal").stat().st_size > 0
print("WAL has committed data", flush=True)
subprocess.run([sys.executable, "backup_sqlite.py", str(source),
str(folder / "snapshot.sqlite")], check=True)
con.execute("INSERT INTO jobs VALUES (?, ?)", (3, "later"))
print("LIVE rows=3; snapshot should still have 2", flush=True)
finally:
con.close()
wal_autocheckpoint=0 只用于演示,确保自动检查点不会提前清空这个小样本的 WAL。不要照搬到生产任务;生产环境需要自己的检查点策略。
5. 验证独立恢复和查询结果
将以下内容保存为 verify_backup.py。它只复制备份文件到新的临时目录,那里没有源库的 -wal 或 -shm 文件。
"""Read the backup in a fresh directory without the source or its WAL files."""
from contextlib import closing
from pathlib import Path
import shutil
import sqlite3
import tempfile
backup = Path("sqlite-backup-demo/snapshot.sqlite").resolve(strict=True)
with tempfile.TemporaryDirectory(prefix="sqlite-restore-") as work:
restored = Path(work) / "restored.sqlite"
shutil.copyfile(backup, restored)
with closing(sqlite3.connect(restored.as_uri() + "?mode=ro", uri=True,
isolation_level=None)) as con:
assert con.execute("PRAGMA quick_check").fetchall() == [("ok",)]
assert con.execute("PRAGMA journal_mode").fetchone()[0] == "delete"
rows = con.execute("SELECT id, status FROM jobs ORDER BY id").fetchall()
assert rows == [(1, "done"), (2, "done")], rows
print("RESTORE OK: [(1, 'done'), (2, 'done')]")
运行:
python3 demo_wal.py
python3 verify_backup.py
预期输出如下,绝对路径取决于工作目录:
WAL has committed data
BACKUP OK: /your/workdir/sqlite-backup-demo/snapshot.sqlite
LIVE rows=3; snapshot should still have 2
RESTORE OK: [(1, 'done'), (2, 'done')]
备份中应只有 ID 1、2,后写入源库的 ID 3 不会自动进入旧快照。PRAGMA quick_check用于快速结构检查,不检查所有索引一致性,也不验证业务正确性。需要更全面的结构检查时可用 integrity_check;实际恢复还要核对关键记录、行数和业务约束。
6. 验证拒绝覆盖,再用于自己的任务
- 对同一路径再次备份,确认非零退出码,同时旧备份仍可读取:
python3 backup_sqlite.py sqlite-backup-demo/live.sqlite sqlite-backup-demo/snapshot.sqlite
printf 'exit=%s\n' "$?"
python3 verify_backup.py
应看到 destination already exists、exit=1 和 RESTORE OK。不要用删除旧备份来绕过这个检查;选择新名称。
- 用时间戳生成新名称:
mkdir -p backups
python3 backup_sqlite.py sqlite-backup-demo/live.sqlite "backups/progress-$(date +%Y%m%d-%H%M%S).sqlite"
这是对演示源库生成另一个快照,其中已经有第三条记录;上面的两行断言只用于第一个演示快照。换成自己的数据库后,应修改验证查询。重复运行到同一秒仍可能发生名称冲突,此时脚本安全退出。
定时运行时,可参考固定 Python 路径和工作目录,使用绝对路径并检查退出码。备份保留数量、异地复制和恢复演练需要另行安排;同一块磁盘上的副本不能覆盖磁盘损坏的场景。
7. 常见错误与处理
| 现象 | 具体处理 |
|---|---|
| 源文件不存在 | 用 ls -l /实际路径/progress.sqlite 核对名称,脚本不会创建空源库。 |
destination parent directory must exist |
先运行 mkdir -p backups,再传入该目录中的新文件名。 |
Permission denied |
用 ls -ld backups 核对运行用户和目录权限;只读 WAL 连接也需要合适的旁文件或目录权限,见 SQLite 只读 WAL 条件。 |
Operation not supported |
换用支持硬链接的本地备份目录;脚本失败时不会发布最终文件名。 |
database is locked 或长时间不结束 |
避开长期事务或密集写入时段,先在演示库验证;sleep=0.1 是重试间隔,不是总超时。 |
No space left on device |
用 df -h backups 检查空间,按磁盘满排查教程恢复可用空间,再生成新备份。 |
SQLite 备份 API 说明指出,频繁写入可能使备份反复重启。大库应安排合适的运行窗口;本例不承诺从调用开始那一刻截取快照,也不设置整个备份过程的截止时间。
8. 小结
通过 SQLite 连接生成备份,检查完成后再发布新文件名,可以避免误覆盖已有副本。先用有 WAL 数据的演示库验证,再把独立恢复查询换成自己的关键业务检查。保留旧快照并定期演练恢复,备份才有实际用途。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-sqlite-backup-restore.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。