备份任务显示成功,恢复后却少了最近一批记录,这类故障在使用 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/月起