五个进程同时写 SQLite,谁在丢数据

五个进程同时写 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 次。

需要说清楚的两点:

  1. WAL 不是零延迟。8 次里有一次读的最长延迟是 268 毫秒,和其他七次(1.4--7.5 毫秒)差了两个数量级。我推测和 checkpoint 有关,但没做验证,只能当异常点记下来。
  2. 上面的读延迟是「单机、本机 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 卷。所有数字都是本机实测,重复次数写在证据表里;只给中位数和范围,不给单次最好成绩。

相关推荐
FungLeo5 小时前
成为全栈·基础补充·数据库索引:为什么加了索引还是慢
mysql·postgresql·sqlite·sql优化·数据库索引·成为全栈
FungLeo3 天前
成为全栈·Go 后端篇·从 Node SQLite 到 PostgreSQL:把迁移做成独立交付物
postgresql·golang·sqlite·go·数据库迁移·成为全栈
xn71337 天前
本地 AI 知识库跨设备同步:SQLite FTS5、Embedding、Tombstone 和引用校验
人工智能·sqlite·ai编程
北方的银狐-Zero8 天前
OntoL 7.0|面向工业多智能体的语义本体套件,让异构系统真正 “看懂彼此”——OntoL的本体产品矩阵
人工智能·mysql·sqlite·1024程序员节·本体论·本体产品
CS创新实验室10 天前
改了模型,数据库为什么能自动跟上?——Django 迁移(migrations)的底层
数据库·django·sqlite
誰能久伴不乏10 天前
桌面客户端的运行日志系统:SQLite 分块 + 独立写线程 + 文本降级
数据库·c++·qt·架构·sqlite
l1t11 天前
测试用go语言执行脚本把数据保存到SQLite数据库
数据库·golang·sqlite
l1t11 天前
DeepSeek辅助编写连接sqlite的CLI
数据库·golang·sqlite
l1t12 天前
在windows中测试musql数据库
数据库·sqlite·go·1024程序员节