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

SQLite 事务与 WAL:锁状态、并发读写、Checkpoint 和忙等待

SQLite 的事务并发,不能只用“SQLite 只有一个写者”概括。要准确理解一次读写操作是否会阻塞、为什么会返回 SQLITE_BUSY、WAL 文件为什么会增长,需要同时观察四个层次:

  1. 事务层BEGINCOMMITROLLBACK 以及自动提交;
  2. Pager 层:把页面读写、日志和锁组织成原子事务;
  3. 文件与 VFS 层:通过操作系统文件锁访问数据库文件、回滚日志或 WAL;
  4. WAL 层:用追加写入和 Checkpoint 把 WAL 中的页面合并回主数据库。

本文以 SQLite 官方稳定版本公开语义为准。示例使用文件数据库和默认的 SQLite CLI;不同语言绑定可能改变错误处理 API,但不会改变下面的并发模型。


1. 先区分事务、连接和锁

1.1 事务属于连接,不属于某条 SQL

SQLite 使用自动提交模式时,每条未显式包在事务中的 SQL 都会隐式开启并结束一个事务。

例如:

UPDATE account SET balance = balance - 10 WHERE id = 1;

如果当前没有显式事务,SQLite 通常会:

  1. 开启一个隐式事务;
  2. 执行 UPDATE
  3. 提交;
  4. 回到自动提交状态。

而下面的多条语句属于同一个事务:

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 写事务大致如下:

  1. 读取主数据库中的旧页面;
  2. 在连接的事务中修改页面;
  3. 把修改后的页面作为 frame 追加到 WAL;
  4. 写入表示提交边界的提交信息;
  5. 提交完成后,其他连接可以看到这批修改。

主数据库文件可能暂时没有这些新页面,但 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
读者与写者     通常可以并行
多个写者       不能并行

“读者与写者可以并行”必须加上两个限定:

  1. 所有写者仍然需要竞争同一个写事务资格;
  2. Checkpoint、锁竞争、文件系统能力和长事务仍可能造成等待或 SQLITE_BUSY

WAL 解决的是读写之间的主要阻塞,不是把 SQLite 变成多写者数据库。


5. BEGIN DEFERREDIMMEDIATEEXCLUSIVE

三种显式事务模式的区别,主要在于什么时候尝试获取写资格

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 模式下,EXCLUSIVEIMMEDIATE 的并发效果基本相同:它们都会获取写事务资格,但不会因为事务类型本身阻止其他连接读取 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

这不是普通的“对方还没释放锁”。逻辑过程是:

  1. A 的读快照基于值 20
  2. B 已经基于更晚状态提交了值 30
  3. A 如果直接升级写事务,会让它的修改建立在过期快照上;
  4. 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_BUSYSQLITE_BUSY_SNAPSHOTSQLITE_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
        失败

这里有两个关键点:

  1. 每次重试都必须结束旧事务;
  2. 业务计算必须在新事务中重新读取最新状态。

重试次数、退避算法和是否允许重复执行,取决于业务操作是否幂等。一个已经发送外部消息的事务,不能仅靠数据库重试循环保证业务层不重复。


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 会:

  1. 检测 WAL;
  2. 根据 WAL 元数据识别有效的提交边界;
  3. 把主数据库与 WAL 组合成一致视图;
  4. 必要时执行恢复或后续 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 异常时,可以按以下顺序还原事实:

  1. 确认日志模式

    PRAGMA journal_mode;
    
  2. 确认哪个连接处于事务中

    检查是否执行过 BEGIN,是否遗漏 COMMITROLLBACK

  3. 确认事务类型

    DEFERRED 读事务升级写事务,还是 IMMEDIATE 直接竞争写锁?

  4. 确认是否存在长查询或未关闭游标

    这决定读快照和文件锁是否仍然存在。

  5. 区分错误类别

    是普通 SQLITE_BUSYSQLITE_BUSY_SNAPSHOT,还是 SQLITE_LOCKED

  6. 检查 WAL 和 Checkpoint

    PRAGMA wal_checkpoint(PASSIVE);
    

    比较 logcheckpointed 的变化。

  7. 确认忙等待配置

    PRAGMA busy_timeout;
    

    不要假设某个连接的设置会自动作用于其他连接。

  8. 最后再调整超时或 Checkpoint 策略

    如果根因是长事务、过期快照或不可靠文件系统,提高超时时间只能延迟错误,不能修复原因。


19. 需要牢牢记住的边界

SQLite WAL 的核心关系可以压缩为:

读者通过快照读取:
主数据库 + WAL 中不晚于自己 end mark 的 frame

写者通过写锁串行追加:
一次只有一个写事务可以提交到 WAL

Checkpoint 负责合并:
只复制读者已经不再需要的 WAL 内容

忙等待负责等待资源:
不能把过期快照变新,也不能制造第二个写者

因此:

  • WAL 允许读写并发,但不允许多个写事务并发提交;
  • BEGIN DEFERRED 的读后写升级可能遇到 SQLITE_BUSY_SNAPSHOT
  • 长读事务可能让 WAL 持续增长,即使写入一直成功;
  • Checkpoint 不是简单的“把 WAL 清空”,它必须尊重最老读快照;
  • busy_timeout 只能等待适用的锁竞争,不能替代事务重启;
  • 错误诊断必须区分锁状态、事务快照、Checkpoint 状态和文件系统边界;
  • 事务和游标的生命周期,往往比某个单独的 PRAGMA 更决定并发结果。

系列导航与关联阅读

官方资料

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