原创

Python SQLite 在线备份完整实验:WAL、一致快照与恢复校验

SQLite 开着 WAL,程序提交了新数据,复制出来的 .db 文件却查不到刚建的表。这个现象并不矛盾:提交可能已经进入 WAL,主文件尚未包含这些页面。只复制文件名最像数据库的那一个,并没有复制当时的数据库状态。

在线备份要交给理解数据库页与事务的接口。Python 标准库的 Connection.backup() 可以完成这件事;但一份能用来恢复的备份,还需要明确输出文件何时可见、失败后留下什么、怎样拒绝覆盖旧备份,以及恢复端究竟核对哪些内容。

这次做一个有限范围的工具:备份单个 SQLite 的 main 库,生成新的本地文件;目标名称已经存在就拒绝。示例使用 Python 3.12,文件发布使用同目录硬链接,适用于支持硬链接的本地文件系统。它不承担远程上传、加密或备份保留策略。两个脚本放在同一目录即可运行,没有第三方 Python 依赖。

先让不完整的文件没有正式名字

直接把正式备份名交给 sqlite3.connect(),程序还没备份完,那个文件就已经存在。另一个任务若按文件名扫描,可能提前拿走尚未完成的内容。把“文件存在”当成“备份成功”,是比复制方法更容易漏掉的问题。

工具先在目标目录创建随机临时文件,备份完成并通过结构检查以后,关闭连接,再给它建立正式名称。同目录让发布动作落在同一文件系统;os.link() 的创建动作会拒绝已存在的目标,避免“先判断不存在,再覆盖写入”的竞争窗口。最后删除临时名称,两个名称曾短暂指向同一个已完成文件。

保存为 safe_backup.py:

from contextlib import closing
from pathlib import Path
import math
import os
import sqlite3
import tempfile
import time


def backup_database(source, destination, seconds=10, on_progress=None):
    if not math.isfinite(seconds) or seconds < 0:
        raise ValueError('seconds must be finite and non-negative')
    source = Path(source).expanduser().resolve(strict=True)
    destination = Path(destination).expanduser().absolute()
    if destination.exists():
        raise FileExistsError(destination)
    if not destination.parent.is_dir():
        raise FileNotFoundError(destination.parent)
    deadline = time.monotonic() + seconds
    fd, temporary = tempfile.mkstemp(prefix='.backup-', suffix='.db',
                                     dir=destination.parent)
    os.close(fd)
    temporary = Path(temporary)

    def progress(status, remaining, total):
        if time.monotonic() >= deadline:
            raise TimeoutError('backup exceeded its cooperative time budget')
        if on_progress is not None:
            on_progress(status, remaining, total)
        if time.monotonic() >= deadline:
            raise TimeoutError('backup exceeded its cooperative time budget')

    try:
        with closing(sqlite3.connect(source.as_uri() + '?mode=ro', uri=True,
                                     timeout=0.2, autocommit=True)) as src:
            with closing(sqlite3.connect(temporary, autocommit=True)) as dst:
                src.backup(dst, pages=16, sleep=0.05, progress=progress)
                if dst.execute('PRAGMA quick_check').fetchall() != [('ok',)]:
                    raise RuntimeError('backup structure check failed')
                if dst.execute('PRAGMA foreign_key_check').fetchall():
                    raise RuntimeError('backup contains foreign key violations')
        # The temporary file is closed; publish only a completed database.
        # Same-directory hard link fails if the final name already exists.
        os.link(temporary, destination)
    finally:
        temporary.unlink(missing_ok=True)
    return destination


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('source')
    parser.add_argument('destination')
    parser.add_argument('--seconds', type=float, default=10)
    args = parser.parse_args()
    if not math.isfinite(args.seconds) or args.seconds <= 0:
        parser.error('--seconds must be finite and positive')
    print(backup_database(args.source, args.destination, args.seconds))

这里特意没有使用 with connection: 代替关闭连接。sqlite3 的连接上下文管理器主要处理事务结束,不负责替调用者关闭连接;closing() 才表达本工具需要的资源所有权。目标连接不与其他工作共享,源连接则是专门为备份打开的只读连接。

源路径先要求真实存在,再用 URI 的 mode=ro 打开。路径写错会明确失败,不会悄悄新建一份空库,然后把空库当成“备份成功”。目标名字已经存在时也明确失败,包括误把源文件本身当目标的情况。

autocommit=True 是这里针对 Python 3.12 的明确选择:工具不需要 Python 自动维持业务事务,备份接口自行组织所需的数据库动作。它不是给所有 SQLite 应用推荐同一个事务模式,更不能拿来修改原业务连接的设置。

用可控的 WAL 内容检验恢复结果

只看到“生成了 verified.db”,证据还不够。实验在源连接保持打开时关闭自动 checkpoint,建表并写入数据,让单独复制主文件的不足能够出现。然后在备份的进度回调中,通过另一个连接提交一次转账,观察生成的数据库是否仍满足业务不变量。

这里有两张完整表:wallet 的余额使用整数作为实验单位,attachment 引用钱包并存储用于增加页面数量的载荷。它们没有任何生产数据。300 条附件只为让 pages=16 有机会产生多个步骤,不用于声称备份吞吐。

保存为 demo.py,与前一个文件放在一起:

from contextlib import closing
from pathlib import Path
import shutil
import sqlite3
import tempfile
from safe_backup import backup_database


def main():
    with tempfile.TemporaryDirectory() as folder:
        root = Path(folder)
        source = root / 'live.db'
        with closing(sqlite3.connect(source, autocommit=True)) as writer:
            writer.execute('PRAGMA journal_mode=WAL')
            writer.execute('PRAGMA wal_autocheckpoint=0')
            writer.executescript('''
                CREATE TABLE wallet (
                    id INTEGER PRIMARY KEY,
                    balance INTEGER NOT NULL CHECK(balance >= 0)
                );
                CREATE TABLE attachment (
                    id INTEGER PRIMARY KEY,
                    wallet_id INTEGER NOT NULL REFERENCES wallet(id),
                    payload BLOB NOT NULL
                );
                INSERT INTO wallet VALUES (1, 100), (2, 100);
            ''')
            writer.executemany(
                'INSERT INTO attachment(wallet_id,payload) VALUES (?,?)',
                [(1, b'x' * 2048) for _ in range(300)])
            unsafe = root / 'unsafe-copy.db'
            shutil.copyfile(source, unsafe)
            with closing(sqlite3.connect(unsafe, autocommit=True)) as raw:
                try:
                    raw.execute('SELECT COUNT(*) FROM wallet').fetchone()
                except sqlite3.OperationalError:
                    print('raw main-file copy: wallet table unavailable')
                else:
                    print('raw copy happened to contain wallet; not a consistency proof')
            changed = False
            def change_once(status, remaining, total):
                nonlocal changed
                if not changed and remaining > 0:
                    writer.execute('BEGIN IMMEDIATE')
                    writer.execute('UPDATE wallet SET balance=90 WHERE id=1')
                    writer.execute('UPDATE wallet SET balance=110 WHERE id=2')
                    writer.execute('COMMIT')
                    changed = True
            target = root / 'verified.db'
            backup_database(source, target, on_progress=change_once)
            assert changed
            with closing(sqlite3.connect(target, autocommit=True)) as restored:
                rows = restored.execute('SELECT id,balance FROM wallet ORDER BY id').fetchall()
                assert rows == [(1, 90), (2, 110)]
                assert restored.execute('SELECT COUNT(*) FROM attachment').fetchone()[0] == 300
                print('restored:', rows, 'attachments=300')
            try:
                backup_database(source, target)
            except FileExistsError:
                print('existing target: refused')
            else:
                raise AssertionError('target overwritten')
            try:
                backup_database(source, root / 'missing.db', seconds=0)
            except TimeoutError:
                assert not (root / 'missing.db').exists()
                assert not list(root.glob('.backup-*'))
                print('expired budget: no published file or temporary file')
            else:
                raise AssertionError('expired backup succeeded')
            try:
                backup_database(root / 'not-there.db', root / 'none.db')
            except FileNotFoundError:
                assert not (root / 'not-there.db').exists()
                print('missing source: refused without creating a database')
            else:
                raise AssertionError('missing source created')

if __name__ == '__main__':
    print('SQLite', sqlite3.sqlite_version)
    main()

运行:

python3 --version
python3 demo.py

在 Python 3.12.14、SQLite 3.53.1 的这次运行中,输出为:

SQLite 3.53.1
raw main-file copy: wallet table unavailable
restored: [(1, 90), (2, 110)] attachments=300
existing target: refused
expired budget: no published file or temporary file
missing source: refused without creating a database

SQLite 的运行库版本由 Python 实际链接的库决定,不能只从 Python 的小版本号推断。实验打印它,方便复现时核对。主文件复制的失败形态也不该写成所有库必然“没有表”;如果之前发生过 checkpoint,复制件可能含有较旧的数据。程序保留了这个分支,提醒读者:碰巧能查询,不代表复制过程具备一致性保证。

这个例子没有同时复制 .db、-wal、-shm 来补救。对仍在变化的三个文件依次运行复制命令,也没有自动建立共同的读取时刻。此处的备份接口读取数据库的逻辑状态,生成的目标库关闭后再按完整文件交付,才是工具选择的协议。

一致快照不等于锁定函数入口那一刻

增量备份允许其他连接继续访问源库,但不是在任意写入压力下都能保证准时完成。源数据在步骤之间变化时,备份过程可能重新开始。实验只提交一次转账,使它能继续结束;如果持续修改导致反复重启,任务可能长期没有结束条件。

因此进度中的 remaining 与 total 适合描述当前步骤,不适合作为永不回退的业务进度。不要因为百分比从 80% 退回 20%,就直接认定数据库损坏,也不要用首次总页数算出一个必然准确的结束时间。

本实验故意在一个尚未完成的回调里提交转账,最终恢复到 90 与 110。它证明这次运行生成了可查询、总额正确并包含该次修改的结果;不能由此承诺所有备份都包含“最后一条实时写入”。业务要求精确的截点时,需要另外确定事务和写入协调协议,并把该截点与备份元数据一起记录。

备份调用的方向也要保持清楚:src.backup(dst) 把源复制到目标,目标原内容会被替换。本工具只为新临时文件建立目标连接,所以不会误把已经发布的历史备份交给覆盖型接口。

时间预算为什么叫协作预算

进度回调在备份步骤之间、用户回调返回后检查单调时钟,超预算就抛出异常。这里约束的是 backup 阶段;随后 quick_check、foreign_key_check 与文件发布不在这份回调预算内。它能停止反复等待或反复复制,却不是操作系统级的强制终止。一次底层读写、锁等待或文件系统调用已经花掉的时间,不能倒退回来。

代码把源连接的等待参数和重试间隔也设得较短,让回调有机会继续检查。pages 控制每次处理的页面数,不是总页数上限;sleep 也不能被当成整个备份最多等待多久。对每一步都要求严格截止的系统,应把备份放在独立的进程生命周期中管理,退出后再核对临时产物,而不是让进度函数宣称自己能中断所有 I/O。

实验中的 seconds=0 直接调用用于确定地触发过期分支;命令行入口则拒绝非正预算与NaN、Infinity等非有限数,函数入口同样拒绝非有限数和负数,避免时钟比较失去意义。测试检查了两件事:正式文件没有发布,随机临时文件也没有残留。异常不能只打印一行然后继续输出正式文件名,否则上游会把失败当成成功。

这份工具没有自动重试整个备份。文件系统报错、校验不通过与预算不足,需要不同处理;尤其正式名称已经存在时,应先判断它来自哪次任务,而不是删除后重跑。创建正式硬链接与程序报告成功之间仍可能发生进程退出,恢复流程要允许“结果存在,但调用方没收到确认”,通过检查已有文件决定后续动作。

硬链接提供的是完整文件的名称发布,不是对断电持久性的全面承诺。磁盘缓存、文件系统和目录同步策略仍属于部署环境。若备份需要跨设备容灾,本地目录里多一个名称也不能替代独立存储副本。

恢复演练要继续走到业务检查

quick_check 检查数据库的部分内部结构;foreign_key_check 检查外键关系。两者都不能证明应用数据满足所有业务约定。因此实验又查询钱包的精确余额与附件数量,不只检查 SQL 能不能执行。

使用工具备份自己的隔离样本时,可以执行:

python3 safe_backup.py ./sample.db ./sample-backup.db --seconds 15

恢复验证用另一个连接打开备份,查询表定义、对象集合与业务不变量,不要为了“验证恢复”直接覆盖正在运行的源库。源库此刻仍在继续写时,拿两个库的当前 COUNT 硬比较,也没有共同时间语义。

一个 main 库的备份不自动包含其他 ATTACH 数据库、上传目录里的文件或应用配置。若业务对象横跨这些位置,需要定义组合恢复点。代码中的函数名虽然叫 backup_database,它的能力仍由明确的单库范围决定。

真正值得保留的是那条交付边界:读取由数据库接口完成,临时内容先校验,正式名称只代表已完成的产物,恢复端再核对业务含义。只有失败文件与成功备份在流程上分得清,定期产生一堆 .db 文件才有可能变成可用的恢复能力。

正文到此结束
评论插件初始化中...
Loading...