数据库基础体系 · 第 116/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

SQLite Backup API 与在线复制:快照、一致性、锁和恢复验证


一、先区分四个容易混淆的概念

在 SQLite 中,“复制数据库文件”“在线备份”“快照”和“在线复制”不是同一件事。

1. 文件复制

例如:

cp app.db backup.db

这只是操作系统层面的字节复制。它没有理解 SQLite 的页面、事务日志和锁状态。

当数据库使用 WAL 模式时,已经提交但尚未合并回主数据库文件的数据可能位于:

app.db-wal
app.db-shm

只复制 app.db,可能得到一个缺少已提交事务的旧状态。更糟糕的是,如果复制恰好发生在回滚日志或 WAL 状态变化的中间阶段,目标文件可能无法作为一致的 SQLite 数据库打开。

2. 在线备份

在线备份是指源数据库仍然被应用读写时,备份操作仍可进行。SQLite 的 Backup API 就是为此设计的。

它不是简单复制文件,而是:

  1. 通过 SQLite 连接打开源数据库和目标数据库;
  2. 按数据库页读取源库;
  3. 在 SQLite 的锁和事务规则下,将页面写入目标库;
  4. 使目标库成为一个内部一致的 SQLite 数据库。

3. 快照

快照是某个逻辑时刻的数据库状态。

如果源库在备份过程中继续变化,Backup API 的目标是生成一个一致的数据库副本,而不是让目标库出现“前半部分是旧状态、后半部分是新状态”的混合结构。

但这里需要严格区分两个保证:

  • **一致性保证:**目标文件可以作为一个完整的 SQLite 数据库使用,内部事务状态不被破坏;
  • **时间点保证:**目标文件是否严格对应某个指定时间、某个事务号或某个业务事件。

Backup API 提供前者,但它不是带日志位点的时间点复制协议,也不能直接给出类似数据库服务器中 LSN、SCN 的恢复位置。

4. 在线复制

在线复制通常意味着源库的变化持续传递到一个或多个目标库,例如:

源库提交事务
    ↓
复制日志或变更流
    ↓
目标库持续应用

SQLite 官方 Backup API 主要提供的是一次性或周期性的在线副本生成能力,不是持续流式复制协议。它可以用来实现:

每隔一段时间生成一个新的副本

但它本身不会:

  • 自动持续监听每个提交;
  • 提供复制槽或日志位点;
  • 让目标库始终追随源库;
  • 解决多主写入冲突;
  • 自动提供增量日志传输。

因此,使用 Backup API 实现的是“在线备份”或“周期性副本”,而不是完整意义上的主从复制。


二、SQLite 文件为什么不能随意复制

SQLite 数据库通常由一个主数据库文件和可能存在的事务相关文件组成。

app.db
app.db-wal
app.db-shm

在传统 rollback journal 模式下,还可能出现:

app.db-journal

1. 数据库文件由页面组成

SQLite 的存储单位不是任意长度的记录,而是固定大小的数据库页。常见页大小为 4096 字节,但实际页大小由数据库文件决定,也可能是其他合法值。

逻辑上的表和索引由 B-tree 页面组成。事务修改数据时,SQLite 需要同时维护:

  • 页面内容;
  • 页面之间的引用关系;
  • 数据库头部信息;
  • 回滚日志或 WAL;
  • 提交边界。

如果操作系统复制只完成了一部分页面,目标文件就可能包含不互相匹配的页面集合。

例如,一个事务需要同时修改页面 10 和页面 20:

事务开始:
    页面 10:旧版本
    页面 20:旧版本

事务写入中:
    页面 10:新版本
    页面 20:新版本

文件复制恰好发生在中间:
    目标页面 10:新版本
    目标页面 20:旧版本

这两个页面可能不构成一个合法的事务状态。SQLite 的事务日志机制可以防止正常数据库连接看到这种半提交状态,但操作系统级的裸复制并没有自动获得这种保护。

2. WAL 模式下,主文件不是全部数据库状态

WAL 模式的基本写入路径是:

读数据库页
    ↓
把修改后的页追加到 app.db-wal
    ↓
提交标记写入 WAL
    ↓
读事务根据 WAL 读取新页

在 checkpoint 之前,主数据库文件可能仍是旧版本,而 WAL 中已经包含已提交的新版本。

因此,下面的复制是不完整的:

cp app.db backup.db

即使 app.db 本身能够打开,也可能缺少 WAL 中的已提交事务。

把三个文件同时复制也不能简单解决问题,因为:

  • 三个文件的复制不是一个原子操作;
  • 复制时源库可能继续写入;
  • -shm 是 WAL 的共享内存索引,不应被当成独立的持久化数据库内容;
  • 目标文件和目标 WAL 必须属于同一个一致的复制时刻。

结论不是“WAL 模式不能备份”,而是:应让 SQLite 负责读取和复制,而不是绕过 SQLite 直接复制文件。


三、Backup API 的对象和生命周期

SQLite C API 的核心接口是:

sqlite3_backup *sqlite3_backup_init(
    sqlite3 *pDest,
    const char *zDestName,
    sqlite3 *pSource,
    const char *zSourceName
);

int sqlite3_backup_step(sqlite3_backup *p, int nPage);

int sqlite3_backup_finish(sqlite3_backup *p);

int sqlite3_backup_remaining(sqlite3_backup *p);

int sqlite3_backup_pagecount(sqlite3_backup *p);

1. 源连接和目标连接

调用者需要分别创建:

  • 源数据库连接 pSource
  • 目标数据库连接 pDest

例如:

pSource → app.db
pDest   → backup.db

sqlite3_backup_init() 的参数顺序容易出错:

sqlite3_backup_init(
    pDest,       // 目标连接
    "main",      // 目标数据库名
    pSource,     // 源连接
    "main"       // 源数据库名
);

"main" 表示主数据库。如果使用 ATTACH DATABASE,也可以指定附加数据库名称。

源库和目标库不能是同一个数据库。Backup API 用来把一个 SQLite 数据库复制到另一个数据库连接,不能用来在同一文件上原地复制。

2. sqlite3_backup_step()

sqlite3_backup_step(p, nPage) 每次最多复制 nPage 个页面。

常见调用方式是分块复制:

while ((rc = sqlite3_backup_step(backup, 100)) == SQLITE_OK) {
    /* 可以在这里让出线程、记录进度或处理取消请求 */
}

当复制完成时,返回:

SQLITE_DONE

nPage 不是“事务大小”,而是本次调用最多处理的数据库页数。将其设置为较小值,可以减少单次持锁时间,也方便:

  • 更新进度;
  • 处理取消;
  • 配合忙等待;
  • 避免长时间阻塞应用线程。

但分块并不改变备份的逻辑一致性。它只是改变执行过程和锁持有的粒度。

3. 进度计算

可以使用:

sqlite3_backup_remaining(backup)
sqlite3_backup_pagecount(backup)

计算近似进度:

progress=1remainingpagecountprogress = 1 - \frac{remaining}{pagecount}

其中:

  • remaining 是尚未复制的页面数;
  • pagecount 是本次备份视图中的总页面数。

如果总页数为 1000,剩余 250 页,则:

progress=12501000=75%progress = 1 - \frac{250}{1000} = 75\%

这个进度用于显示和监控,不应被当成源库“复制到某个事务位点”的证明。它只表示页面复制进展。


四、Backup API 如何保证一致性

1. 目标不是拼接任意页面

设源数据库在某个时间的逻辑状态为:

S(t)={P1(t),P2(t),,Pn(t)}S(t) = \{P_1(t), P_2(t), \ldots, P_n(t)\}

其中 Pi(t)P_i(t) 表示第 ii 个数据库页在时间 tt 的内容。

错误的裸文件复制可能得到:

B={P1(t1),P2(t2),,Pn(tn)}B = \{P_1(t_1), P_2(t_2), \ldots, P_n(t_n)\}

且这些页面之间没有对应同一个已提交事务状态。

Backup API 的目标是使最终目标库 BB 对应源库的某个一致状态:

BS(t)B \equiv S(t^*)

这里的 tt^* 表示某个逻辑一致点,而不是要求所有页面都在同一纳秒被操作系统读取。

更准确地说,Backup API 使用 SQLite 的 pager、锁和事务机制来读取源库,而不是把源文件当成普通字节数组。

2. 源库在备份过程中发生变化

在线备份的关键场景是:

T1:Backup API 开始
T2:备份复制一部分页面
T3:应用提交事务
T4:备份继续复制
T5:Backup API 完成

SQLite 会通过内部备份逻辑处理源库的变化,避免目标库因页面版本不匹配而变成损坏的数据库。

但是,源库持续变化会带来两个现实问题:

  1. 备份可能需要重新处理已经受影响的页面;
  2. 如果写入过于频繁,备份可能长时间处于忙碌状态,甚至难以完成。

因此,“在线”不等于“无成本”。在线备份仍然消耗源库读资源、目标库写资源和 I/O 带宽。

3. 一致性不等于业务静态

Backup API 保证的是数据库层面的一致性。例如:

  • B-tree 页面引用关系合法;
  • SQLite 能够打开数据库;
  • 已提交事务不会被拆成半提交结构;
  • 未提交事务不会以提交状态出现在副本中。

但它不保证应用层面的业务动作已经完整完成。例如,应用可能分两次提交:

事务 1:创建订单
事务 2:写入异步支付状态

备份副本可能只包含事务 1。这个副本在 SQLite 意义上完全一致,但在业务意义上可能表示“订单已创建、支付状态尚未写入”。

如果业务需要在某个明确边界备份,应该在应用层定义边界,例如:

  1. 暂停某类业务写入;
  2. 提交一个表示业务批次完成的标记;
  3. 记录备份开始前后的数据库版本;
  4. 之后再通过 Backup API 生成副本。

Backup API 不会自动理解业务事务边界。


五、锁、WAL 和并发行为

理解锁行为,必须先区分源库和目标库。

1. 源库上的锁

备份需要读取源数据库。它不能在源库完全没有任何读保护的情况下复制页面,否则源库可能在读取期间随意改变。

在 rollback journal 模式下,源库的读事务可能影响写事务的提交过程:

备份读取源库
    ↓
持有源库读锁
    ↓
写事务可以开始,但提交可能需要等待

这并不意味着备份期间所有写入都会立即失败,但长时间的读取可能让写事务在某个阶段遇到:

SQLITE_BUSY

在 WAL 模式下,读者和写者通常具有更好的并发性:

读事务读取旧的 WAL 快照
写事务继续追加新的 WAL 内容

但 WAL 也有两个相关影响:

  • 备份使用的读快照可能阻碍 checkpoint 回收旧 WAL 页面;
  • 长时间备份可能导致 WAL 文件增长。

因此,在 WAL 模式下,在线备份可能不明显阻塞写入,却可能增加 WAL 保留压力。

2. 目标库上的锁

目标库不是只读输出流。Backup API 需要向目标数据库写入页面,因此目标连接通常需要目标数据库的写事务或排他性访问。

如果目标文件同时被其他连接使用,可能遇到:

SQLITE_BUSY
SQLITE_LOCKED

典型原因包括:

  • 目标数据库正在被另一个连接写入;
  • 目标数据库有未完成的读事务;
  • 目标文件被其他进程占用;
  • 目标数据库处于不允许写入的状态。

生产系统通常会将备份目标写到一个专用临时文件,而不是直接覆盖正在被应用读取的目标副本。

3. SQLITE_BUSYSQLITE_LOCKED 不是一回事

粗略区分如下:

  • SQLITE_BUSY:当前资源被其他连接或进程占用,等待后可能成功;
  • SQLITE_LOCKED:通常表示同一进程内存在冲突的连接或未完成操作,简单延时未必能解决;
  • SQLITE_IOERR:底层文件系统 I/O 失败;
  • SQLITE_READONLY:目标或源库以不允许当前操作的方式打开;
  • SQLITE_NOMEM:内存分配失败;
  • SQLITE_DONE:备份完成。

不能把所有错误都转换成“睡眠后重试”。例如,目标路径没有写权限时,反复重试不会解决问题。


六、一个可运行的 Python 在线备份示例

Python 的 sqlite3.Connection.backup() 是对 SQLite Backup API 的封装。下面的示例使用源库只读连接,并将目标写入一个临时文件。

#!/usr/bin/env python3
import os
import sqlite3
import sys
import time

source_path = "app.db"
temp_path = "backup.db.tmp"
backup_path = "backup.db"

if not os.path.exists(source_path):
    raise SystemExit(f"source database does not exist: {source_path}")

# URI 的 mode=ro 使该连接不会意外修改源库。
source_uri = f"file:{os.path.abspath(source_path)}?mode=ro"

src = sqlite3.connect(
    source_uri,
    uri=True,
    timeout=5.0,
)

# 新建目标文件。目标连接由本程序独占使用。
dst = sqlite3.connect(
    temp_path,
    timeout=5.0,
)

# 对一个新建目标库,使用普通回滚日志模式更容易把最终产物管理成单文件。
# 如果目标库已经存在且被其他连接使用,切换模式可能失败。
dst.execute("PRAGMA journal_mode=DELETE")
dst.execute("PRAGMA synchronous=FULL")

def progress(status, remaining, total):
    if total:
        copied = total - remaining
        percent = copied * 100.0 / total
        print(
            f"\rbackup: {percent:6.2f}% "
            f"({copied}/{total} pages)",
            end="",
            flush=True,
        )

try:
    src.backup(
        dst,
        pages=100,       # 每次最多复制 100 页
        progress=progress,
        sleep=0.05,      # 遇到忙状态时,封装层等待后继续
    )
    print()

    # backup() 返回时,SQLite 层面的页面复制已经完成。
    # 关闭前显式提交/关闭目标连接,确保目标库完成正常收尾。
    dst.commit()
finally:
    src.close()
    dst.close()

# 在目标连接已经关闭后再替换最终文件。
# 同一文件系统内 os.replace 通常提供原子路径替换语义。
os.replace(temp_path, backup_path)

print(f"backup created: {backup_path}")

这个示例每一步为什么成立

第一步:源库以只读方式打开

source_uri = "file:...?...mode=ro"

这不是一致性的核心保证。真正的核心仍然是 Backup API,但只读连接可以避免备份程序误写源库,也能更清晰地表达职责。

如果源库不存在、权限不足或 URI 写法错误,连接阶段就会失败。

第二步:先写临时文件

backup.db.tmp

不要直接把备份写入正在被其他程序读取的 backup.db。否则读取者可能观察到一个尚未完成的目标文件。

正确的数据流是:

app.db
  ↓ Backup API
backup.db.tmp
  ↓ 验证
backup.db

第三步:分批复制

pages=100

每次复制 100 页,使进度可观测,也减少一次调用可能占用的资源。

页数不是固定性能答案。页大小、数据库大小、存储设备、写入竞争都会影响结果。应通过实际负载测试选择,而不是套用某个固定数字。

第四步:等待忙状态

sleep=0.05

这只能处理暂时性的竞争。若源库持续高频写入,或者目标库一直被其他连接持有,备份仍可能失败或耗时很长。

第五步:关闭目标连接后再替换

目标连接关闭后,SQLite 有机会完成日志收尾。随后用 os.replace() 替换最终路径,可以避免其他读取者看到“正在生成中的文件”。

但是,原子路径替换不等于所有部署都自动安全:

  • Windows 上可能有打开文件的进程阻止替换;
  • 目标文件所在目录必须允许重命名;
  • 应用必须重新打开新路径,而不是继续使用旧连接;
  • 如果文件还有 -wal-shm,必须按 WAL 规则一起考虑,不能只替换主文件。

七、C API 的完整生命周期和错误处理

下面是核心调用结构。它展示 API 生命周期,不包含路径解析等业务代码。

#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>

static int run_backup(sqlite3 *src_db, sqlite3 *dst_db) {
    sqlite3_backup *backup = NULL;
    int rc;

    backup = sqlite3_backup_init(
        dst_db, "main",
        src_db, "main"
    );

    if (backup == NULL) {
        rc = sqlite3_errcode(dst_db);
        fprintf(stderr, "backup_init failed: %s\n",
                sqlite3_errmsg(dst_db));
        return rc;
    }

    for (;;) {
        rc = sqlite3_backup_step(backup, 100);

        if (rc == SQLITE_DONE) {
            break;
        }

        if (rc == SQLITE_OK) {
            int remaining = sqlite3_backup_remaining(backup);
            int total = sqlite3_backup_pagecount(backup);

            if (total > 0) {
                printf("progress: %d/%d pages\n",
                       total - remaining, total);
            }

            /*
             * 这里可以:
             * 1. 让出线程;
             * 2. 检查取消标记;
             * 3. 记录监控信息。
             */
            continue;
        }

        if (rc == SQLITE_BUSY || rc == SQLITE_LOCKED) {
            /*
             * 实际程序应使用有上限的退避策略。
             * 不能无限循环,也不能把所有 LOCKED 都当作可恢复。
             */
            sqlite3_sleep(50);
            continue;
        }

        fprintf(stderr, "backup_step failed: %s\n",
                sqlite3_errmsg(dst_db));

        /*
         * 即使 step 出错,也必须调用 finish。
         * finish 的返回值可能包含更准确的最终错误。
         */
        break;
    }

    {
        int finish_rc = sqlite3_backup_finish(backup);

        if (rc == SQLITE_DONE) {
            rc = finish_rc;
        }
    }

    return rc;
}

sqlite3_backup_finish() 必须调用

无论 sqlite3_backup_step() 是成功、失败还是被取消,都需要调用:

sqlite3_backup_finish(backup);

原因包括:

  • 释放 Backup API 的内部对象;
  • 释放相关锁;
  • 返回最终错误状态;
  • 完成资源清理。

忽略 finish() 会造成资源泄漏,也可能使连接继续处于不应有的锁定状态。

忙等待必须有边界

一个可用的重试策略至少应包含:

重试次数上限
总等待时间上限
取消标记
错误分类
日志记录

例如:

第 1 次等待 10 ms
第 2 次等待 20 ms
第 3 次等待 40 ms
最多等待 5 秒

但指数退避不是 SQLite Backup API 的规范要求,而是工程策略。真正重要的是:SQLITE_BUSY 可能暂时恢复,SQLITE_READONLYSQLITE_IOERR 等错误通常不应盲目重试。


八、备份目标的原子发布

“备份完成”与“备份文件可以安全交付给恢复程序”之间还隔着一个发布步骤。

推荐的数据流如下:

源库 app.db
   │
   │ sqlite3_backup / Connection.backup
   ▼
临时文件 backup.db.tmp
   │
   │ 完整性检查、重新打开、业务校验
   ▼
关闭连接、完成持久化收尾
   │
   │ 原子替换
   ▼
正式文件 backup.db

为什么不能直接覆盖正式备份

假设备份目标就是:

backup.db

而另一个进程正在读取它:

读取 backup.db
备份程序同时改写 backup.db

读取者可能得到:

  • 旧页面和新页面混合的逻辑结果;
  • 目标库锁冲突;
  • 读取失败;
  • 读取到一个尚未完成的备份。

即使 SQLite 能保护单个连接的读写,也不应让多个角色直接共享一个“正在被重新生成”的备份目标。

临时文件本身也要处理异常

备份进程崩溃后,可能留下:

backup.db.tmp

它不能自动被当成正式备份。启动清理程序时,应根据文件名、修改时间和校验结果决定是否删除,而不是扫描目录后随意删除所有类似文件。


九、恢复验证:复制成功不等于恢复成功

恢复验证至少有三个层次。

1. SQLite 结构完整性

可以使用 SQLite CLI:

sqlite3 backup.db "PRAGMA quick_check;"

正常情况下输出:

ok

quick_checkintegrity_check 更快,适合频繁备份后的快速检查。

更彻底的检查是:

sqlite3 backup.db "PRAGMA integrity_check;"

正常情况下同样应输出:

ok

如果发现问题,可能看到类似:

*** in database main ***
Page 123 is never used
row 5 missing from index ...

这些输出表示数据库结构或索引关系可能存在问题。

需要注意:

  • quick_checkintegrity_check 是数据库结构检查;
  • 它们不是业务数据校验;
  • 输出 ok 不能证明备份文件已经被复制到异地介质;
  • 也不能证明应用一定能完成启动和恢复流程。

2. 打开和读取验证

恢复测试应在一个独立目录完成:

rm -rf restore-test
mkdir restore-test
cp backup.db restore-test/app.db

sqlite3 restore-test/app.db <<'SQL'
PRAGMA quick_check;
SELECT name
FROM sqlite_master
WHERE type IN ('table', 'index')
ORDER BY name;
SELECT COUNT(*) FROM orders;
SQL

验证的重点是:

  1. 能否重新打开;
  2. 是否能读取 schema;
  3. 关键表是否存在;
  4. 关键索引是否存在;
  5. 关键表的记录数是否处于合理范围。

如果备份目标使用 WAL 模式并且关闭时仍留下 backup.db-wal,则不能只复制主文件进行测试。应先让目标库正常关闭并确认日志处理完成,或者把主文件和必要的日志文件作为一个整体处理。为了简化可搬运的单文件备份,备份目标通常使用专用连接和合适的 journal 模式,并在关闭后再验证。

3. 业务不变量验证

假设应用有以下业务不变量:

orders.id 唯一
order_items.order_id 必须引用 orders.id
订单金额不能为负数
每个已完成订单必须有对应的支付记录

可以编写专门的 SQL 检查:

PRAGMA foreign_key_check;

SELECT COUNT(*) AS invalid_amounts
FROM orders
WHERE amount < 0;

SELECT COUNT(*) AS completed_without_payment
FROM orders AS o
WHERE o.status = 'completed'
  AND NOT EXISTS (
      SELECT 1
      FROM payments AS p
      WHERE p.order_id = o.id
  );

这些检查的结果不应只打印到终端,还应纳入备份任务的成功条件。例如:

结构检查失败       → 备份失败
关键表不存在       → 备份失败
业务不变量违反     → 标记为不可恢复备份

4. 真正执行一次恢复演练

最有价值的验证不是只在备份源上执行检查,而是:

生成备份
    ↓
复制到独立恢复目录或测试主机
    ↓
使用真实应用启动
    ↓
执行只读启动检查
    ↓
执行关键查询和版本迁移检查
    ↓
记录恢复耗时

恢复时间目标可以形式化为:

RTO=T发现故障+T准备环境+T替换数据库+T应用启动验证RTO = T_{\text{发现故障}} + T_{\text{准备环境}} + T_{\text{替换数据库}} + T_{\text{应用启动验证}}

如果从未做过恢复演练,就无法仅凭“备份文件存在”推导出实际 RTO。


十、PRAGMA integrity_check 不能发现所有问题

数据库完整性检查非常重要,但它的覆盖范围有边界。

它通常可以发现:

  • 页面结构异常;
  • B-tree 指针不一致;
  • 索引与表之间的结构问题;
  • 未使用页面或页面引用异常。

它不能自动证明:

  • 所有业务记录都存在;
  • 备份时间符合业务要求;
  • 文件没有被错误地放到了错误租户目录;
  • 应用版本能够读取该 schema;
  • 外部对象存储中的备份内容没有被截断;
  • 密钥仍然可用;
  • 备份文件没有被替换成另一份合法但错误的数据库。

因此,生产备份验证应同时考虑:

SQLite 结构
+ schema 版本
+ 关键数据统计
+ 业务不变量
+ 文件大小与校验和
+ 实际恢复启动

校验和的作用是验证传输和存储完整性。例如:

sha256sum backup.db > backup.db.sha256
sha256sum -c backup.db.sha256

但校验和只能说明“当前文件与生成时的字节一致”,不能说明源文件本身业务正确。


十一、WAL 模式下的 checkpoint 与备份

1. 备份不要求先 checkpoint

使用 Backup API 时,不需要为了备份而强制执行:

PRAGMA wal_checkpoint(TRUNCATE);

SQLite 会通过自己的数据库访问机制处理主数据库和 WAL 中的可见状态。

强制 checkpoint 可能:

  • 增加 I/O;
  • 等待长期读事务;
  • 与高并发写入竞争;
  • 让备份任务反而更慢。

如果只是为了生成一致副本,不能把“先 checkpoint”当成 Backup API 的前置条件。

2. 长读事务会影响 checkpoint

WAL 中的读者通常读取某个快照。只要某个读事务仍然需要旧的 WAL 页面,checkpoint 就不能任意回收这些页面。

在线备份如果持续时间较长,可能形成如下关系:

Backup API 保持读快照
        ↓
旧 WAL 页面仍可能被需要
        ↓
checkpoint 无法完全回收
        ↓
app.db-wal 增长

诊断时可以检查:

PRAGMA journal_mode;
PRAGMA wal_checkpoint(PASSIVE);

CLI 中还可以观察文件系统上的 -wal 文件大小变化。

不过,wal_checkpoint 的返回值和行为要结合当前读者、写者以及 SQLite 版本语义解释,不能看到 WAL 文件存在就直接认定数据库损坏。

3. 不要用 checkpoint 代替备份协议

checkpoint 的作用是把 WAL 中的页面合并或复制回主数据库文件,它不是:

  • 一个备份;
  • 一个原子文件快照;
  • 一个恢复点标记;
  • 一个持续复制机制。

即使 checkpoint 成功,之后直接用 cp 复制主文件,仍然可能受到新的并发写入、文件系统缓存和复制时序影响。


十二、Backup API 与 VACUUM INTO 的区别

SQLite 还提供:

VACUUM INTO 'backup.db';

它可以生成一个一致的数据库副本,并且能够整理页面、回收空间、重建数据库文件。

但它和 Backup API 的用途不同。

Backup API 更适合

  • 在线备份;
  • 按页分批执行;
  • 显示进度;
  • 在应用中控制忙等待;
  • 生成周期性副本;
  • 复制到一个已经通过 SQLite 连接管理的目标库。

VACUUM INTO 更适合

  • 生成压缩或整理后的新数据库文件;
  • 在迁移时重新构建数据库;
  • 希望目标文件不保留源库的碎片布局。

VACUUM INTO 通常是一次性操作,不提供 Backup API 那样的逐步 step() 控制。它也不是增量复制协议。

选择时应考虑目标:

只想可靠复制当前数据库状态 → Backup API
想同时重建并压缩数据库文件 → VACUUM INTO
想实时复制每个事务           → 需要另行设计变更传输机制

十三、周期性在线复制的真实语义

可以用 Backup API 实现如下任务:

每 10 分钟:
    生成一个临时副本
    完成结构和业务验证
    发布为 backup-时间戳.db
    删除过期副本

这提供的是一种离散备份序列:

B1,B2,B3,B_1, B_2, B_3, \ldots

如果备份间隔是 10 分钟,故障发生在两次备份之间,那么理论上最多可能丢失接近 10 分钟的已提交数据。这个恢复点目标可粗略表示为:

RPO备份间隔+任务延迟RPO \leq \text{备份间隔} + \text{任务延迟}

其中:

  • RPO 是可接受的数据丢失时间范围;
  • 备份间隔 是两次备份启动之间的时间;
  • 任务延迟 包括排队、复制、验证和上传时间。

这不是 Backup API 自动提供的数学保证,而是由调度策略和任务成功率共同决定的运营指标。

如果目标是更小的 RPO,需要额外设计:

  • 更高频的备份;
  • 应用级变更日志;
  • 事件表或审计表;
  • 事务级日志传输;
  • 上层同步协议。

但是,应用级变更日志必须与业务事务在同一个 SQLite 事务中写入,否则可能出现:

业务表已提交,变更日志未提交

或反过来:

变更日志已提交,业务表未提交

这会使后续复制流无法可靠重放。


十四、典型失败场景与诊断路径

场景一:直接复制 WAL 模式主文件

现象:

备份文件能打开,但缺少刚提交的数据

诊断:

sqlite3 app.db "PRAGMA journal_mode;"
ls -l app.db app.db-wal app.db-shm

如果源库为 WAL 模式并且存在较大的 app.db-wal,直接复制主文件就是高风险操作。

修复:

  • 使用 Backup API;
  • 或在明确控制并发、日志和关闭生命周期的条件下使用 SQLite 提供的其他备份方式;
  • 不要把 cp app.db 当成通用在线备份方案。

场景二:目标库返回 SQLITE_BUSY

可能原因:

  • 目标文件被其他程序读取或写入;
  • 目标目录或文件权限不足;
  • 备份直接覆盖了应用正在使用的目标;
  • 源库存在长事务或写入竞争。

诊断:

lsof backup.db
fuser backup.db

Linux 工具是否安装取决于系统环境。Windows 上应使用对应的文件占用诊断工具。

修复思路:

  1. 使用独占的临时目标;
  2. 给 Backup API 设置有上限的等待;
  3. 记录源库和目标库的错误消息;
  4. 区分暂时锁冲突和永久 I/O 错误;
  5. 避免在业务线程中无限重试。

场景三:备份长期不完成

可能原因:

  • 源库写入非常频繁;
  • 源库存在长期读写事务;
  • 目标存储很慢;
  • 备份线程被调度器长期延迟;
  • WAL 旧页面受读快照影响无法回收。

诊断内容应包括:

备份开始时间
已复制页数和总页数
每次 step 返回码
BUSY/LOCKED 次数
源库 WAL 大小
目标文件写入速度
备份线程等待时间

不要只记录:

backup failed

这种日志无法区分数据库锁、磁盘错误和程序逻辑错误。

场景四:integrity_check 通过,但恢复后应用失败

可能原因:

  • 应用版本不兼容 schema;
  • 缺少外部配置或密钥;
  • 数据库内没有满足业务要求的数据;
  • 备份属于错误的租户或错误环境;
  • 应用启动时依赖了数据库外部的文件。

修复:

  • 将应用启动测试纳入恢复演练;
  • 验证数据库 schema 版本;
  • 验证关键业务表和计数;
  • 验证配置、密钥和外部资源;
  • 记录从备份到服务可用的完整耗时。

十五、恢复时的文件替换边界

恢复不是简单地执行:

cp backup.db app.db

更安全的流程是:

停止或隔离所有使用 app.db 的连接
    ↓
确认没有残留写事务
    ↓
将备份复制到恢复临时路径
    ↓
执行 quick_check/integrity_check
    ↓
完成权限、所有者和标签设置
    ↓
原子替换数据库路径
    ↓
重新启动应用
    ↓
执行启动后验证

如果数据库使用 WAL 模式,必须特别注意:

app.db
app.db-wal
app.db-shm

在没有任何 SQLite 连接使用数据库时,才可以安全地清理不再属于当前数据库状态的旧 sidecar 文件。不能在数据库仍被进程打开时随意删除 -wal-shm,否则可能破坏正在运行的连接或丢失尚未合并的状态。

恢复后,应用必须重新打开数据库连接。旧连接可能仍持有:

  • 旧文件描述符;
  • 旧 pager 状态;
  • 旧 WAL 快照;
  • 旧 schema 缓存。

替换路径不会自动把已有连接切换到新文件。


十六、安全边界:备份文件也是敏感数据

备份副本通常包含完整业务数据,因此需要与源库同等级别的保护:

  • 文件系统权限;
  • 进程访问控制;
  • 加密存储;
  • 传输加密;
  • 密钥生命周期管理;
  • 备份保留周期;
  • 删除和销毁策略;
  • 审计日志。

如果使用 SQL 或文件路径接收外部输入,不应直接拼接路径:

backup_path = "/backup/" + user_input + ".db"

这可能引入路径穿越、覆盖任意文件或软链接攻击。备份服务应:

  1. 使用受控目录;
  2. 规范化并校验路径;
  3. 拒绝 .. 和绝对路径输入;
  4. 使用安全创建方式;
  5. 对最终文件执行权限和所有者校验;
  6. 不把备份路径当成普通用户输入处理。

备份验证程序也应以最小权限运行。对一个损坏或恶意构造的 SQLite 文件执行复杂读取时,减少进程权限和网络权限可以降低风险。


十七、规范保证、实现现象和工程建议

最后需要把三类结论分开。

SQLite API 的规范语义

可以依赖:

  • Backup API 通过 SQLite 连接执行数据库复制;
  • sqlite3_backup_step() 支持分步复制;
  • SQLITE_DONE 表示复制完成;
  • sqlite3_backup_finish() 必须调用;
  • remaining/pagecount 可用于进度;
  • API 的目标是生成一致的数据库副本,而不是裸文件拼接。

常见实现现象

不能把它们当成无条件保证:

  • WAL 模式下读写通常可以并发;
  • checkpoint 通常不会阻塞所有读写;
  • 小批量 step() 通常更容易控制锁和延迟;
  • 新建目标库通常更容易管理为单文件;
  • 关闭目标连接后通常更容易得到可搬运的目标文件。

这些行为仍然会受 SQLite 编译选项、操作系统、文件系统、连接配置和当前并发状态影响。

工程层面的建议

属于应用设计,而非 SQLite API 自动提供的能力:

  • 先写临时文件,再验证和发布;
  • BUSY/LOCKED 使用有上限的重试;
  • 将结构检查、业务检查和恢复启动测试结合起来;
  • 保存多个带时间和版本标识的备份;
  • 对备份文件计算校验和;
  • 记录备份耗时、页数、错误码和 WAL 大小;
  • 定期执行真实恢复演练;
  • 不把周期性 Backup API 误称为实时复制。

SQLite Backup API 解决的是“如何在数据库仍在线使用时,得到一个 SQLite 层面一致的副本”。它不会替应用决定复制频率、恢复点、业务边界、备份发布方式和灾难恢复流程。

真正可恢复的系统必须同时满足:

可恢复性=一致副本+可靠存储+可验证性+可执行的恢复流程\text{可恢复性} = \text{一致副本} + \text{可靠存储} + \text{可验证性} + \text{可执行的恢复流程}

缺少其中任何一项,备份都可能只是一个看起来存在、但无法在故障时使用的文件。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。