数据库基础体系 · 第 32/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
SQLite 事务与 WAL:锁状态、并发读写、Checkpoint 和忙等待
SQLite 的事务并发,不能只用“SQLite 只有一个写者”概括。要准确理解一次读写操作是否会阻塞、为什么会返回 SQLITE_BUSY、WAL 文件为什么会增长,需要同时观察四个层次:
- 事务层:
BEGIN、COMMIT、ROLLBACK以及自动提交; - Pager 层:把页面读写、日志和锁组织成原子事务;
- 文件与 VFS 层:通过操作系统文件锁访问数据库文件、回滚日志或 WAL;
- WAL 层:用追加写入和 Checkpoint 把 WAL 中的页面合并回主数据库。
本文以 SQLite 官方稳定版本公开语义为准。示例使用文件数据库和默认的 SQLite CLI;不同语言绑定可能改变错误处理 API,但不会改变下面的并发模型。
1. 先区分事务、连接和锁
1.1 事务属于连接,不属于某条 SQL
SQLite 使用自动提交模式时,每条未显式包在事务中的 SQL 都会隐式开启并结束一个事务。
例如:
UPDATE account SET balance = balance - 10 WHERE id = 1;
如果当前没有显式事务,SQLite 通常会:
- 开启一个隐式事务;
- 执行
UPDATE; - 提交;
- 回到自动提交状态。
而下面的多条语句属于同一个事务:
BEGIN;
UPDATE account SET balance = balance - 10 WHERE id = 1;
UPDATE account SET balance = balance + 10 WHERE id = 2;
COMMIT;
事务的边界决定了:
- 哪些修改一起提交;
- 读事务看到哪个一致性快照;
- 写锁持有多久;
- 其他连接需要等待多久;
- WAL 中有多少内容暂时不能被 Checkpoint 回收。
因此,事务越长,不只是“占用数据库时间更久”,还可能长期固定一个 WAL 读快照,阻碍 Checkpoint。
1.2 连接之间才形成并发
SQLite 的锁通常是针对数据库文件以及相关 WAL 文件建立的。所谓“另一个事务”,在工程上通常意味着:
- 另一个进程中的 SQLite 连接;
- 同一进程中的另一个连接;
- 同一连接中被不同执行流错误地交错使用的事务。
同一个连接不能把多个独立事务真正并行执行。多线程程序即使共享连接,也必须遵守所使用 SQLite 编译选项和语言绑定规定的线程安全约束;增加线程数量不会自动增加 SQLite 的写并发。
1.3 Pager 是锁和事务语义的关键中间层
SQLite 的架构大致可以抽象为:
SQL
↓
解析器、查询规划器、虚拟机
↓
B-tree
↓
Pager
↓
VFS
↓
数据库文件 / 回滚日志 / WAL 文件
B-tree 负责表和索引的页面结构,Pager 负责:
- 从文件获取数据库页面;
- 缓存页面;
- 在事务中追踪页面修改;
- 维护回滚日志或 WAL;
- 请求和释放文件锁;
- 在提交、回滚、恢复时维持原子性。
所以,“SQLite 加了 WAL”并不意味着 SQL 层出现了多个并行写事务。WAL 主要改变的是读写之间如何协调,以及提交内容如何暂存。
2. 回滚日志模式下的锁状态
SQLite 默认的日志模式历史上是回滚日志模式。写事务先把将要覆盖的原页面写入 数据库名-journal,再修改主数据库文件。发生崩溃时,SQLite 可以用回滚日志恢复原页面。
在传统的 Unix 文件锁语义中,常用的数据库锁状态如下:
| 状态 | 含义 |
|---|---|
UNLOCKED |
当前连接没有读锁或写锁 |
SHARED |
正在读取数据库;多个连接可以同时持有 |
RESERVED |
某个连接准备写入;允许已有读者继续读,也允许新的读者进入 |
PENDING |
写者即将取得排他锁;不再允许新的读者进入,但已有读者可以继续 |
EXCLUSIVE |
写事务正在独占数据库;不能有其他读者或写者 |
可以把一个典型的回滚日志写事务表示为:
UNLOCKED
│ 第一次读
▼
SHARED
│ BEGIN IMMEDIATE 或首次写入
▼
RESERVED
│ 等待读者退出
▼
PENDING
│ 取得排他锁
▼
EXCLUSIVE
│ 修改主数据库并提交
▼
UNLOCKED
这解释了回滚日志模式下的基本现象:
- 多个读者可以并行;
- 写者需要最终取得
EXCLUSIVE; - 写者在等待排他锁时,已有读者可以继续;
- 一旦进入
PENDING,新的读者也可能被阻塞; - 写事务提交后,其他连接才能正常继续。
PENDING 是文件锁协议中的过渡状态。Pager 内部通常不会把它作为长期独立状态管理,但它对理解“为什么新的读操作也开始失败或等待”很有帮助。
2.1 回滚日志为什么让读写互相影响
假设连接 A 正在读取主数据库文件:
A: SHARED
连接 B 要修改同一个数据库:
B: RESERVED → PENDING → EXCLUSIVE
B 必须保证 A 不会在读取过程中看到“半旧半新”的页面,因此 B 不能直接覆盖 A 可能读取的页面。它要等待所有读者释放 SHARED 锁。
反过来,如果 B 已经处于写入阶段,新的读者也不能绕过 B 直接读取正在修改的主数据库文件。这就是回滚日志模式下常说的“读写互相阻塞”。
3. WAL 模式改变了什么
启用 WAL:
PRAGMA journal_mode = WAL;
如果成功,SQLite CLI 通常会输出:
wal
这个设置会持久化到数据库文件的头部;以后打开该数据库的连接通常都会使用 WAL,除非再次切换日志模式或环境不允许。
WAL 模式下,写事务不立即覆盖主数据库文件,而是把修改后的页面以 WAL frame 追加到:
数据库名-wal
通常还会有:
数据库名-shm
-shm 文件用于共享 WAL 索引信息。具体共享内存实现依赖 VFS;在常规文件 VFS 中通常表现为这个文件。
核心数据流是:
主数据库文件
│
├── 旧页面
│
└── 读取时结合 WAL 中较新的页面
▲
│
数据库名-wal
追加新的页面版本
一次 WAL 写事务大致如下:
- 读取主数据库中的旧页面;
- 在连接的事务中修改页面;
- 把修改后的页面作为 frame 追加到 WAL;
- 写入表示提交边界的提交信息;
- 提交完成后,其他连接可以看到这批修改。
主数据库文件可能暂时没有这些新页面,但 SQLite 会通过 WAL 索引找到 WAL 中较新的页面版本。因此,WAL 不是“把主数据库改成内存数据库”,而是把新页面暂存在一个追加日志中。
4. WAL 中的读快照
4.1 读者不会随意看到后来提交的数据
WAL 读事务开始时,会选择一个一致的 WAL 末端位置,通常称为读者的 end mark。它可以理解为:
本次读事务最多看到 WAL 中这个位置以前的已提交内容。
假设 WAL 中存在如下已提交 frame:
frame: 1 2 3 4 5 6 7 8
提交边界: ↑ ↑
连接 A 开始读事务时选择 frame 5 作为快照:
A 的可见范围:主数据库 + frame 1..5
此时连接 B 又提交了 frame 6、7、8:
B 的可见范围:主数据库 + frame 1..8
A 不会因为 B 提交就自动看到 frame 6..8。A 仍然读取自己的快照,从而得到一致的事务级视图。
4.2 WAL 的并发保证
WAL 模式下,典型并发关系是:
多个读者 可以并行
一个写者 可以写入 WAL
读者与写者 通常可以并行
多个写者 不能并行
“读者与写者可以并行”必须加上两个限定:
- 所有写者仍然需要竞争同一个写事务资格;
- Checkpoint、锁竞争、文件系统能力和长事务仍可能造成等待或
SQLITE_BUSY。
WAL 解决的是读写之间的主要阻塞,不是把 SQLite 变成多写者数据库。
5. BEGIN DEFERRED、IMMEDIATE 和 EXCLUSIVE
三种显式事务模式的区别,主要在于什么时候尝试获取写资格。
5.1 BEGIN DEFERRED
BEGIN DEFERRED;
这是默认的 BEGIN 行为。
它不会立即取得读锁或写锁。后续动作决定事务类型:
BEGIN DEFERRED; -- 此时通常还没有访问数据库
SELECT * FROM t; -- 首次读,建立读事务
UPDATE t SET ...; -- 之后尝试升级为写事务
如果事务先读后写,就存在“读事务升级为写事务”的特殊路径。
5.2 BEGIN IMMEDIATE
BEGIN IMMEDIATE;
它会立即尝试取得写事务资格。
在 WAL 模式下,这通常意味着立即竞争 WAL 写锁;如果另一个连接已经在写,语句可能等待或返回 SQLITE_BUSY。
它适用于这样的场景:
- 已经知道后续一定要写;
- 希望在事务开始时就发现写者冲突;
- 不希望先建立读快照,过一段时间后再尝试升级。
5.3 BEGIN EXCLUSIVE
BEGIN EXCLUSIVE;
在回滚日志模式下,它要求排他访问,因而会阻塞其他连接的读操作。
在 WAL 模式下,EXCLUSIVE 与 IMMEDIATE 的并发效果基本相同:它们都会获取写事务资格,但不会因为事务类型本身阻止其他连接读取 WAL 快照。WAL 模式仍然只有一个写者。
因此不能把下面两句话混为一谈:
- “
EXCLUSIVE在所有模式下都阻塞读者”:错误; - “WAL 下
EXCLUSIVE会允许多个写者”:错误。
6. 一个完整的 WAL 并发算例
下面用两个 SQLite CLI 会话观察并发行为。先准备数据库。
会话 0:初始化
sqlite3 demo.db
执行:
PRAGMA journal_mode = WAL;
CREATE TABLE item(
id INTEGER PRIMARY KEY,
value INTEGER NOT NULL
);
INSERT INTO item(value) VALUES (10);
然后退出:
.quit
6.1 两个读者和一个写者
打开会话 A:
sqlite3 demo.db
执行:
PRAGMA busy_timeout = 5000;
BEGIN;
SELECT * FROM item;
预期看到:
1|10
此时 A 保持一个读事务。
打开会话 B:
sqlite3 demo.db
执行:
PRAGMA busy_timeout = 5000;
BEGIN IMMEDIATE;
UPDATE item SET value = 20 WHERE id = 1;
COMMIT;
在 WAL 模式下,B 通常可以完成提交,A 不会被迫结束,也不会自动看到 20。
回到 A:
SELECT * FROM item;
预期仍然是:
1|10
A 的事务快照是在 B 提交之前建立的。
结束 A 的事务:
COMMIT;
然后重新开始一次读取:
SELECT * FROM item;
这时预期看到:
1|20
原因是新语句或新事务可以选择更新后的 WAL 快照。
6.2 读事务升级为写事务的反例
仍然使用 WAL。会话 A 执行:
BEGIN DEFERRED;
SELECT value FROM item WHERE id = 1;
A 已经建立了读快照,例如看到:
20
此时会话 B 执行:
BEGIN IMMEDIATE;
UPDATE item SET value = 30 WHERE id = 1;
COMMIT;
B 提交成功后,A 再执行:
UPDATE item SET value = 21 WHERE id = 1;
A 可能收到:
database is locked
如果启用了扩展错误码,底层扩展错误码可能是 SQLITE_BUSY_SNAPSHOT。
这不是普通的“对方还没释放锁”。逻辑过程是:
- A 的读快照基于值
20; - B 已经基于更晚状态提交了值
30; - A 如果直接升级写事务,会让它的修改建立在过期快照上;
- SQLite 不允许这种升级制造不一致的历史,因此拒绝 A 的升级。
正确处理方式通常是:
ROLLBACK;
BEGIN IMMEDIATE;
-- 重新读取当前值
-- 根据最新值重新计算业务修改
UPDATE item SET value = 31 WHERE id = 1;
COMMIT;
不能只依赖 busy_timeout 解决 SQLITE_BUSY_SNAPSHOT。等待不会让 A 的旧快照变新;必须回滚并重新开始事务。
7. WAL 的单写者模型
WAL 允许多个读者同时读取,但写入 WAL 仍然需要一个写锁。
假设连接 A 正在执行:
BEGIN IMMEDIATE;
-- 大量 UPDATE
在 A 提交或回滚前,连接 B 执行:
BEGIN IMMEDIATE;
B 无法同时成为写者。结果取决于 B 的忙等待设置:
- 没有忙等待:很快返回
SQLITE_BUSY; - 设置了超时:等待一段时间;
- A 在超时前提交或回滚:B 可能随后成功;
- A 长时间持有写事务:B 最终仍会失败。
因此,WAL 的写吞吐上限仍受以下条件限制:
- 每个写事务的持续时间;
- 写事务之间的竞争;
- 单个 VFS 和文件系统的锁实现;
- 提交时的同步与存储设备行为;
- Checkpoint 是否与写入竞争。
“WAL 支持并发写入”如果被理解成“多个写事务可以同时提交”,就是错误的。准确说法是:WAL 允许读写并发,但仍然串行化写事务。
8. Checkpoint:把 WAL 合并回主数据库
8.1 为什么需要 Checkpoint
WAL 不会无限增长。Checkpoint 的任务是:
把 WAL 中已经可以安全应用的页面复制回主数据库文件。
假设主数据库页面为:
page 1 = A
page 2 = B
WAL 中追加了:
frame 1: page 1 = A'
frame 2: page 2 = B'
frame 3: page 1 = A''
如果当前最老读者的快照只允许处理到 frame 2,Checkpoint 可以把:
page 1 = A'
page 2 = B'
复制回主数据库,但不能随意处理 frame 3,因为某些读者可能仍需要按自己的快照读取旧版本。
更一般地说,设:
L:当前最老读事务允许使用的 WAL 位置;W:当前 WAL 中已经提交的末端;C:已经复制回主数据库的安全位置。
则 Checkpoint 的推进条件近似为:
C ≤ L ≤ W
Checkpoint 能推进到的上限受 L 限制,而不是只看 WAL 当前有多长。
8.2 长读事务如何阻塞回收
如果连接 A 长时间保持读事务:
BEGIN;
SELECT * FROM large_table;
-- 长时间不 COMMIT 或 ROLLBACK
连接 B 可以继续提交写事务,WAL 可能变成:
主数据库 | frame 1..100 | frame 101..200 | frame 201..300
▲
A 的读快照
Checkpoint 可以复制一部分内容,但不能回收 A 仍可能需要的旧 frame。于是:
- WAL 文件继续增长;
- 自动 Checkpoint 可能反复只能部分完成;
- 写者最终可能在 Checkpoint 或 WAL 空间管理处遇到等待;
- 磁盘占用增加。
这里的“读者阻塞 Checkpoint”不是说读者阻塞了所有写入。WAL 允许写者继续追加,但读者可能阻止 WAL 被完全回收。
9. 四种 Checkpoint 模式
SQLite 提供的 Checkpoint 模式通过:
PRAGMA wal_checkpoint(MODE);
或 C API sqlite3_wal_checkpoint_v2() 使用。常见模式如下。
9.1 PASSIVE
PRAGMA wal_checkpoint(PASSIVE);
尽可能执行 Checkpoint,但不主动等待会阻塞 Checkpoint 的锁。它适合低干扰地尝试回收 WAL。
如果有冲突,它可能只完成部分工作,而不是等待到完成。
9.2 FULL
PRAGMA wal_checkpoint(FULL);
会阻止新的写事务进入 Checkpoint 的关键阶段,并尝试把所有可处理内容复制回主数据库,但不会阻止读者继续读取。
如果已有读事务阻止 Checkpoint 完成,调用可能返回忙状态或只达到受限结果,具体应检查返回值和输出计数。
9.3 RESTART
PRAGMA wal_checkpoint(RESTART);
行为类似 FULL,并且会尝试等待读者结束,使得下一个写事务可以从 WAL 的起点重新开始,而不必继续追加在旧内容之后。
如果有长期读者,它可能无法达到预期的重启条件。
9.4 TRUNCATE
PRAGMA wal_checkpoint(TRUNCATE);
在完成相应 Checkpoint 后,进一步尝试把 WAL 文件截断为零长度。
它对文件锁条件要求更严格。如果其他连接仍在使用 WAL,或者读者仍然保持旧快照,截断可能失败或返回忙状态。
9.5 如何解释 Checkpoint 的结果
在 SQLite CLI 中,可以执行:
PRAGMA wal_checkpoint(PASSIVE);
通常会返回三列:
busy | log | checkpointed
它们可理解为:
busy:是否因为无法取得必要锁而未能完成目标;log:WAL 中的 frame 数量;checkpointed:已经复制回主数据库的 frame 数量。
例如:
0|1200|900
表示本次调用没有报告锁忙,但 WAL 中仍有约 1200 个 frame,已经处理约 900 个。剩余 frame 可能仍受读快照限制,也可能是检查点过程尚未覆盖的部分;不能仅凭一行输出断言所有剩余 frame 都被某一个读者固定。
如果长期看到:
log 很大
checkpointed 长期落后
应重点排查长事务、未关闭游标和没有结束的读连接,而不是盲目提高 Checkpoint 频率。
10. 自动 Checkpoint 的边界
SQLite 默认会在 WAL 达到一定页数后,由执行提交的连接尝试自动 Checkpoint。常见默认阈值是 1000 个页面,但这个默认值属于可配置行为,不应把它当作适合所有部署的固定容量。
可以为某个连接设置:
PRAGMA wal_autocheckpoint = 1000;
参数表示 WAL 大约达到多少个页面后触发自动 Checkpoint。它不是“每 1000 次事务执行一次”,也不是字节数。
重要边界是:
- 自动 Checkpoint 通常由某个数据库连接执行;
- 它受读者快照和锁竞争影响;
- 它不保证 WAL 立即变成零长度;
- 长读事务仍可能导致 WAL 持续增长;
- 调低阈值会增加 Checkpoint 频率,不等于降低总 I/O;
- 调高阈值可以减少频繁 Checkpoint,但可能增加恢复时间和 WAL 文件大小。
生产系统常把“何时 Checkpoint”与业务负载、读事务持续时间、磁盘容量以及写入延迟一起观察,而不是只调整一个阈值。
11. 忙等待:SQLITE_BUSY 到底等待什么
11.1 默认不会无限等待
SQLite 遇到无法立即取得锁时,通常返回 SQLITE_BUSY。默认行为不是无限重试。
可以在 SQLite CLI 中设置连接级超时:
PRAGMA busy_timeout = 5000;
这表示遇到适用的锁竞争时,SQLite 可以等待大约 5000 毫秒后再返回错误。
在 C API 中,等价能力通常通过:
sqlite3_busy_timeout(db, 5000);
或者自定义 sqlite3_busy_handler() 实现。
忙等待属于连接级配置。新打开的连接不会自动继承另一个连接的 busy_timeout。
11.2 忙等待的逐步过程
假设:
- 连接 A 持有写事务;
- 连接 B 执行
BEGIN IMMEDIATE; - B 设置了 5000 毫秒超时。
逻辑上可能是:
t0: B 尝试取得写锁,发现 A 持有
t1: B 的 busy handler 被调用,等待
t2: B 再次尝试
...
t0 + 5000ms: A 仍未释放
tend: B 返回 SQLITE_BUSY
如果 A 在 t0 + 5000ms 之前提交,B 可能成功;如果 A 只是执行了一个很慢的查询但没有结束事务,B 仍可能超时。
忙等待只能解决“对方稍后会释放的锁”,不能解决所有错误。
11.3 SQLITE_BUSY、SQLITE_BUSY_SNAPSHOT 和 SQLITE_LOCKED
这几个错误经常被统称为“数据库锁了”,但含义不同。
SQLITE_BUSY
通常表示其他连接或进程持有冲突资源,当前操作可以通过等待或稍后重试获得机会。
典型情况:
- 两个连接竞争写事务;
- Checkpoint 需要的锁被其他连接占用;
- 试图切换日志模式时有其他连接正在使用数据库。
SQLITE_BUSY_SNAPSHOT
表示当前读快照已经过期,不能安全升级为写事务。
典型过程:
A: BEGIN DEFERRED
A: SELECT -- 建立旧快照
B: 写入并 COMMIT
A: UPDATE -- 尝试从旧快照升级,失败
正确策略是回滚 A 的事务并重新开始,而不是原地循环执行 UPDATE。
SQLITE_LOCKED
更常与同一连接、共享缓存或对象级锁冲突有关。它和“另一个进程持有文件锁”不是同一类问题。
实际诊断时应使用扩展错误码,而不是只根据显示文本 database is locked 做判断。不同语言绑定对扩展错误码的暴露方式不同,需要查对应绑定的 API。
12. 一个可靠的重试边界
错误重试必须放在正确的事务层次。
错误做法:只重试一条写语句
BEGIN;
读取库存
其他连接提交了变化
UPDATE 库存
失败
原事务仍保持旧快照
重复 UPDATE
如果失败原因是 SQLITE_BUSY_SNAPSHOT,重复执行并不能使旧快照更新。
合理做法:回滚整个事务再重试
伪代码如下:
for attempt in 1..N:
BEGIN IMMEDIATE
try:
读取当前值
根据当前值计算新值
执行 UPDATE
COMMIT
成功
except SQLITE_BUSY:
ROLLBACK
等待退避时间
继续
except SQLITE_BUSY_SNAPSHOT:
ROLLBACK
等待退避时间
继续
except 其他错误:
ROLLBACK
失败
这里有两个关键点:
- 每次重试都必须结束旧事务;
- 业务计算必须在新事务中重新读取最新状态。
重试次数、退避算法和是否允许重复执行,取决于业务操作是否幂等。一个已经发送外部消息的事务,不能仅靠数据库重试循环保证业务层不重复。
13. 事务生命周期中的常见陷阱
13.1 未关闭的游标会延长读事务
在许多 SQLite API 中,准备好的语句只有在:
- 执行完成;
sqlite3_reset();sqlite3_finalize();- 或绑定提供的等价方法调用后,
才算真正释放相关资源。
例如应用层遍历查询结果时,如果游标没有关闭,即使业务代码已经“读完了需要的行”,底层语句仍可能保持读事务或文件资源。
结果可能包括:
- Checkpoint 无法推进;
- WAL 文件增长;
- 写者或 Checkpoint 返回忙状态;
- 测试环境正常,生产环境因连接池滞留而异常。
13.2 事务中包含网络调用
下面的结构风险很高:
BEGIN
UPDATE 本地数据库
调用远程 HTTP 服务
等待远程服务响应
UPDATE 更多数据
COMMIT
网络延迟、重试和远程服务故障都会延长 SQLite 事务。WAL 下其他读者可能仍能工作,但其他写者、Checkpoint 和 WAL 回收会受到影响。
更安全的结构通常是把外部调用放在数据库事务之外,或者使用明确的状态机和幂等键,而不是让数据库写事务跨越不可控的外部等待。
13.3 连接池不等于并发写入
连接池可以复用连接,但不能消除 SQLite 的单写者限制。多个连接同时写入时,仍然只有一个连接能够取得写事务资格。
连接池还会引入新的生命周期问题:
- 连接归还前未提交事务;
- 未清理的游标留在连接上;
- 某个连接设置了与其他连接不同的忙超时;
- 连接在错误后未执行回滚。
因此,连接池封装必须明确规定:借出、事务开始、提交、回滚、语句关闭和归还的顺序。
14. WAL 文件为什么会突然变大
WAL 文件增长通常不是单一原因。可以按以下因果路径分析:
路径一:有持续写入
写事务不断追加 frame,WAL 自然增长。
路径二:自动 Checkpoint 没有完全回收
Checkpoint 被读快照限制,只能复制较早的 frame。
路径三:读事务长期不结束
最老读者的 end mark 长期不移动,后续 frame 无法完全回收。
路径四:Checkpoint 争用或未执行
没有合适的 Checkpoint 调度、连接无法取得相关锁,或者部署代码没有观察 Checkpoint 失败结果。
诊断时可以检查:
PRAGMA journal_mode;
PRAGMA wal_autocheckpoint;
PRAGMA wal_checkpoint(PASSIVE);
并结合应用层观察:
- 活跃事务持续时间;
- 未关闭查询游标;
- 写事务平均和最大持续时间;
SQLITE_BUSY的发生位置;-wal文件大小随时间的变化;- 是否有多个进程使用同一个数据库文件。
不要直接删除正在使用的 -wal 或 -shm 文件。它们可能包含主数据库尚未合并的已提交内容,强行删除可能破坏数据库一致性。正确的恢复和清理应通过关闭连接、完成必要的 Checkpoint 或使用 SQLite 提供的数据库操作完成。
15. WAL、崩溃和 Checkpoint 的关系
WAL 提交后,新数据首先存在 WAL 中。主数据库文件没有立即包含所有最新页面,并不表示提交丢失。
正常打开数据库时,SQLite 会:
- 检测 WAL;
- 根据 WAL 元数据识别有效的提交边界;
- 把主数据库与 WAL 组合成一致视图;
- 必要时执行恢复或后续 Checkpoint。
如果进程在写 WAL 的中途崩溃,未形成有效提交边界的 frame 不应被视为已提交事务的一部分。Checkpoint 也必须遵守 WAL 中的有效提交边界和读者快照。
但是,事务原子性和崩溃后的持久性还受到 PRAGMA synchronous、VFS、操作系统和存储设备实际行为影响。不能因为使用 WAL,就推断所有同步级别都提供相同的断电持久性保证。
16. WAL 的部署边界
16.1 文件系统锁必须可靠
SQLite 的并发正确性依赖 VFS 对文件锁和文件访问语义的实现。把数据库文件放在不可靠的网络文件系统、特殊同步盘或实现不完整的文件系统上,可能导致:
- 锁不互斥;
-shm共享信息不可见;- WAL 读取顺序异常;
SQLITE_BUSY行为与本地磁盘不同;- 更严重的数据一致性风险。
WAL 不是脱离文件系统的纯用户态功能。
16.2 多进程访问需要共享同一数据库环境
如果多个进程访问同一数据库文件,它们必须能够正确访问:
数据库文件
数据库名-wal
数据库名-shm
并且使用兼容的 VFS 和文件锁语义。一个进程使用正常 VFS,另一个进程以不支持 WAL 共享访问的方式打开同一文件,可能导致失败或不兼容。
16.3 WAL 不适合所有数据库形态
WAL 主要针对文件数据库。内存数据库、临时数据库、只读副本、特殊 URI 打开方式和自定义 VFS 可能有额外限制。不能把在普通 demo.db 上成功执行的:
PRAGMA journal_mode = WAL;
直接推断为所有 SQLite 数据库形态都支持相同语义。
17. 回滚日志与 WAL 的选择
可以用下表概括主要差异:
| 特性 | 回滚日志 | WAL |
|---|---|---|
| 读者之间 | 可并行 | 可并行 |
| 读者与写者 | 更容易互相阻塞 | 通常可并行 |
| 写者之间 | 一次一个 | 一次一个 |
| 写入位置 | 修改主数据库,先写回滚日志 | 追加到 WAL |
| 是否需要 Checkpoint | 不使用 WAL Checkpoint | 需要 |
| 长读事务影响 | 可能阻塞写者 | 可能阻塞 WAL 回收 |
| 额外文件 | -journal |
-wal、通常还有 -shm |
| 文件系统要求 | 需要可靠文件锁 | 需要可靠文件锁和共享 WAL 访问 |
选择 WAL 的主要理由通常是读多写少、希望读写并行,而不是“获得多写者能力”。
如果应用写事务很长、读事务也很长,WAL 只是把冲突形态从“读写直接阻塞”转化为“写者可以继续,但 WAL 和 Checkpoint 受到压力”。它并不能消除事务设计问题。
18. 设计和诊断时的因果顺序
遇到 SQLite 锁等待或 WAL 异常时,可以按以下顺序还原事实:
-
确认日志模式
PRAGMA journal_mode; -
确认哪个连接处于事务中
检查是否执行过
BEGIN,是否遗漏COMMIT或ROLLBACK。 -
确认事务类型
是
DEFERRED读事务升级写事务,还是IMMEDIATE直接竞争写锁? -
确认是否存在长查询或未关闭游标
这决定读快照和文件锁是否仍然存在。
-
区分错误类别
是普通
SQLITE_BUSY、SQLITE_BUSY_SNAPSHOT,还是SQLITE_LOCKED? -
检查 WAL 和 Checkpoint
PRAGMA wal_checkpoint(PASSIVE);比较
log与checkpointed的变化。 -
确认忙等待配置
PRAGMA busy_timeout;不要假设某个连接的设置会自动作用于其他连接。
-
最后再调整超时或 Checkpoint 策略
如果根因是长事务、过期快照或不可靠文件系统,提高超时时间只能延迟错误,不能修复原因。
19. 需要牢牢记住的边界
SQLite WAL 的核心关系可以压缩为:
读者通过快照读取:
主数据库 + WAL 中不晚于自己 end mark 的 frame
写者通过写锁串行追加:
一次只有一个写事务可以提交到 WAL
Checkpoint 负责合并:
只复制读者已经不再需要的 WAL 内容
忙等待负责等待资源:
不能把过期快照变新,也不能制造第二个写者
因此:
- WAL 允许读写并发,但不允许多个写事务并发提交;
BEGIN DEFERRED的读后写升级可能遇到SQLITE_BUSY_SNAPSHOT;- 长读事务可能让 WAL 持续增长,即使写入一直成功;
- Checkpoint 不是简单的“把 WAL 清空”,它必须尊重最老读快照;
busy_timeout只能等待适用的锁竞争,不能替代事务重启;- 错误诊断必须区分锁状态、事务快照、Checkpoint 状态和文件系统边界;
- 事务和游标的生命周期,往往比某个单独的 PRAGMA 更决定并发结果。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:SQLite 架构与文件格式:嵌入式数据库、Pager、B-tree 和 VFS
- 下一篇:SQLite 查询与索引:类型亲和性、查询计划、FTS5 和性能边界
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论