SQLite的WAL模式可能锁定短期读取器
摘要
SQLite的WAL模式可能会导致短生命周期的只读连接出现“数据库已被锁定”错误,原因是WAL索引文件的内部锁定;设置忙超时或切换到DELETE模式可以解决该问题。
<p><a href="https://lobste.rs/s/uusfyj/sqlite_wal_mode_can_lock_short_lived">评论</a></p>
查看缓存全文
缓存时间: 2026/07/27 01:38
# SQLite WAL 模式可能锁定短时只读读取器
来源:https://hynek.me/til/sqlite-read-only-wal-locked/
2026年7月26日
“SQLite 数据库被锁定”的错误不仅限于写入操作。
网络上已经有很多关于那个臭名昭著的 SQLite 错误的讨论:
> 无法准备 SQLite 语句:数据库被锁定
通常的建议 (https://blog.pecar.me/django-sqlite-dblock/) 大致如下:
1. 使用 WAL 模式 (https://www.sqlite.org/wal.html),
2. 设置非零的忙等待超时,
3. 对于将要执行写入的事务,使用 `BEGIN IMMEDIATE`。
对于拥有长连接数据库池的 Web 应用的典型工作负载来说,这一建议是正确的。1 (https://hynek.me/til/sqlite-read-only-wal-locked/#fn:1)
然而,克苏鲁再一次诅咒我拥有一个非典型的工作负载。我们极少向数据库写入(某些情况下每月不到一次),但每秒读取几十甚至几百次,而且 **没有连接池**,因为这些是独立的进程。换句话说:我们以只读模式(`SQLITE_OPEN_READONLY`)打开数据库,执行一个 `SELECT`,然后关闭数据库。
但因为我们不希望写入操作阻塞读取操作(尽管写入很少),所以我们采取了表面上安全的路径:仍然将数据库设置为 WAL 模式。结果,对于我们的操作方式来说,这反而带来了问题,我们通过惨痛的教训才认识到这一点,因为 SQLite C 库的默认连接超时为 0 2 (https://hynek.me/til/sqlite-read-only-wal-locked/#fn:2),于是我们开始遇到罕见但持续的“数据库被锁定”错误。
## 在 WAL 模式下,读取也意味着写入
问题在于 WAL 的连接生命周期:连接通过 `-shm` 文件进行协调,而打开或关闭一个空的 WAL 数据库可能会短暂地需要独占锁。如果一个新的读取器恰好在这些窗口期间到达,就会收到 `SQLITE_BUSY` 错误,**即使没有任何应用数据正在被写入——甚至已经好几天没有写入过了**:
```
$ ls -la /vmws/config/config.db*
-rw-r----- 1 root root 294912 Jul 21 09:49 /vmws/config/config.db
-rw-r----- 1 root root 32768 Jul 24 18:26 /vmws/config/config.db-shm
-rw-r----- 1 root root 0 Jul 24 18:26 /vmws/config/config.db-wal
```
比较修改时间:这条 `ls` 命令是在 7 月 24 日下午 6:26 执行的。数据库本身自 7 月 21 日以来就没有被碰过,但 `-wal` 和 `-shm` 文件 **刚刚** 被我们的只读读取器写过了。
对于一个实际上只读的系统来说,居然有这么多写入操作!
---
如果默认忙等待超时不是 0,我们可能永远都不会发现这个问题——太好了,问题会大声地暴露出来!
由于写入如此罕见,我们决定将数据库切换回 `DELETE` 模式,此后就再也没有遇到这个错误了。一如既往,全是权衡取舍。
## 复现方法
如果你想重现这个错误,这里有一个仅依赖标准库的 Python 脚本。
它通过并行运行多个进程,每个进程执行一个只读的“打开-查询-关闭”循环,演示了三种场景:
- A:无忙等待超时,数据库处于 WAL 模式。
- B:添加 1 秒的忙等待超时,仍处于 WAL 模式。
- C:无忙等待超时,并且不使用 WAL 模式。
在我 2023 年的 MacBook Pro 上,使用 Python 3.14,我可靠地观察到了场景 A 中出现 1 到 10 次错误,而场景 B 和 C 中为 0。
```
#!/usr/bin/env -S uv run --script
"""
Concurrently read from a SQLite database.
"""
import argparse
import multiprocessing as mp
import pathlib
import sqlite3
import tempfile
def worker(db, rounds, barrier, timeout, q):
locked = 0
for _ in range(rounds):
barrier.wait() # everyone opens at the same instant
try:
con = sqlite3.connect(
f"{db.as_uri()}?mode=ro", uri=True, timeout=timeout
)
con.execute("SELECT v FROM config").fetchone()
con.close()
except sqlite3.OperationalError as e:
if "locked" not in str(e):
raise
locked += 1
q.put(locked)
def scenario(name, db, journal_mode, timeout, workers, rounds):
con = sqlite3.connect(db)
con.execute(f"PRAGMA journal_mode={journal_mode}")
con.execute("CREATE TABLE IF NOT EXISTS config(v)")
con.execute("INSERT INTO config VALUES (42)")
con.commit()
con.close()
barrier, q = mp.Barrier(workers), mp.Queue()
procs = [
mp.Process(target=worker, args=(db, rounds, barrier, timeout, q))
for _ in range(workers)
]
[p.start() for p in procs]
[p.join() for p in procs]
num_errs = sum(q.get() for _ in procs)
print(f"{name:<40} {num_errs:>4} / {workers * rounds} locked")
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument(
"-w",
"--workers",
type=int,
default=64,
help="concurrent short-lived processes (default: 64)",
)
ap.add_argument(
"-r",
"--rounds",
type=int,
default=100,
help="open/select/close cycles per worker (default: 100)",
)
args = ap.parse_args()
with tempfile.TemporaryDirectory() as tmpdir:
db = pathlib.Path(tmpdir, "config.db")
for name, mode, timeout in [
("A) WAL, no busy timeout (the bug)", "WAL", 0.0),
("B) WAL, 1s busy timeout (fix #1)", "WAL", 1.0),
("C) DELETE, no busy timeout (fix #2)", "DELETE", 0.0),
]:
scenario(name, db, mode, timeout, args.workers, args.rounds)
```
典型输出:
```
A) WAL, no busy timeout (the bug) 7 / 6400 locked
B) WAL, 1s busy timeout (fix #1) 0 / 6400 locked
C) DELETE, no busy timeout (fix #2) 0 / 6400 locked
```
这篇博文的产生得益于个人和公司的捐赠 (https://hynek.me/say-thanks/),他们欣赏我的公共工作。
想要更多类似的内容?这是我的免费、低频率、非骚扰式的 *Hynek Did Something* 新闻通讯 (https://buttondown.com/hynek)!它让我能够直接与你分享我的内容,并添加额外的背景信息。
相似文章
再次审视SQLite的WAL-Reset漏洞
本文讨论了SQLite WAL-reset机制中一个长期存在的漏洞,该漏洞导致数据丢失和损坏。通过一个100行的C语言工作负载进行复现,以展示竞争条件。
SQLite 在生产环境中的应用:优化 WAL 模式、并发性和 VFS 层
深入探讨如何优化 SQLite 以用于生产环境,涵盖预写日志模式、检查点策略、并发性改进以及自定义虚拟文件系统层,以实现低延迟的应用服务器性能。
打破 WAL
作者描述了使用 Antithesis 与 Claude 在 15 分钟内重现了一个长期存在的 SQLite WAL 重置错误,并在 3.51.3 版本中验证了修复。
rqlite如何(以及为何)掌控SQLite的预写日志
本文介绍了rqlite(一种分布式SQLite数据库)如何掌控SQLite的预写日志(WAL),从而实现对Raft共识的高效快照,通过将WAL作为增量状态来避免完整的数据库复制。
使用 TLA+ 追踪一个存在16年之久的 SQLite WAL 漏洞
Canonical 的 dqlite 团队使用 TLA+ 对 WAL 检查点机制中一个存在16年之久、可能导致数据库损坏的 SQLite 漏洞进行建模和理解,随后验证了 dqlite 是否受其影响。