物理主机 SQLite 在线备份:WAL 一致性与恢复验收

物理主机 SQLite 在线备份:WAL 一致性与恢复验收

备份任务显示成功,恢复后却少了最近一批记录,这类故障在使用 SQLite WAL 模式的文件服务中尤其容易被误判。文件复制返回成功,只能证明字节搬运结束,不能证明数据库处于一致状态。对持续记录文件索引、任务状态或备份清单的物理主机,应把“生成副本”和“证明副本能用”拆成两个步骤。

1. 先明确 WAL 与备份的边界

WAL 模式下,提交后的修改可能先保存在 -wal 文件,随后才通过检查点写回数据库主文件。业务仍在运行时执行 cp service.db backup.db,可能遗漏这些修改;按顺序复制主文件、WAL 和共享内存文件,也不等于取得同一时点的快照。

在线备份接口通过数据库连接读取一致内容,而不是盲目复制正在变化的文件。它适合业务不能停写的场景,但仍会产生读取与目标写入开销。能够关闭全部连接的离线备份更简单,不过停写必须覆盖后台任务,不能只停止前台请求。

WAL 不是异地备份,镜像也不是备份。可靠副本需要脱离源数据库的故障范围,并经过独立读取验证。

OnuCloud 物理主机标配两块 1TB NVMe,各自直连、没有硬件 RAID。把副本写到另一块盘可以分离部分读写负载,但两块盘仍在同一台机器内,不能代替异地保存。

2. 建立可重复的持续写入样本

以下实验适用于 Debian 12 等具备 Python 3 与 sqlite3 标准库模块的环境。先检查模块,创建独立临时目录;全部操作仅针对实验文件,不接触生产数据库,也不需要修改系统服务。

python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'
umask 077
export LAB_DIR="$(mktemp -d)"
printf '%s\n' "$LAB_DIR"

样本采用两张表:events 保存明细,totals 保存累计值。每次事务同时增加一条明细和一份累计值,因此副本必须满足“明细金额之和等于累计值”。这个条件比检查文件存在更有意义,也能说明结构完整与业务正确是两种验收。

下方脚本自带写入线程与备份过程。目录由前一步传入,每次复验应重新创建目录,避免与旧样本混用。生产数据库通常更复杂,应另行定义跨表关系和应用版本要求,不能直接套用这个计数模型。

3. 用在线接口生成并检查副本

脚本先完成一次提交,再让写入线程继续运行,同时执行在线备份。目标先使用临时文件,只有完整性检查、外键检查和业务不变量全部通过,才改名为正式实验副本。任一步异常都会使脚本失败,不发布新副本。

python3 - <<'PY'
import os
import sqlite3
import threading
import time
from pathlib import Path

base = Path(os.environ["LAB_DIR"])
source = base / "source.db"
pending = base / "backup.pending.db"
published = base / "backup.db"

db = sqlite3.connect(source)
mode = db.execute("PRAGMA journal_mode=WAL").fetchone()[0]
if mode.lower() != "wal":
    raise RuntimeError("WAL mode unavailable")
db.executescript("""
CREATE TABLE events (
    n INTEGER PRIMARY KEY,
    amount INTEGER NOT NULL
);
CREATE TABLE totals (
    id INTEGER PRIMARY KEY CHECK (id = 1),
    balance INTEGER NOT NULL
);
INSERT INTO totals VALUES (1, 0);
""")
db.close()

stop = threading.Event()
ready = threading.Event()
errors = []

def writer():
    conn = None
    try:
        conn = sqlite3.connect(source, timeout=5)
        while not stop.is_set():
            with conn:
                conn.execute("INSERT INTO events(amount) VALUES (1)")
                conn.execute(
                    "UPDATE totals SET balance = balance + 1 WHERE id = 1"
                )
            ready.set()
            time.sleep(0.01)
    except Exception as exc:
        errors.append(exc)
        ready.set()
    finally:
        if conn is not None:
            conn.close()

worker = threading.Thread(target=writer)
worker.start()
try:
    if not ready.wait(5):
        raise RuntimeError("Writer did not become ready")
    if errors:
        raise RuntimeError("Writer failed") from errors[0]
    src = sqlite3.connect(source, timeout=5)
    dst = sqlite3.connect(pending, timeout=5)
    try:
        src.backup(dst, pages=128, sleep=0.01)
    finally:
        dst.close()
        src.close()
finally:
    stop.set()
    worker.join()

if errors:
    raise RuntimeError("Writer failed") from errors[0]

check = sqlite3.connect(pending)
try:
    result = check.execute("PRAGMA integrity_check").fetchall()
    if result != [("ok",)]:
        raise RuntimeError("Integrity check failed")
    if check.execute("PRAGMA foreign_key_check").fetchall():
        raise RuntimeError("Foreign key check failed")
    count, amount = check.execute(
        "SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM events"
    ).fetchone()
    balance = check.execute(
        "SELECT balance FROM totals WHERE id = 1"
    ).fetchone()[0]
    if not (count > 0 and count == amount == balance):
        raise RuntimeError("Business invariant failed")
finally:
    check.close()

os.replace(pending, published)
print(f"Verified rows: {count}")
print(published)
PY

pages=128 指每轮处理的数据库页数,不是字节数;sleep 主要用于忙碌重试,并不是固定吞吐限速器。生产运行应记录耗时和失败原因,设置任务级截止时间。持续高写入可能拖长备份,超时应判失败,而不是把未完成文件标为成功。

这里的改名用于区分候选与已验收副本,不构成断电持久化保证。正式备份流程还需要结合文件同步、目录同步和目标存储语义设计持久化边界。

4. 隔离恢复,避免拿源库替副本作证

备份结束后,源库可能已经继续写入,因此不能要求副本行数等于随后查询的源库行数。应检查副本自身是否一致,再依据备份窗口判断新鲜度。在线备份不是持续复制,也不会自动保留任意时刻的恢复能力。

从正式副本再制作恢复工作件

实验副本已经关闭,可以离线复制到另一个目录。下面只读打开恢复工作件,确认表结构和关键关系;不要把恢复测试连接指向 source.db,否则验证的是原库。

mkdir "$LAB_DIR/restore"
cp "$LAB_DIR/backup.db" "$LAB_DIR/restore/service.db"
python3 - <<'PY'
import os
import sqlite3
from pathlib import Path

path = Path(os.environ["LAB_DIR"]) / "restore" / "service.db"
conn = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)
try:
    print(conn.execute("PRAGMA integrity_check").fetchall())
    print(conn.execute("PRAGMA foreign_key_check").fetchall())
    print(conn.execute("""
        SELECT
            (SELECT COALESCE(SUM(amount), 0) FROM events),
            (SELECT balance FROM totals WHERE id = 1)
    """).fetchone())
finally:
    conn.close()
PY

通过条件是完整性结果为 ok、外键检查没有返回行、两个累计值相等。样本没有外键,所以外键检查为空只证明检查流程可以执行;实际业务应保留真实约束进行验证。

生产验收还要用匹配版本的应用打开工作件,读取文件索引或任务清单,并关闭邮件、回调与自动执行任务。数据库能打开,不代表外部文件也齐全。

5. 把单次实验变成备份准入条件

上线前至少保留四项证据:源数据库路径与应用版本、备份起止时间、检查结果、隔离读取结果。时间用于判断数据新鲜度,不应被解释为精确事务截止点;有明确恢复点需求的业务,应另行设计提交序号与恢复目标。

备份目录应限制访问权限,避免数据库中的令牌、用户资料或任务参数随副本暴露。副本传到异地后,再核对文件校验值并重复恢复测试;校验值一致只证明传输完整,不能替代数据库验收。

失败时保留上一份已经验证的副本,候选文件单独处理,不用空文件覆盖成功记录。若数据库引用附件、索引分片或其他数据库,单库在线备份并不提供跨资源原子性,需要业务停写或版本清单协调。

最终准入条件不是“复制命令返回成功”,而是“副本独立存在、结构检查通过、业务关系成立、应用能够隔离读取”。这四项共同成立,备份才具备可验证的恢复价值。

常见问题

SQLite 使用 WAL 时,能否只复制数据库主文件作为在线备份?

不能把这种做法作为可靠的在线备份流程。已提交事务可能仍在 WAL 中,主文件与 WAL 分别复制也不保证属于同一时点。应使用 SQLite 在线备份接口,或在所有连接关闭后按离线流程复制。

PRAGMA integrity_check 返回 ok,是否就证明备份可恢复?

不能。它主要检查数据库内部结构,还需要单独执行 foreign_key_check,并在隔离副本上验证关键表、业务不变量和应用读取路径,确认副本符合实际恢复目标。

AMD EPYC 7543P 独享物理主机

$472/月起

配置 OnuCloud 物理主机 核对 OnuCloud 租赁价格

物理主机配置,按负载登记

AMD EPYC 7543P 独享整机,存储冗余与数据备份由管理员自行规划。