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

SQLite 工程实践:嵌入应用、备份、迁移、损坏恢复与安全

SQLite 经常被描述为“一个数据库文件”,但这个说法容易掩盖真正的工程边界:SQLite 是被链接进应用进程的数据库引擎,负责 SQL 编译、事务、B-tree、Pager、锁和文件 I/O;数据库文件只是其中一种持久化结果。备份、迁移、恢复和安全问题,最终都要落到“哪些字节在什么时刻构成一个一致数据库,以及谁有权读写这些字节”。

本文以 SQLite 官方公开稳定语义为准。示例主要使用 SQLite 命令行工具和 Python 标准库 sqlite3;Python 版本和编译时 SQLite 版本可能不同,因此实际能力应以本机 sqlite3.sqlite_version 和 SQLite CLI 的 .version 为准。


一、先建立正确模型:SQLite 嵌入的是什么

1.1 SQLite 不是数据库服务器

传统数据库通常包含:

客户端进程
    │ TCP/Unix Socket
数据库服务器进程
    │
数据文件

SQLite 通常是:

应用进程
    ├── SQL 编译器
    ├── 查询执行器
    ├── B-tree
    ├── Pager
    ├── VFS
    └── 数据库文件、日志文件

应用通过 SQLite API 调用库函数。没有独立的 SQLite 服务进程,也没有监听端口、数据库用户体系或网络协议。数据库连接对象通常只是进程内的一个句柄,多个连接可能属于同一进程,也可能来自多个进程。

这带来两个直接结论:

  1. 部署简单:应用和 SQLite 一起发布,数据库通常就是应用数据目录中的文件。
  2. 并发和权限责任更靠近应用:SQLite 可以协调多个连接对同一文件的访问,但操作系统文件权限、备份工具、进程异常和多进程部署方式仍由工程系统负责。

SQLite 并不等于“只能单线程”。同一进程可以创建多个连接,多线程和多进程也可以访问数据库文件,但具体安全性还取决于 SQLite 编译选项、连接使用方式、线程模型和文件系统语义。

1.2 从 SQL 到文件的关键组件

一次写事务可以抽象为:

SQL
 │
 ▼
Parser / Code Generator
 │
 ▼
Virtual Machine
 │
 ▼
B-tree
 │
 ▼
Pager
 │
 ▼
VFS
 │
 ▼
操作系统文件

各组件的职责不同:

  • SQL 编译器和虚拟机:把 SQL 转成可执行的字节码,并访问表、索引和临时结构。
  • B-tree:把表和索引组织成页组成的树。SQLite 的表通常以 rowid B-tree 存储,索引以 index B-tree 存储。
  • Pager:管理数据库页的读写、缓存、事务状态、锁和崩溃一致性。
  • VFS(Virtual File System):把“打开文件、读页、写页、加锁、同步、获取时间”等抽象为接口。SQLite 可以为不同操作系统、存储介质或测试环境替换 VFS。
  • 数据库文件:由页组成。页大小、文件头、B-tree 页、空闲页等由 SQLite 文件格式定义。

因此,直接复制文件不是“复制一个抽象数据库对象”,而是复制某个时刻的一组文件字节。这个时刻是否构成一致快照,取决于事务模式和相关日志文件是否被正确处理。

1.3 一个数据库不一定只有一个文件

常见文件包括:

app.db          主数据库文件
app.db-wal      WAL 模式下的预写日志
app.db-shm      WAL 共享内存辅助文件
app.db-journal  回滚日志模式下的回滚日志

这些文件的存在取决于当前连接、事务状态、日志模式和 SQLite 行为。

-wal 文件不是普通的“可有可无缓存”。在 WAL 模式下,已经提交但尚未回写主数据库文件的页面可能仍在 WAL 中。只复制 app.db,可能得到一个缺少已提交事务的旧快照;只复制 app.db-wal 也不能独立构成数据库。

-shm 文件主要用于 WAL 连接之间的共享索引和协调,通常不能脱离主数据库和 WAL 文件单独使用。


二、事务、日志模式与一致性边界

2.1 一致快照是什么

设数据库在时刻 tt 的逻辑状态为 D(t)D(t)。一个备份结果 BB 如果满足:

B=D(t0)B = D(t_0)

其中所有表、索引、元数据和约束关系都来自同一个事务一致性时刻 t0t_0,则称它是一个一致快照。

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

B=部分页面来自 D(t0)+部分页面来自 D(t1)B = \text{部分页面来自 } D(t_0) + \text{部分页面来自 } D(t_1)

这不一定立刻表现为“文件损坏”。更危险的情况是文件仍能打开,但索引、表页或元数据来自不一致时刻,导致查询结果异常或后续写入暴露问题。

2.2 回滚日志模式

SQLite 默认日志模式通常是 DELETE,也可能被数据库永久设置为其他模式。回滚日志模式下,写事务修改数据库页之前,会把原始页面写入回滚日志。事务提交后,SQLite 通过日志和同步操作保证崩溃恢复。

典型过程可以简化为:

1. 读取旧页面 P
2. 把旧页面 P 写入 app.db-journal
3. 修改 app.db 中的页面 P
4. 同步日志和数据库
5. 提交事务
6. 删除、清空或处理回滚日志

读者在写事务进行时通常不能直接看到未提交修改。发生崩溃时,Pager 可以利用日志恢复旧页面。

2.3 WAL 模式

WAL(Write-Ahead Logging,预写日志)把修改追加到 app.db-wal,而不是先覆盖主数据库中的页面:

读者 ──► 主数据库 + WAL 中对应页面
写者 ──► 追加 WAL
检查点 ──► 将 WAL 页面回写主数据库

WAL 的重要语义是:

  • 读事务看到一个一致快照;
  • 多个读者可以并发读取;
  • 通常允许一个写者与读者并发;
  • 同一时刻仍然只有一个写事务;
  • WAL 依赖同一数据库连接集合对 -wal-shm 的协调。

WAL 并不等于“无限并发”,也不等于“自动备份”。长时间读事务可能阻止 WAL 检查点推进,导致 WAL 文件持续增长;多个写请求仍需排队。

可以通过 CLI 检查日志模式:

PRAGMA journal_mode;

可能输出:

wal

设置日志模式:

PRAGMA journal_mode = WAL;

该设置会返回实际模式。它不是一个只对当前事务临时生效的普通参数,而是与数据库文件状态相关的设置;部署时应统一决定,避免不同连接在运行中反复切换。

2.4 BEGIN、锁和忙等待

SQLite 常见事务开始方式:

BEGIN;              -- 通常是 DEFERRED
BEGIN DEFERRED;
BEGIN IMMEDIATE;
BEGIN EXCLUSIVE;

DEFERRED 开始时不立即获得写锁,第一次读或写时才决定需要的锁。如果应用先读后写,可能在升级为写事务时遇到 SQLITE_BUSY

IMMEDIATE 更早声明“我准备写”,适合希望尽早发现写竞争的场景。不同日志模式下锁的细节不同,但工程上应记住:SQLite 的写并发能力是单写者模型,不是多写者模型

可以设置忙超时:

PRAGMA busy_timeout = 5000;

表示遇到暂时锁竞争时等待约 5000 毫秒。Python 中也可以:

import sqlite3

conn = sqlite3.connect("app.db", timeout=5.0)

忙超时不是死锁解决器,也不是吞吐量提升器。长事务、未关闭游标、事务泄漏和单连接串行化问题仍需修复。


三、嵌入应用:连接生命周期、线程和错误处理

3.1 一个可运行的最小示例

下面的示例创建数据库、启用外键、执行事务并读取结果:

import sqlite3
from pathlib import Path

db_path = Path("app.db")

with sqlite3.connect(db_path, timeout=5.0) as conn:
    conn.execute("PRAGMA foreign_keys = ON")
    conn.execute("PRAGMA busy_timeout = 5000")

    conn.execute("""
        CREATE TABLE IF NOT EXISTS user (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL UNIQUE
        )
    """)

    conn.execute(
        "INSERT OR IGNORE INTO user(name) VALUES (?)",
        ("Ada",)
    )

    row = conn.execute(
        "SELECT id, name FROM user WHERE name = ?",
        ("Ada",)
    ).fetchone()

    print(row)

预期输出类似:

(1, 'Ada')

这里有几个关键点:

  • ? 是参数绑定,不是字符串拼接;
  • with sqlite3.connect(...) 在正常退出时提交,在异常退出时回滚;
  • PRAGMA foreign_keys = ON 是连接级设置,必须在每个需要约束的连接上启用;
  • INTEGER PRIMARY KEY 在 SQLite 中具有特殊语义:它通常是 rowid 的别名。AUTOINCREMENT 并非默认需要,且会带来额外的序列表维护。

不要把 PRAGMA foreign_keys = ON 放在事务已经开始之后。该设置不能在事务中可靠地改变,应在连接建立后、执行任何事务前设置。

3.2 连接不是线程安全策略

应用应明确连接归属:

  • 一个连接只由一个工作线程使用;
  • 或由连接池管理,并保证连接不会同时被多个线程使用;
  • 或显式使用 SQLite 的线程模式和上层互斥,但不能仅因为库“编译为线程安全”就共享任意 Python 连接。

在 Python 中,默认连接通常不允许跨线程使用;即使设置了 check_same_thread=False,也只是关闭检查,不会自动为游标、事务和业务状态加锁。

一个常见服务端模式是:

请求线程/任务
    │
    ├── 获取本线程或本任务连接
    ├── 设置 foreign_keys、busy_timeout
    ├── 短事务读写
    └── 归还或关闭连接

事务内不要执行网络请求、用户回调或长时间计算,否则会长时间占用写锁或持有读快照。

3.3 错误类型必须区分

典型 SQLite 错误包括:

  • SQLITE_BUSY:其他连接占用锁,可能稍后重试;
  • SQLITE_LOCKED:更偏向同一连接或共享缓存上下文中的锁冲突,不能简单等同于 BUSY
  • SQLITE_CONSTRAINT:唯一键、外键、非空等约束失败;
  • SQLITE_CORRUPT:数据库结构损坏;
  • SQLITE_FULL:磁盘或配额不足;
  • SQLITE_IOERR:底层 I/O 错误;
  • SQLITE_READONLY:只读文件、只读目录或只读连接。

重试策略必须针对错误类型。对约束错误无限重试没有意义,对 I/O 错误盲目重试可能掩盖磁盘故障。


四、备份:复制一致状态,而不是复制文件名

4.1 为什么在线复制数据库文件有风险

假设数据库处于 WAL 模式:

app.db      100 个已写入页面
app.db-wal   20 个已提交但尚未检查点的页面

此时直接复制 app.db,备份只包含前 100 个页面,而当前逻辑数据库已经包含 WAL 中的提交内容。

即使没有 WAL,写事务也可能正在修改主文件并使用回滚日志。复制过程中的不同文件页可能不属于同一个一致时刻。

因此以下做法不构成通用安全备份:

cp app.db backup.db

在数据库仍被使用时尤其危险。

4.2 方式一:在线备份 API

SQLite 的 Online Backup API 以源数据库的某个一致快照为基础,把内容复制到目标数据库。源库可以继续被其他连接读取或写入;备份过程可能需要反复执行,遇到源库写入时返回忙状态,直到完成。

Python 标准库提供了对应封装:

import sqlite3

with sqlite3.connect("app.db", timeout=5.0) as source:
    with sqlite3.connect("backup-001.db") as target:
        source.backup(
            target,
            pages=100,
            progress=lambda status, remaining, total: print(
                f"status={status}, remaining={remaining}, total={total}"
            )
        )

print("backup finished")

前置条件:

  • app.db 是可打开的 SQLite 数据库;
  • backup-001.db 应由备份流程管理,不能让无关程序同时写入;
  • 目标所在目录必须有足够空间和权限。

pages=100 表示每次复制的页数,不是备份总页数。较小批次可以减少单次占用源库的时间,但会使备份过程更长。备份期间源库继续变化时,最终结果仍应是一个一致快照,而不是简单的文件字节拼接。

备份完成后应验证:

with sqlite3.connect("backup-001.db") as conn:
    result = conn.execute("PRAGMA integrity_check").fetchone()[0]
    print(result)

成功时通常输出:

ok

验证不能证明“业务数据一定正确”,但能发现一部分结构损坏。还应检查关键业务表的行数、最大时间戳或校验和。

4.3 方式二:命令行 .backup

SQLite CLI 可以使用:

sqlite3 app.db ".backup 'backup-001.db'"

也可以交互执行:

sqlite> .backup backup-001.db

该命令使用 SQLite 的备份机制,而不是简单的操作系统文件复制。若命令失败,应保留错误输出并检查目标文件是否只是部分产物,不要把失败目标当成可恢复备份。

备份完成后:

sqlite3 backup-001.db "PRAGMA integrity_check;"

预期输出:

ok

4.4 方式三:VACUUM INTO

VACUUM INTO 可以把当前数据库内容写入一个新的数据库文件:

sqlite3 app.db "VACUUM INTO 'backup-compact.db';"

它的特点是:

  • 生成一个一致快照;
  • 通常会重新组织页面并压缩空闲空间;
  • 目标文件应按 SQLite 语义准备,通常要求目标不存在或为空;
  • 需要额外磁盘空间;
  • 过程会执行较多 I/O,不能把它当成低成本后台复制。

VACUUM INTO 适合生成紧凑的导出副本,但不应替代所有备份策略。在线备份 API 更适合持续、分批复制;VACUUM INTO 更像是重新构造一个数据库文件。

4.5 什么时候直接复制文件才安全

离线复制可以成立,但必须满足明确条件:

  1. 所有访问数据库的连接和进程都已停止;
  2. 没有活动事务;
  3. 数据库目录中的相关日志文件状态已被正确处理;
  4. 文件系统和存储设备没有返回错误;
  5. 复制后的文件经过独立验证。

最可靠的离线流程是停止应用,再复制整个数据库目录中的相关文件,启动前或启动后由 SQLite 处理正常日志恢复。不要只凭“看不到 -wal 文件”判断数据库一定安全;文件可能在复制时刚被创建或删除。


五、备份、恢复点与 PITR 的边界

5.1 RPO 和 RTO

  • RPO(Recovery Point Objective):最多能接受丢失多长时间的数据。
  • RTO(Recovery Time Objective):从故障发生到服务恢复最多允许多长时间。

例如:

RPO = 1 小时
RTO = 30 分钟

意味着最坏情况下允许丢失一小时内的数据,并要求半小时内恢复服务。

SQLite 自身可以生成一致备份,但并没有像某些服务器数据库那样提供通用的、可直接用于生产的归档日志和任意时间点恢复(PITR)体系。

5.2 为什么 WAL 不等于 PITR

WAL 记录的是页面级修改,用于当前数据库连接集合的读写和崩溃恢复。它具有以下限制:

  • WAL 可能被检查点回写并截断;
  • WAL 只覆盖当前生命周期的一部分修改;
  • WAL 的存在依赖主数据库文件和 SQLite 的元数据;
  • 标准工具不会把它当作一个长期归档、可按任意时间点查询的业务日志;
  • 保留 WAL 文件本身也不等于拥有完整的备份链。

因此,下面的方案不能直接宣称支持 PITR:

定期复制 app.db
保留几个 app.db-wal
出故障时挑一个 WAL 拼接

5.3 SQLite 工程中的可行恢复方案

根据 RPO 需求,常见方案是:

方案 A:周期性完整备份

每小时在线备份一次
每天保留一个长期备份

优点是简单、恢复路径短;缺点是 RPO 至少受备份周期限制。

方案 B:完整备份 + 应用级变更日志

应用在同一个事务中写入业务表和变更日志:

BEGIN IMMEDIATE;

UPDATE account
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;

INSERT INTO account_change(account_id, delta, created_at)
VALUES (1, -100, unixepoch());

COMMIT;

变更日志必须和业务写入处于同一事务,否则日志与数据可能分离。恢复时:

恢复最近完整备份
    │
    ├── 读取备份之后的变更日志
    ├── 按顺序重放经过验证的事件
    └── 得到目标时间点状态

这里的日志是应用定义的逻辑日志,不是 SQLite WAL。要支持任意时间点,还需要事件序号、幂等规则、格式版本、校验和和重放工具。

方案 C:存储系统快照

文件系统或云存储快照可以降低复制成本,但必须确认快照的一致性边界。对正在写入的单个 SQLite 文件做不具备应用协调能力的块级快照,不能自动保证数据库一致性。应使用应用停写、备份 API 或存储系统提供的数据库一致快照机制。

5.4 恢复不是“把备份文件拷回去”就结束

一个可执行的恢复流程至少应包括:

1. 停止写入或切换到维护模式
2. 选定并校验备份
3. 将备份恢复到临时路径
4. 执行 integrity_check
5. 执行业务级校验
6. 在测试连接上运行迁移
7. 原子地切换数据库路径
8. 启动应用
9. 观察错误率、写入延迟和关键数据

不要直接覆盖正在被应用打开的数据库文件。应用可能仍持有旧连接、旧 WAL 状态或缓存页。


六、数据库迁移:从一个可运行状态到另一个可运行状态

6.1 迁移的定义

数据库迁移不是“执行一条 ALTER TABLE”,而是一个有版本的状态变换:

SnMn+1Sn+1S_n \xrightarrow{M_{n+1}} S_{n+1}

其中:

  • SnS_n:版本为 nn 的数据库结构和数据不变量;
  • Mn+1M_{n+1}:将版本 nn 升级到 n+1n+1 的迁移;
  • Sn+1S_{n+1}:新应用可以安全使用的状态。

一个合格的迁移需要说明:

  • 当前版本如何识别;
  • 迁移是否可重复;
  • 失败时如何回滚或恢复;
  • 旧应用是否仍能访问;
  • 数据转换如何处理旧值;
  • 大表迁移是否会阻塞写入。

SQLite 提供 PRAGMA user_version 作为数据库文件中的应用可用版本整数:

PRAGMA user_version;
PRAGMA user_version = 2;

它不是 SQLite 自动维护的 schema 迁移系统,版本含义由应用定义。

6.2 一个事务化迁移示例

假设版本 1 只有:

CREATE TABLE user (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL
);

现在要升级到版本 2,增加非空邮箱字段。旧行没有邮箱,因此不能直接添加一个没有默认值的 NOT NULL 列。可以分阶段:

import sqlite3

def migrate_to_v2(conn: sqlite3.Connection) -> None:
    conn.execute("PRAGMA foreign_keys = ON")

    version = conn.execute("PRAGMA user_version").fetchone()[0]
    if version >= 2:
        return
    if version != 1:
        raise RuntimeError(f"unsupported schema version: {version}")

    try:
        conn.execute("BEGIN IMMEDIATE")

        conn.execute("""
            ALTER TABLE user
            ADD COLUMN email TEXT
        """)

        conn.execute("""
            UPDATE user
            SET email = 'unknown-' || id || '@invalid.example'
            WHERE email IS NULL
        """)

        # SQLite 的 ALTER TABLE 能力有限;
        # 若业务要求 email NOT NULL,通常需要重建表。
        conn.execute("""
            CREATE TABLE user_new (
                id INTEGER PRIMARY KEY,
                name TEXT NOT NULL,
                email TEXT NOT NULL UNIQUE
            )
        """)

        conn.execute("""
            INSERT INTO user_new(id, name, email)
            SELECT id, name, email
            FROM user
        """)

        conn.execute("DROP TABLE user")
        conn.execute("ALTER TABLE user_new RENAME TO user")

        conn.execute("PRAGMA user_version = 2")
        conn.execute("PRAGMA foreign_key_check")

        conn.execute("COMMIT")
    except Exception:
        if conn.in_transaction:
            conn.execute("ROLLBACK")
        raise

这个迁移的逻辑是:

  1. 读取版本;
  2. 只有版本为 1 时执行;
  3. 开始写事务,避免其他连接看到中间结构;
  4. 先为旧数据产生可用的 email
  5. 创建新表并复制数据;
  6. 用新表替换旧表;
  7. 写入版本号;
  8. 校验外键;
  9. 提交。

但这个示例还有生产边界:如果其他表通过外键引用 user,直接 DROP TABLE user 需要重新设计迁移顺序;如果存在触发器、索引、视图,也必须逐一迁移。foreign_key_check 返回空结果通常表示没有发现外键违规,但它不是通用完整性检查。

6.3 SQLite 的 ALTER TABLE 限制

SQLite 支持的 ALTER TABLE 能力相对集中,常见的是:

ALTER TABLE t RENAME TO t_new;
ALTER TABLE t RENAME COLUMN old_name TO new_name;
ALTER TABLE t ADD COLUMN new_col ...;
DROP COLUMN ...

具体语义和可用能力会随 SQLite 版本变化,尤其是 DROP COLUMN 等能力应以部署版本为准。复杂变更通常采用“重建表”:

CREATE TABLE new_table (...)
INSERT INTO new_table (...) SELECT ... FROM old_table
DROP TABLE old_table
ALTER TABLE new_table RENAME TO old_table
重建索引、触发器、视图

重建表不是机械模板,必须核对:

  • 主键和 WITHOUT ROWID 语义;
  • 外键;
  • 索引;
  • 触发器;
  • CHECK 约束;
  • 默认值;
  • 生成列;
  • 视图;
  • 表级注释或应用元数据。

6.4 迁移与应用发布的兼容关系

如果应用发布和迁移同时发生,常见安全顺序是扩展—迁移—收缩:

第 1 版:增加新列,旧列仍保留
第 2 版:同时读旧列和新列,逐步回填
第 3 版:只写新列
第 4 版:确认旧版本不再运行后删除旧列

原因是数据库文件可能短时间内被旧应用和新应用共同访问。若新应用先删除旧列,旧应用立即失败;若先增加可选列,旧应用通常仍可运行。

迁移脚本必须支持启动失败后的诊断。不要把“迁移失败后再次启动”设计成重复插入数据或破坏半成品结构。事务能回滚的变更应放在一个事务中;不可回滚的外部动作,例如移动文件、调用远程服务,不应伪装成数据库事务的一部分。


七、损坏:先区分逻辑错误、文件损坏和存储故障

7.1 能打开不代表数据正确

以下问题不是同一类故障:

  1. 业务逻辑错误:SQL 成功执行,但数据规则写错。
  2. 约束违规:约束未启用,例如连接没有打开 foreign_keys
  3. 应用级数据丢失:误删、错误迁移、错误导入。
  4. SQLite 结构损坏:页、B-tree、索引或文件格式不一致。
  5. 底层存储故障:磁盘坏块、文件系统错误、I/O 超时、掉电保护不足。
  6. 权限或路径错误:应用打开了错误文件,或无法创建日志文件。

恢复策略必须先分类。把所有问题都称为“数据库损坏”,会导致错误的修复动作。

7.2 三个重要检查

quick_check

sqlite3 app.db "PRAGMA quick_check;"

它执行较快的完整性检查,但检查范围和深度低于 integrity_check

integrity_check

sqlite3 app.db "PRAGMA integrity_check;"

成功时通常:

ok

发现问题时会输出一条或多条诊断信息。它主要检查数据库结构完整性,不会证明业务金额、用户权限或数据语义正确。

foreign_key_check

sqlite3 app.db "PRAGMA foreign_key_check;"

输出为空通常表示没有检测到外键违规。它与 integrity_check 是不同维度的检查,且外键约束是否在写入时执行取决于连接上的 PRAGMA foreign_keys = ON

可以在只读方式打开数据库进行检查,避免诊断过程产生写入:

sqlite3 "file:app.db?mode=ro" "PRAGMA integrity_check;"

URI 形式是否可用取决于 CLI 和连接参数设置;不确定时先执行:

sqlite3 --version

7.3 .dump 不是可靠的损坏修复工具

普通导出:

sqlite3 damaged.db ".dump" > dump.sql

如果数据库存在损坏,.dump 可能在中途失败,也可能导出不完整内容。执行:

sqlite3 recovered.db < dump.sql

只能重建导出成功的部分,不能证明所有数据已恢复。

较新 SQLite CLI 提供 .recover,用于从损坏数据库中尽力提取可辨认的表和记录:

sqlite3 damaged.db ".recover" > recover.sql
sqlite3 recovered.db < recover.sql

必须明确:

  • .recover 是尽力恢复,不是事务级还原;
  • 可能丢失记录、索引、触发器、约束或原始顺序;
  • 恢复出来的 SQL 需要人工和业务校验;
  • CLI 版本不同,选项和输出细节可能不同;
  • 不应在原始损坏文件上直接尝试破坏性操作。

正确顺序通常是:

保留原始文件只读副本
    │
    ├── 计算哈希并记录环境
    ├── 在副本上 quick_check / integrity_check
    ├── 优先从已验证备份恢复
    └── 没有可用备份时,再对副本尝试 .recover

7.4 不要用 VACUUM“修复”损坏

VACUUM 会重写数据库文件。它可以整理空间、改变页面布局,但不是通用修复工具。对损坏文件执行它可能失败,也可能让取证更困难。

同样,直接设置:

PRAGMA writable_schema = ON;

并修改 sqlite_schema 是高风险操作。它可能使数据库在表面上“能打开”,却破坏解析、索引或迁移语义。除非是在充分理解文件格式、拥有备份并进行离线实验,否则不应作为生产修复手段。

7.5 损坏的常见根因

生产诊断应同时检查:

SQLite 版本与编译选项
应用是否强制杀进程或异常断电
存储设备和文件系统日志
是否把数据库放在不可靠的网络文件系统上
是否存在多个应用实例使用同一路径
是否复制了活动数据库文件
是否错误处理了 -wal / -shm / -journal
磁盘空间和 inode 是否耗尽
文件权限是否中途改变

特别要避免:

  • 把同一个数据库文件放在多个不支持 SQLite 锁语义的共享文件系统上;
  • 让两个不同版本的应用同时对同一文件执行不兼容迁移;
  • 通过操作系统 API 直接覆盖应用正在使用的数据库;
  • 忽略 SQLITE_IOERR,继续把数据库当作健康文件写入。

八、安全:SQLite 没有替应用完成安全边界

8.1 SQLite 核心不提供数据库用户认证

因为 SQLite 没有独立服务器进程,核心 SQLite 通常不提供类似服务器数据库的:

CREATE USER
GRANT SELECT
REVOKE UPDATE

任何能读取数据库文件的进程,通常都可能直接读取其中的数据;任何拥有写权限的进程,都可能修改文件或删除它。

因此安全边界主要是:

操作系统账户
文件权限和目录权限
应用内授权
密钥管理
输入验证
备份访问控制

数据库文件应放在应用专用目录,而不是所有用户都可写的临时目录。目录权限同样重要,因为 SQLite 可能需要创建 -wal-shm-journal 文件。

8.2 SQL 注入与参数绑定

危险写法:

name = user_input
conn.execute(f"SELECT * FROM user WHERE name = '{name}'")

输入:

' OR 1=1 --

可能改变 SQL 结构。

安全写法:

conn.execute(
    "SELECT id, name FROM user WHERE name = ?",
    (user_input,)
)

参数绑定用于值,不用于表名、列名或 SQL 关键字。如果表名来自用户输入,不能用普通参数占位;应使用固定白名单:

allowed = {
    "created": "created_at",
    "name": "name",
}

column = allowed.get(user_choice)
if column is None:
    raise ValueError("invalid sort field")

sql = f"SELECT id, name FROM user ORDER BY {column}"
conn.execute(sql)

8.3 扩展加载、触发器和可执行结构

SQLite 可以通过扩展增加能力。应用不应无条件启用动态扩展加载;只有在明确需要、扩展来源可信并受文件权限控制时才应开启。

数据库中的以下对象也属于可执行逻辑的一部分:

  • 触发器;
  • 视图定义;
  • 生成列表达式;
  • 虚拟表模块;
  • 扩展提供的函数。

恢复或导入不可信数据库时,不要只因为文件能打开就信任其中的 schema。应在隔离环境中检查:

SELECT type, name, sql
FROM sqlite_schema
ORDER BY type, name;

导入 SQL 时尤其要警惕 ATTACH、触发器、虚拟表和扩展函数。

8.4 加密不是 SQLite 核心的默认能力

官方 SQLite 核心不等同于一个自带透明数据库加密方案。若磁盘被复制,普通 SQLite 数据库文件中的文本和数值通常可以被读取。

需要静态加密时,选择可能包括:

  • 应用层字段加密;
  • 加密文件系统;
  • 操作系统或云盘加密;
  • 与 SQLite 兼容的第三方加密实现,例如 SQLCipher 类方案。

第三方加密实现不是 SQLite 核心的语义,可能改变 API、文件格式、备份方式、性能和密钥生命周期。必须单独验证:

备份是否仍然加密
恢复工具是否具备密钥
密钥轮换如何进行
迁移是否能读取旧密钥数据
崩溃恢复是否经过测试

“数据库文件目录受保护”与“文件内容加密”解决的是不同问题。

8.5 只读和最小权限

只读连接可以降低误写风险。例如 URI 形式:

file:app.db?mode=ro

在 Python 中:

import sqlite3

conn = sqlite3.connect(
    "file:app.db?mode=ro",
    uri=True
)

这要求文件已经存在,且连接不能写入。只读连接仍然不能防止拥有文件权限的其他进程修改文件,也不能替代操作系统权限。

备份文件必须按生产数据处理。常见泄漏路径包括:

临时备份未删除
调试日志打印数据库路径或 SQL 参数
云端备份桶权限过宽
崩溃转储包含敏感查询结果
测试环境复制生产数据库

九、一个完整的生产闭环

把上述机制组合起来,一个较稳妥的 SQLite 生命周期可以是:

初始化

创建专用数据目录
设置操作系统权限
打开连接
启用 foreign_keys
设置 busy_timeout
检查 schema version
执行事务化迁移

正常运行

短事务
单写者意识
避免事务内网络调用
参数绑定
记录 SQLite 错误码
监控数据库大小、WAL 大小和备份成功率

备份

使用 Online Backup API 或 CLI .backup
不要在线 cp 主文件
备份完成后执行 integrity_check
做业务级抽样校验
加密并限制备份访问
记录备份时间、SQLite 版本和应用版本

升级

先验证备份可恢复
在副本上执行迁移
检查外键、索引、触发器和关键查询
考虑旧版本与新版本并存
控制长事务和停机窗口

故障

先停止写入
保留损坏原件
区分 BUSY、IOERR、READONLY、CORRUPT
优先恢复已验证备份
没有备份时在副本上尝试 .recover
执行结构校验和业务校验
演练切换和回滚

SQLite 的可靠性不是由某一个 PRAGMA 或某一条备份命令单独保证的。它来自一条完整的因果链:

明确的连接与事务边界
    → SQLite 能建立一致快照
    → 使用正确的备份机制保存快照
    → 迁移在版本和事务中可重复
    → 故障时保留原件并从已验证副本恢复
    → 文件权限、输入和密钥保护数据

只要其中一环被“把数据库当成普通文件”替代,嵌入式部署的简单性就可能转化为难以诊断的数据风险。


系列导航与关联阅读

官方资料

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