"database is locked" 不止一种死法:SQLite 并发写的锁升级实测

举报
yd_276135645 发表于 2026/11/03 06:08:17 2026/11/03
【摘要】 同一条 database is locked 报错其实对应两种失败:普通锁等待可以靠 busy_timeout 等过去,而事务从读锁升级到写锁被拒(SQLITE_BUSY_SNAPSHOT)时 busy_timeout 完全不生效、毫秒级就返回。本文用 SQLite 3.45.1 实测复现:timeout=0 时 800 次更新只成功 83 次;WAL 把读者尾延迟从 2473ms 降到 81ms

先说结论

同一条报错 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 全成功。

落地清单

  1. 连接一律显式 timeout=5(Python 默认也是 5,但很多 ORM 会把它设成 0 或用 null)。timeout=0 在并发下等于「不重试」。
  2. 开 WAL:PRAGMA journal_mode=WAL。它是数据库级持久设置,只需设一次,不是每个连接都要设;但每个连接仍建议 PRAGMA busy_timeout=5000,因为 busy_timeout 是连接级的。
  3. 写事务一律 BEGIN IMMEDIATE,尤其是「先查有没有,没有就插」这种读改写逻辑。用 isolation_level=None 手动控制,或者用 ORM 的「立即写」选项。
  4. 别指望 busy_timeout 兜住一切:它只处理锁等待,不处理快照冲突。判断依据就是失败耗时——毫秒级返回的大量 database is locked,几乎一定是锁升级问题。
  5. 写并发再高就该上单写者队列了:一个线程/进程串行消费写请求,其他连接只读。SQLite 的定位是「嵌入式单机库」,把它当 MySQL 用,最后都是在给锁写补丁。
  6. synchronous=NORMAL 配 WAL 是常见取舍(掉电可能丢最后几个事务,但不会坏库);FULL 更安全,写吞吐会掉。这个开关没有标准答案,取决于你的数据能否重建。

一句总结

SQLite 的并发问题里,busy_timeout 是止血带,WAL 是扩血管,BEGIN IMMEDIATE 才是把伤口缝上。三条都用上,一个单文件数据库扛几百 QPS 的读写混合完全没问题;缺了第三条,你会得到一堆文案相同、含义不同的 database is locked。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。