SQLite的WAL模式可能锁定短期读取器

Lobsters Hottest 新闻

摘要

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漏洞

Lobsters Hottest

本文讨论了SQLite WAL-reset机制中一个长期存在的漏洞,该漏洞导致数据丢失和损坏。通过一个100行的C语言工作负载进行复现,以展示竞争条件。

打破 WAL

Hacker News Top

作者描述了使用 Antithesis 与 Claude 在 15 分钟内重现了一个长期存在的 SQLite WAL 重置错误,并在 3.51.3 版本中验证了修复。

rqlite如何(以及为何)掌控SQLite的预写日志

Lobsters Hottest

本文介绍了rqlite(一种分布式SQLite数据库)如何掌控SQLite的预写日志(WAL),从而实现对Raft共识的高效快照,通过将WAL作为增量状态来避免完整的数据库复制。