五个进程同时写 SQLite,谁在丢数据
结论先给 :SQLite 的多进程写入不是「会不会失败」,而是「失败由谁承担」。先说清楚一件事:这里丢的是「写不进去」------事务被拒、行没落库,不是已提交的数据损坏。同一个任务,busy_timeout 设成 0 时 1 万行只写进去 800 行,而且这 800 行全部来自同一个进程;设成 100 毫秒,1 万行一行不少。WAL 能让读不被写挡住,但不会让两个写者并行。
三条可核对的事实:
- 【实测】5 个进程各写 2000 行、每 100 行一个事务,
busy_timeout=0:100 个事务里 92 个直接返回database is locked,7 次重复的落库中位数是 800/10000 行。 - 【实测】同样的任务把
busy_timeout设成 100 毫秒:7 次重复全部 10000/10000 行,总耗时中位 0.107 秒。 - 【实测】WAL 模式下,两个进程各跑 10 个「持锁 100 毫秒」的事务,2.97 秒完成------和串行的 2 秒量级吻合,不是一个 1 秒。
这个场景从哪来
Agent 类应用现在很流行把记忆、会话、任务队列直接塞进一个本地 SQLite 文件。单实例时一切正常;一旦你开了多个 worker(或者用户双击了两次应用、起了两个 MCP server),同一个文件就有了多个写者。这时候线上最常见的报错只有一句:
text
sqlite3.OperationalError: database is locked
它到底有多容易发生、busy_timeout 该设多少、开 WAL 能不能解决,这三件事很少有实测数据。我自己造了一个最小负载来量。
实验设置
- Python 3.9.6,SQLite 3.54.0,macOS 27.2,APFS 本机 SSD。
- 表结构:
bench(id INTEGER PRIMARY KEY AUTOINCREMENT, k INTEGER, v TEXT, ts REAL),k上建索引,v是 200 字节的定长文本,模拟一条带内容的记录。 - 每个进程写 2000 行,每 100 行一个事务(20 个事务),5 个进程共 1 万行。
- 所有进程用同一个
multiprocessing.Event对齐,然后同时开跑;计时从放行开始,到最后一个进程结束。 - 每个配置重复 3--7 次,下面写的是中位数,范围在证据表里。
核心循环就是这样:
python
def worker(wid, path, batch, timeout, go):
conn = sqlite3.connect(path, timeout=0, isolation_level=None)
conn.execute(f"PRAGMA busy_timeout={timeout}")
go.wait()
for _ in range(2000 // batch):
conn.execute("BEGIN IMMEDIATE")
conn.executemany(
"INSERT INTO bench(k,v,ts) VALUES(?,?,?)",
[(wid, "x" * 200, time.time())] * batch,
)
conn.execute("COMMIT")
这段是省略了异常处理的骨架。真实 harness 在 BEGIN IMMEDIATE 和 COMMIT 外面套了 try/except sqlite3.OperationalError,撞锁时把次数记进 locked、把这一批记进 failed,然后继续下一批------下面逐进程表里的「撞锁 20 次」就是这么来的。例外情况只保留一句:Python 3.9 的 sqlite3.connect(timeout=0) 下,抢锁失败抛的就是 database is locked。
一、busy_timeout=0 时,失败是「赢家通吃」
先把 busy_timeout 设成 0,也就是「拿不到锁就立刻失败」。5 个进程、1 万行:
| busy_timeout | 失败事务 / 100 | 落库行数 / 10000 | 总耗时(中位) |
|---|---|---|---|
| 0 | 92 | 800 | 0.006 s |
| 10 ms | 6 | 9400 | 0.057 s |
| 50 ms | 1 | 9900 | 0.101 s |
| 100 ms | 0 | 10000 | 0.107 s |
| 1000 ms | 0 | 10000 | 0.077 s |
最后一行比 100 毫秒那行还快,这是抖动,不是「等得越久越快」------见第九节。
0.006 秒是「快到不正常」的那种快------因为它根本没写。1 万行里 9200 行没进去。
更值得看的是逐进程分布。三次重复里赢家都是同一个进程:它自己分别提交 1900、2000、700 行,自己只撞锁 1、0、13 次;另外四个进程每次都是 0 行、20 次撞锁。下表是其中一次(rep2):
text
进程 1: 提交 2000 行, 撞锁 0 次
进程 0: 提交 0 行, 撞锁 20 次
进程 2: 提交 0 行, 撞锁 20 次
进程 3: 提交 0 行, 撞锁 20 次
进程 4: 提交 0 行, 撞锁 20 次
rep2 是赢家拿满 2000 行的那次;上文 800 行的中位来自另一次运行,两者不是同一次。
不是「每个进程失败 20%」,是赢家每一批都抢到了锁,另外四个从头到尾一个事务都没成功 ------所以 1 万行里落库的那部分是单进程写进去的,不是五个进程分摊后的残缺。我推测原因是 BEGIN IMMEDIATE 抢写锁失败会立刻返回,抢输的进程马上进入下一个批次再抢;而赢家提交完立刻开始下一批,它的循环里没有失败分支,节奏更紧,于是持续赢。
如果你的代码把异常吞掉、或者只 try/except 记个日志继续跑,用户看到的现象就是「数据莫名其妙少了一大半」,而且丢得非常有规律------落库的全在同一个 worker 上;没抢到锁的四个颗粒无收,赢家自己也可能漏几批。
顺带说明一个容易搞混的点:Python 的 sqlite3.connect() 默认 timeout=5.0,也就是默认会等 5 秒,上面那个 0 是我显式设的。但很多项目会为了「快速失败」把它设成 0,或者自己写重试时忘了设 busy handler。上面这张表就是那一边的代价。
二、100 毫秒就够,重试能把最后一点补上
busy_timeout 的作用是让「抢不到锁」变成一个阻塞等待,而不是错误。从表里看:
- 10 毫秒还不够,仍有 6% 的事务失败;
- 50 毫秒只剩 1 次失败;
- 100 毫秒之后 7 次重复全部零失败。
也就是说,在这个负载下,锁的实际持有时间是亚毫秒级到几十毫秒,100 毫秒的窗口足以让等待方等到。但不要 把希望全寄托在它身上:busy_timeout 只覆盖「等锁」这一种情况,等待超时后照样抛 database is locked。生产代码里该有的重试还是要写。
我顺手测了「busy_timeout=0 + 应用层重试」这一组,作为对照:
| 配置 | 落库行数 | 撞锁次数 | 总尝试次数 | 耗时(中位) |
|---|---|---|---|---|
| 不重试 | 800 / 10000 | 92 | 100 | 0.006 s |
| 重试(最多 40 次,指数退避) | 10000 / 10000 | 21 | 121 | 0.114 s |
重试的中位耗时比不重试多 0.1 秒就把 92% 的丢失补回来了;不过 5 次里有 1 次是 1.45 秒(0.06、0.094、0.114、0.116、1.452),尾部比中位难看得多。注意「撞锁 21 次」远小于「失败 92 个事务」------因为重试之后,大多数冲突在第二次尝试时就成功了。
(第一版脚本我用固定间隔重试,5 个进程互相把对方挤掉,跑了两分钟没结束,属于活锁。加了随机抖动才跑通。这也是一个可以抄走的细节:重试间隔要带抖动,不能所有进程用同一个退避节奏。)
三、WAL 不让写入变并行
这是最容易被误解的一点。很多人以为开了 WAL 就能多个写者同时写------不是。
WAL 的并发模型是「一个写者 + 多个读者 」:读不再需要等写,但写和写之间仍然要抢同一把写锁。我直接量了持锁时间:让每个事务在 COMMIT 前故意 sleep(0.1),制造 100 毫秒的持锁窗口,每个进程 10 个事务。
| 模式 | 1 进程 | 2 进程 | 4 进程 |
|---|---|---|---|
| WAL | 1.74 s | 2.97 s | 5.11 s |
| DELETE(回滚日志) | 1.63 s | 2.22 s | 6.03 s |
1 个进程 10 个事务 = 约 1 秒(10 × 100ms);2 个进程 20 个事务 = 约 2 秒;4 个进程 40 个事务 = 约 4--6 秒。总耗时随写者数量线性增长,说明它们完全在排队。WAL 那一列没有任何「并行」的迹象。
还有一个可以量出来的细节:2 个进程跑 WAL 时,单个事务从 BEGIN IMMEDIATE 到 COMMIT 的中位耗时是 210 毫秒------正好是「等对方 100 毫秒 + 自己干 100 毫秒」。等锁的时间是实打实算在你头上的。
但 WAL 依然值得开,因为它对顺序写入本身更快。同样是 5 进程 1 万行、没有人为持锁:DELETE 模式 0.112 秒,WAL 模式 0.027 秒。差 4 倍。原因是回滚日志模式每次提交要写 journal 再删掉,WAL 只追加。
四、写锁为什么是全局的
回滚日志模式下,事务的锁是分级的:读拿 SHARED,准备写时升到 RESERVED,真正写文件时升到 EXCLUSIVE。升级过程中如果别人占着锁,你就得退回去等------这也是为什么 BEGIN(DEFERRED)比 BEGIN IMMEDIATE 更容易出问题:它先拿共享锁,等到第一次 INSERT 才去升级,升级失败时你可能已经读了一堆数据,重试成本更高,WAL 下还会直接给你 SQLITE_BUSY_SNAPSHOT,重试必须整个事务重来。
WAL 模式取消了「读者挡写者」这件事:读者读的是一份快照,写者往 -wal 文件追加,两边不碰同一把锁。但写者和写者之间仍然只有一把锁------WAL 文件同一时刻只允许一个写事务追加。这就是第三节那张表里耗时严格随进程数线性增长的原因:不是 SQLite 没优化,是它的并发模型本来就这么设计的。
想清楚这一点,很多疑问会自动消失:为什么加机器、加进程都没用;为什么 busy_timeout 是唯一的缓冲;为什么真正的解法是「减少事务数」而不是「提高并发度」。
五、WAL 真正的收益在读
写不并行,那 WAL 好在哪?好在读。
同样的场景:一个写进程持续以 100 毫秒为周期提交事务,三个读进程每 20 毫秒做一次 SELECT count(*),跑 2 秒,重复 8 次:
| 模式 | 读延迟中位 | 读的最长延迟(8 次的中位) | 最坏一次 |
|---|---|---|---|
| DELETE | 0.05 ms | 307 ms | 781 ms |
| WAL | 0.03 ms | 4 ms | 268 ms |
中位数两边都很低,因为大部分查询撞不到写事务;差别全在长尾。DELETE 模式下读进程要等写事务把整把锁放掉,最长延迟中位 307 毫秒,最坏那次 781 毫秒------对一个「查一下有没有这条记忆」的请求来说,这就是一次卡顿。WAL 下同样的指标是 4 毫秒。
不设 busy_timeout 时更直接:DELETE 模式下读进程也会报 database is locked,三次里每个读进程 2 秒内报了 0--22 次;WAL 三次都是 0--1 次。
需要说清楚的两点:
- WAL 不是零延迟。8 次里有一次读的最长延迟是 268 毫秒,和其他七次(1.4--7.5 毫秒)差了两个数量级。我推测和 checkpoint 有关,但没做验证,只能当异常点记下来。
- 上面的读延迟是「单机、本机 SSD、读多写少」的条件。真实 Agent 负载的读写比完全不同,不能直接套。
六、批次大小比调什么参数都重要
同一套写入(5 个进程、1 万行、WAL、连接默认 synchronous=NORMAL),只改「一个事务塞多少行」:
| 每事务行数 | 事务数 | 总耗时(中位) |
|---|---|---|
| 1 | 10000 | 3.990 s |
| 100 | 100 | 0.027 s |
| 1000 | 10 | 0.024 s |
一行一提交比一百行一提交慢 148 倍。
为了看这里面有多少是 fsync 的账,我又用回滚日志跑了一遍同一组(DELETE 模式、synchronous=FULL,也就是每个事务都真的 fsync),9 次取中位:
| 每事务行数 | 1 行 | 100 行 | 1000 行 |
|---|---|---|---|
| WAL + NORMAL | 3.990 s | 0.027 s | 0.024 s |
| DELETE + FULL | 3.281 s | 0.079 s | 0.049 s |
两边「一行一提交」都是 3 秒上下,但方向相反:加了 fsync 的 DELETE+FULL(3.281 秒)反而比 WAL+NORMAL(3.990 秒)快。这组对照拆不开两件事------既换了日志模式(DELETE/WAL),也换了 synchronous(FULL/NORMAL);所以一百行一提交时 DELETE+FULL 0.079 秒比 WAL+NORMAL 0.027 秒慢约 2.9 倍,也不能全记在 fsync 账上(回滚日志每个事务写、删 journal 也有开销)。148 倍只能归到「每事务固定开销」这一层:BEGIN/COMMIT、WAL 帧写入、Python 侧调用一起被放大了 100 倍。fsync 占多少,这组数据给不出答案。
这条比 busy_timeout、WAL、synchronous 都重要。很多「SQLite 写不动」的问题,本质是一行一个事务。
七、synchronous:只测出一点差别
社区常推荐 WAL + synchronous=NORMAL,理由是 NORMAL 在 WAL 下不 fsync,快很多。我照着测了------注意每个连接都要单独设,我第一版就栽在这里(见本节最后)。这次每组 15 次,bench5.py,日志 sqlite-lock-sync2.jsonl:
| 模式 | NORMAL | FULL |
|---|---|---|
| WAL | 0.023 s(0.021--0.028) | 0.027 s(0.025--0.031) |
| DELETE | 0.076 s(0.073--0.106) | 0.077 s(0.075--0.107) |
WAL 下 FULL 比 NORMAL 高约 17%(0.023 → 0.027),但两组范围重叠:NORMAL 最慢 0.028,FULL 最快 0.025。这个差不能当成稳定的百分比。DELETE 下则完全没有差别。
方向是「NORMAL 不慢」,但幅度远小于「批次大小」那种量级------在这 0.03 秒里,真正的成本是进程启动和 Python 侧的开销,fsync 只占一部分。想看到 fsync 的真实代价,得把数据量放大到几十万行,或者换更慢的盘。这一点我没测。
顺带记一个我自己犯过的错:第一版这个对比里,harness 的 worker 只设了 busy_timeout,没设 synchronous,两列跑的是同一个默认值,那组 0.032 / 0.040 的差别纯属波动。上面这版是补了 per-connection PRAGMA 重跑的,PRAGMA synchronous 读回来分别是 1(NORMAL)和 2(FULL)。
顺带一个容易踩的坑:journal_mode=WAL 是写进数据库文件的持久设置,设一次就够;但 busy_timeout 和 synchronous 是每个连接的属性,每次连接都要重新设。
八、可以拿去用的配置
python
import sqlite3, time
conn = sqlite3.connect("agent.db", timeout=0, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL") # 持久,只需设一次
conn.execute("PRAGMA synchronous=NORMAL") # 每个连接都要设
conn.execute("PRAGMA busy_timeout=5000") # 每个连接都要设
def write_batch(conn, rows):
rows = list(rows) # 必须物化:executemany 会消费生成器,重试时它已经空了,会静默提交空事务
for attempt in range(6):
try:
conn.execute("BEGIN IMMEDIATE")
conn.executemany(
"INSERT INTO memory(kind, body, ts) VALUES(?,?,?)", rows)
conn.execute("COMMIT")
return
except sqlite3.OperationalError as e:
if "locked" not in str(e) and "busy" not in str(e):
raise
try:
conn.execute("ROLLBACK")
except sqlite3.Error:
pass
time.sleep(0.002 * (2 ** attempt) * (0.5 + 0.5 * time.time() % 1))
raise RuntimeError("sqlite busy after 6 attempts")
几个要点:
BEGIN IMMEDIATE一开始就拿写锁。用它而不是默认的BEGIN(DEFERRED),能把冲突提前到事务开始,而不是等到第一次写------后者会给你一个更难处理的「升级锁失败」。busy_timeout设 5000 毫秒,比实测需要的 100 毫秒宽两个数量级。理由是现实里的慢查询、checkpoint、其他进程的大事务都可能把锁持有时间拉长;但等待不是免费的------最坏情况下每次BEGIN都可能阻塞 5 秒,叠上最多 6 次重试就是 30 秒。放在请求路径或 Agent 任务队列里,请自己加个总时限(比如 2 秒),或者把这一层的busy_timeout降到 100--500 毫秒。- 重试一定要带随机抖动,否则多个进程同步退避会互相撞。
- 批量写,100--1000 行一个事务。
九、同一配置能差 130 倍
写这一节是因为它影响你怎么读上面所有表格。
5 个进程、DELETE 模式、busy_timeout=5000、完全相同的代码,重复 5 次拿到的是:0.103、0.108、0.112、0.134、1.216 秒。最快和最慢差约 12 倍。1 个进程那组更夸张:0.008、0.009、0.097、0.288、1.069 秒,差 133 倍。
同一个二进制、同一份数据、同一台机器,我控制的变量都一样;具体是什么在变,我没有做归因(后台索引、APFS 的写合并、其他进程的 IO 都只是推测)。所以:
- 我只报中位数,并且把范围一起写进证据表------单次最好成绩没有意义。
- 「0.1 秒」这种量级的结论只适合用来做数量级判断,不要拿去做「A 比 B 快 3%」这种比较。
- 反过来,WAL 那组的 5 次是 0.027--0.038 秒,比 DELETE 那组稳。稳定看起来是路径更短、抖动源更少,但这条同样没有做归因。
如果你的机器上测出来和这里差一个数量级,先怀疑磁盘和后台负载,再怀疑结论。
十、我没测什么
按惯例把边界写清楚:
- 真实 Agent 负载。我自己造的是「1 万行 / 0.1 秒」的短时爆发;真实应用是每秒几行到几十行的持续写入。后者的冲突概率按理说低得多,"赢家通吃"未必会出现------这是推测,我没有跑持续写入的负载。
- 网络文件系统。NFS/SMB 上的锁语义和本地完全不同,本文所有结论仅限本机 APFS。
- 超过 10 个进程、多机同时写。
- 断电、SIGKILL 后的恢复。WAL 的持久性承诺我没验证,不要拿这篇当崩溃安全性的依据。
- 长时间运行的 checkpoint 压力 、
-wal文件增长、以及我在第四节看到的那次 268 毫秒尖峰。 - ORM 和连接池(SQLAlchemy、Prisma、Drizzle 等)------它们各自的默认参数会盖掉 DBAPI 层的设置,这是另一层坑。
复现材料
- 基准脚本:
bench3.py(写入矩阵)、bench5.py(per-connection synchronous 对照)、benchread.py(读写并发) - 原始记录:
sqlite-lock-raw4.jsonl、sqlite-lock-perproc.jsonl、sqlite-lock-readers8.jsonl、sqlite-lock-readers-nobt.jsonl、sqlite-lock-sync2.jsonl - 数字→来源对照表:
sqlite-lock-numbers.md
本文的终端输出与数字在 Python 3.9.6、SQLite 3.54.0、macOS 27.2 上产生(2026-11-06),同一台机器、同一个 APFS 卷。所有数字都是本机实测,重复次数写在证据表里;只给中位数和范围,不给单次最好成绩。