qinghuan
← 回到知识探索

后端现场 / 可下载小项目

数据库文件已经复制好了,为什么还是少了已提交的订单?

亲手造出一份能正常打开、完整性检查也通过,却缺少一条订单的副本;再用 SQLite 备份 API 恢复同一时刻的已提交记录。

展开本文目录
  1. 下载完整练习包
  2. 提交成功,修改仍可能主要保存在 WAL 文件里
  3. 先把错误结果做成可重复的条件
  4. 让数据库负责读出完整状态,再重新打开目的库
  5. 结构检查通过,仍要检查业务数据的新旧
  6. 运行完整脚本,对照两份副本读出的编号
  7. 带回项目时,先明确要恢复的范围
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
  • 能读懂 INSERT、SELECT 与提交的基本含义。
  • 能运行 Python 脚本;不需要安装数据库服务。
  • 需要补基础时:先分清提交与读取可见性
遇到不熟悉的词,先看这 1 个解释
预写日志(WAL)
SQLite 的 WAL 模式先把新的修改追加到单独的日志文件,之后再通过检查点把相应内容写回主数据库文件。因此一次提交完成时,最新内容可能仍在日志中。
打开完整术语小抄 ↗

桌面工具把订单写在 live.db 中。你看到新增订单已提交,就复制这个文件作为备份。副本能打开,检查结果也是 ok,可那条订单不见了。问题可能出在“一个数据库”与“一个文件”并不总是同一范围。

这次先固定一份只有两条订单的 SQLite 数据库,故意只复制主文件,再用连接提供的 backup 方法备份。两份副本都会重新打开读取,观察记录本身,而不是仅确认文件存在。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压到独立练习文件夹,在含 demo.py 的目录打开终端运行。SQLite临时WAL库的主文件遗漏、backup恢复与11项顺序检查;未测试并发在线备份与生产恢复

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

提交成功,修改仍可能主要保存在 WAL 文件里

WAL 可以理解为数据库旁边的修改记录。启用这种模式后,新提交的页能够先追加到 -wal 文件,读数据库时由 SQLite 合并主文件与合适的日志内容。提交是数据库对事务状态的确认,并不要求立刻把每项修改搬回主文件。

把日志里已经提交的修改转移回主文件叫 checkpoint。它与提交是两个步骤。若复制工具只拿走 live.db,就可能拿到一个结构完好的较早状态;这个状态既没有报错,也没有包含你打算保护的全部记录。

本实验故意让主文件先包含订单一,再把订单二留在 WAL 中。过程中没有其他写入线程,也没有在复制时制造随机竞争,所以缺失来自遗漏日志这一个原因。实际运行中一边写一边裸拷贝还可能出现别的问题,不能把本例当作裸拷贝操作指南。

先把错误结果做成可重复的条件

脚本使用临时目录和显式自动提交模式。启用 WAL 后,先关闭自动 checkpoint,再创建表并插入编号一。接着手工运行一次 wal_checkpoint(TRUNCATE),检查初始 checkpoint 已完成,最后插入编号二;这时编号二已经提交,但读取它仍需要 WAL。

这里关闭自动 checkpoint 只是让这个小实验稳定出现差异。不要为了“方便备份”把业务库也改成这个设置:长期写入下 WAL 的维护方式需要按真实读写负载设计。本脚本结束后整个临时目录都会清理。

copyfile 只复制 live.db 到 raw-copy.db。我们刻意在源连接仍打开时完成这步;如果先正常关闭最后一个连接,SQLite 可能已完成相应 checkpoint,错误路径就不再展示我们要研究的遗漏。

让数据库负责读出完整状态,再重新打开目的库

live.backup(destination) 通过 SQLite 连接复制数据库状态,源端需要的 WAL 内容由 SQLite 自己读取。目的连接关闭后,脚本重新连接 api-backup.db,确认编号一和二都能查询到;这比检查返回值或备份文件大小更接近一次最小恢复演练。

源库随后新增编号三,备份仍只有一和二。备份是一份完成时形成的独立副本,不会自动跟随源库。应用要记录备份批次、完成状态与业务检查点,才能回答“这一份到底保护到了哪里”。

本例的源数据在备份调用期间没有继续变化,所以预期内容没有歧义。真实在线备份还要处理锁等待、持续写入、目标磁盘空间与失败重试;官方 API 支持在线场景,不等于任意负载下都能立即结束。

结构检查通过,仍要检查业务数据的新旧

两份副本的 integrity_check 都返回 ok,但裸拷贝只有编号一。这个对照把两类问题分开了:文件结构是否符合数据库要求,以及它是否包含本次备份承诺的那条订单。一个检查不能替另一项作答。

实际项目可选取稳定的业务证据,例如指定批次的记录编号、已提交流水的边界、关键表之间的关系。只比较总行数也不充分:少了一行又多了另一行,总数仍然可能相同。示例因此直接比较按主键排序的编号列表。

检查结果要与恢复对象一起保存。生产恢复还涉及访问控制、加密密钥、文件权限和外部附件等内容;本文只验证这一个本地 SQLite 数据库的记录,不声称已经覆盖整套系统的恢复。

运行完整脚本,对照两份副本读出的编号

将下面代码保存为 demo.py,执行 python -B demo.py。程序只创建自己的临时数据库,不读取当前目录中的其他 .db 文件,也不联网。11 项检查中,既要求错误路径确实遗漏编号二,也要求正确路径恢复它。

本次制作实际使用 Python 3.14.0 与 SQLite 3.50.4。最后一行的清理完成标记只会在所有连接关闭、临时目录退出后打印;如果中途发生异常,应先保留错误信息,检查本机 SQLite 是否支持 WAL 和临时目录是否可写。

展开收起代码 · 小实验python · 70 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""A quiescent WAL database: a file copy can be valid yet stale.

Only temporary demonstration databases are created. No business files are read.
"""
from contextlib import closing
from pathlib import Path
from shutil import copyfile
from tempfile import TemporaryDirectory
import sqlite3


def ids(connection):
    return [row[0] for row in connection.execute("SELECT id FROM orders ORDER BY id")]


def main():
    checks = 0

    def check(condition, message):
        nonlocal checks
        if not condition:
            raise AssertionError(message)
        checks += 1

    with TemporaryDirectory(prefix="qinghuan-wal-") as folder:
        root = Path(folder)
        source = root / "live.db"
        with closing(sqlite3.connect(source, isolation_level=None)) as live:
            mode = live.execute("PRAGMA journal_mode=WAL").fetchone()[0]
            check(mode == "wal", "WAL mode was not enabled")
            live.execute("PRAGMA wal_autocheckpoint=0")
            live.execute("CREATE TABLE orders(id INTEGER PRIMARY KEY, item TEXT NOT NULL)")
            live.execute("INSERT INTO orders VALUES(1, 'tea')")
            checkpoint = live.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
            check(checkpoint == (0, 0, 0), "initial checkpoint must finish")
            live.execute("INSERT INTO orders VALUES(2, 'book')")
            check(not live.in_transaction, "second row must already be committed")
            check(ids(live) == [1, 2], "live database must include both orders")
            check(Path(str(source) + "-wal").stat().st_size > 0, "WAL must contain data")

            # Deliberately wrong backup: no writes occur during this copy, but
            # committed pages in the WAL are still omitted.
            copyfile(source, root / "raw-copy.db")
            with closing(sqlite3.connect(root / "raw-copy.db")) as raw:
                raw_ids = ids(raw)
                raw_integrity = raw.execute("PRAGMA integrity_check").fetchone()[0]
            check(raw_ids == [1], "raw copy should miss the WAL-only committed row")
            check(raw_integrity == "ok", "stale data can still pass integrity_check")

            with closing(sqlite3.connect(root / "api-backup.db")) as destination:
                live.backup(destination)
            # Reopen the destination: the following checks read restored data.
            with closing(sqlite3.connect(root / "api-backup.db")) as restored:
                restored_ids = ids(restored)
                restored_integrity = restored.execute("PRAGMA integrity_check").fetchone()[0]
                check(restored_ids == [1, 2], "backup must include the committed WAL row")
                check(restored_integrity == "ok", "backup must be structurally valid")
                live.execute("INSERT INTO orders VALUES(3, 'pen')")
                check(ids(live) == [1, 2, 3], "source should advance after backup")
                check(ids(restored) == [1, 2], "a backup does not follow later changes")

            print("live_before_backup=[1, 2]")
            print(f"raw_copy={raw_ids}; integrity={raw_integrity}")
            print(f"api_backup={restored_ids}; integrity={restored_integrity}")
            print("live_after_insert=[1, 2, 3]; backup_still=[1, 2]")
    print(f"PASS checks={checks}; temporary_files_cleaned=True")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中执行 python -B demo.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。SQLite临时WAL库的主文件遗漏、backup恢复与11项顺序检查;未测试并发在线备份与生产恢复

运行后对照结果
live_before_backup=[1, 2]
raw_copy=[1]; integrity=ok
api_backup=[1, 2]; integrity=ok
live_after_insert=[1, 2, 3]; backup_still=[1, 2]
PASS checks=11; temporary_files_cleaned=True

带回项目时,先明确要恢复的范围

如果数据库正在被应用使用,优先选择该数据库提供的备份方式,让它处理一致性边界。不要仅凭“文件复制命令返回零”宣布备份成功,也不要把不同时间分别复制的 db 与 wal 随意拼到一起。

备份完成后,另外打开副本做结构检查和业务抽查,并记录完成时间及验证结果。验证需要在副本上进行,避免把一次检查误变成对生产文件的操作。本文的临时文件设计正是为了把这个步骤练熟。

记住这三点

  • WAL 模式下,已提交修改可能仍由主文件和 WAL 共同表达。
  • 文件存在与 integrity_check 通过,都不证明备份包含期望的业务记录。
  • 通过备份 API 生成副本后,重新打开并核对业务证据。

别把例子用过头

  • 复制前先关闭源连接,再据此断言在线裸拷贝总是安全。
  • 把 checkpoint 当成每次提交都已完成的步骤。
  • 看到 ok 就跳过恢复后的记录核对。
  • 把本实验关闭自动 checkpoint 的设置照搬到生产。

自己试一小步

给备份加入一张订单明细表,怎样证明恢复后不仅订单在,明细也属于同一个备份批次?

  1. 先写出两张表在备份前应有哪些编号和对应关系。
  2. 新增一条订单与两条明细,保证它们在一个事务中提交。
  3. 分别用裸文件复制和 backup 方法生成副本,再重新打开读取。
  4. 自己选择至少两项能够区分“结构完整”与“业务完整”的检查,保存运行记录。

做到这些,就算完成

  • 检查明确写出编号和关联关系,不能只比较文件大小。
  • 说明备份后新增记录是否应该出现在旧副本中。
  • 程序只操作自行创建的临时数据,异常时也关闭连接。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • SQLite:WAL 的提交与 checkpoint (新标签页)↗SQLite · 英文

    解释提交可先记录于 WAL,checkpoint 再把页写回主文件。

    2026-10-17 已实际打开。核对 How WAL Works、Checkpointing 和 The WAL File,未把单一文件等同于完整在线数据库。

  • SQLite:Online Backup API (新标签页)↗SQLite · 英文

    支持通过数据库 API 复制一致副本,以及持续写入可能使备份重启的边界。

    2026-10-17 已实际打开。核对简介和在线备份示例;本文没有移植其中的 C 代码。

  • Python 3.14:Connection.backup (新标签页)↗Python Software Foundation · 英文

    核对 Python 连接备份接口及连接生命周期。

    2026-10-17 已实际打开。实际例子使用 Connection.backup,并显式关闭源和目标连接。

  • SQLite:integrity_check (新标签页)↗SQLite · 英文

    界定完整性检查关注的数据库结构问题。

    2026-10-17 已实际打开。核对 integrity_check 的检查范围;业务新鲜度检查由本文另行设计。