数据库基础体系 · 第 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 服务进程,也没有监听端口、数据库用户体系或网络协议。数据库连接对象通常只是进程内的一个句柄,多个连接可能属于同一进程,也可能来自多个进程。
这带来两个直接结论:
- 部署简单:应用和 SQLite 一起发布,数据库通常就是应用数据目录中的文件。
- 并发和权限责任更靠近应用: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 一致快照是什么
设数据库在时刻 的逻辑状态为 。一个备份结果 如果满足:
其中所有表、索引、元数据和约束关系都来自同一个事务一致性时刻 ,则称它是一个一致快照。
错误的文件复制可能得到:
这不一定立刻表现为“文件损坏”。更危险的情况是文件仍能打开,但索引、表页或元数据来自不一致时刻,导致查询结果异常或后续写入暴露问题。
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 什么时候直接复制文件才安全
离线复制可以成立,但必须满足明确条件:
- 所有访问数据库的连接和进程都已停止;
- 没有活动事务;
- 数据库目录中的相关日志文件状态已被正确处理;
- 文件系统和存储设备没有返回错误;
- 复制后的文件经过独立验证。
最可靠的离线流程是停止应用,再复制整个数据库目录中的相关文件,启动前或启动后由 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”,而是一个有版本的状态变换:
其中:
- :版本为 的数据库结构和数据不变量;
- :将版本 升级到 的迁移;
- :新应用可以安全使用的状态。
一个合格的迁移需要说明:
- 当前版本如何识别;
- 迁移是否可重复;
- 失败时如何回滚或恢复;
- 旧应用是否仍能访问;
- 数据转换如何处理旧值;
- 大表迁移是否会阻塞写入。
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 时执行;
- 开始写事务,避免其他连接看到中间结构;
- 先为旧数据产生可用的
email; - 创建新表并复制数据;
- 用新表替换旧表;
- 写入版本号;
- 校验外键;
- 提交。
但这个示例还有生产边界:如果其他表通过外键引用 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 能打开不代表数据正确
以下问题不是同一类故障:
- 业务逻辑错误:SQL 成功执行,但数据规则写错。
- 约束违规:约束未启用,例如连接没有打开
foreign_keys。 - 应用级数据丢失:误删、错误迁移、错误导入。
- SQLite 结构损坏:页、B-tree、索引或文件格式不一致。
- 底层存储故障:磁盘坏块、文件系统错误、I/O 超时、掉电保护不足。
- 权限或路径错误:应用打开了错误文件,或无法创建日志文件。
恢复策略必须先分类。把所有问题都称为“数据库损坏”,会导致错误的修复动作。
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 能建立一致快照
→ 使用正确的备份机制保存快照
→ 迁移在版本和事务中可重复
→ 故障时保留原件并从已验证副本恢复
→ 文件权限、输入和密钥保护数据
只要其中一环被“把数据库当成普通文件”替代,嵌入式部署的简单性就可能转化为难以诊断的数据风险。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:SQLite 查询与索引:类型亲和性、查询计划、FTS5 和性能边界
- 下一篇:Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream
- 延伸:SQLite 架构与文件格式:嵌入式数据库、Pager、B-tree 和 VFS
- 延伸:数据库备份与恢复:全量、增量、PITR、RPO/RTO 和恢复演练
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论