"database is locked" 不止一种死法:SQLite 并发写的锁升级实测
先说结论
同一条报错 database is locked,在 SQLite 里至少对应两种完全不同的失败:一种是「大家都在写,等一会儿就好」,另一种是「你的事务已经读过数据,锁升级被拒绝,等多久都没用」。前者靠 busy_timeout 解决,后者只能靠 BEGIN IMMEDIATE 提前拿写锁。只看报错文案分不出这两种,只有看失败耗时才分得出——这是本文实测出来的最有用一条。
测试环境:SQLite 3.45.1、Python 3.12(sqlite3 模块)、4 线程各 300 次更新、synchronous=FULL。所有数字都是本机真实跑出来的。
第一幕:timeout=0 时,800 次更新只成功 83 次
同一张表、同一行做 UPDATE t SET n=n+1,4 个线程竞争:
A rollback + timeout=0 errors=717 n= 83/800 mean_wait=0.56ms max=24ms
B rollback + timeout=5 errors= 0 n= 800/800 mean_wait=17.67ms max=3245ms
A 组 90% 的操作直接失败:SQLite 在 rollback journal 模式下写事务要拿 EXCLUSIVE 锁,拿不到立刻返回 database is locked,不会等。把 sqlite3.connect(timeout=5) 一加(等价于 PRAGMA busy_timeout=5000),800 次全成功。
但 B 组有个数字值得盯着看:max_wait = 3245ms。也就是说在只有 4 个写线程的极小场景里,已经有一次写入场景等了 3.2 秒。busy_timeout 的语义是「最多等这么久,等不到就报错」,它并不保证公平性——被唤醒的线程可能又要重新排队。所以调大 timeout 只是把「报错」换成「偶发 3 秒延迟」,这两件事在接口超时面前往往一样致命。
第二幕:WAL 救的是读者,不是写者
rollback journal 模式下,写事务提交时要短暂持有独占锁,读者会被挡在门外。3 秒压测(1 个写线程,每轮插 2000 行并提交;3 个读线程每 10ms 做一次 SELECT count(*)):
rollback writes=153 reads= 111 p50=13.75ms p95=437.79ms p99=953.88ms max=2472.9ms
WAL writes=267 reads= 257 p50=21.00ms p95= 63.69ms p99= 75.20ms max= 81.0ms
读者尾延迟从 2473ms 降到 81ms,翻了 30 倍;顺便写吞吐也从 153 涨到 267(WAL 提交不必每次都 fsync 主库)。代价是 p50 反而从 13.75ms 涨到 21ms——WAL 读要维护 reader mark,读多写少的场景有这个固定开销,别指望它免费。
同一份结论换个角度看:rollback 模式下「读」会被「写提交」阻塞,WAL 模式下不会。如果你的业务是「很多人在读、少数人在写」,WAL 基本是必选项;如果几乎不并发,就别为了「看起来专业」去改开销。
第三幕:真正阴的那个坑——锁升级
WAL 也只有一个写者。很多人听说「WAL 允许多读一写」,就把事务写成:
conn.execute("BEGIN DEFERRED")
row = conn.execute("SELECT n FROM t WHERE id=1").fetchone() # 先读
conn.execute("UPDATE t SET n=n+1 WHERE id=1") # 后写 —— 锁升级
conn.execute("COMMIT")
实测结果(同样是 4 线程 × 300 次,已经是 WAL + busy_timeout=5000):
D WAL + DEFERRED upgrade errors=988 n= 212/1200 mean_wait=2.62ms max=10ms
E WAL + BEGIN IMMEDIATE errors= 0 n=1200/1200 mean_wait=9.92ms max=735ms
D 组 82% 失败,而且 max_wait 只有 10ms——这不是「等不到」,是根本没等。原因:BEGIN DEFERRED 时事务拿到的是读快照;当你从读升级到写时,如果期间别的连接已经提交过,你的快照就过期了,SQLite 返回 SQLITE_BUSY_SNAPSHOT,这个错误不经过 busy handler,busy_timeout 设成 5 秒、5 分钟都一样立刻失败。
最坑的地方在于:它抛出来的异常文案和普通锁冲突一模一样,都是 database is locked。所以你看到日志里一堆 database is locked,很容易得出「并发太高,加大 timeout」的错误结论,然后越调越糟——因为真正的修法是不要让事务从读锁升级,在开始时就明确「我要写」:
conn.execute("BEGIN IMMEDIATE") # 一开始就拿写锁,拿不到才走 busy_timeout
E 组换成 BEGIN IMMEDIATE 后 1200/1200 全成功。
落地清单
- 连接一律显式
timeout=5(Python 默认也是 5,但很多 ORM 会把它设成 0 或用null)。timeout=0在并发下等于「不重试」。 - 开 WAL:
PRAGMA journal_mode=WAL。它是数据库级持久设置,只需设一次,不是每个连接都要设;但每个连接仍建议PRAGMA busy_timeout=5000,因为 busy_timeout 是连接级的。 - 写事务一律
BEGIN IMMEDIATE,尤其是「先查有没有,没有就插」这种读改写逻辑。用isolation_level=None手动控制,或者用 ORM 的「立即写」选项。 - 别指望
busy_timeout兜住一切:它只处理锁等待,不处理快照冲突。判断依据就是失败耗时——毫秒级返回的大量database is locked,几乎一定是锁升级问题。 - 写并发再高就该上单写者队列了:一个线程/进程串行消费写请求,其他连接只读。SQLite 的定位是「嵌入式单机库」,把它当 MySQL 用,最后都是在给锁写补丁。
synchronous=NORMAL配 WAL 是常见取舍(掉电可能丢最后几个事务,但不会坏库);FULL更安全,写吞吐会掉。这个开关没有标准答案,取决于你的数据能否重建。
一句总结
SQLite 的并发问题里,busy_timeout 是止血带,WAL 是扩血管,BEGIN IMMEDIATE 才是把伤口缝上。三条都用上,一个单文件数据库扛几百 QPS 的读写混合完全没问题;缺了第三条,你会得到一堆文案相同、含义不同的 database is locked。
- 点赞
- 收藏
- 关注作者
评论(0)