侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

database is locked 实测:busy_timeout 到底在等什么

2026-9-15 / 0 评论 / 14 阅读

database is locked 实测:busy_timeout 到底在等什么

问题的样子

多线程往 SQLite 里写的时候,sqlite3.OperationalError: database is locked 是个很容易被放过去的报错:不频繁,重启就好,过几天又来。这次没让它过去,把排查过程按顺序走了一遍,下面每一步都在本机跑过(Python 3.11.16 配 SQLite 3.53.1)。

第一步:先把 timeout 调到 30 秒

一开始的判断是等锁的时间不够长。timeout 调到 30 秒之后,报错变少了,但没有消失。变少这个结果有迷惑性,看上去方向是对的,只是参数没调够。

第二步:把新库的默认值打出来

参数没解决问题,就得先弄清默认值到底是多少。于是把几种情形在本机跑了一遍(Python 3.11.16 配 SQLite 3.53.1):

import sqlite3

c = sqlite3.connect("app.db", isolation_level=None)   # 不传 timeout
for p in ("journal_mode", "synchronous", "busy_timeout", "locking_mode", "foreign_keys"):
    print(p, "=", c.execute(f"PRAGMA {p}").fetchone()[0])

实测输出:

journal_mode = delete
synchronous = 2
busy_timeout = 5000
locking_mode = normal
foreign_keys = 0

busy_timeout 是 5000 而不是 0,因为它跟的是 connect(timeout=) 的默认值。用 timeout=0.2 和 timeout=30 各连了一次,busy_timeout 就是 200 和 30000,代码里写的秒数直接成了等待毫秒数,没有第二层保险。

journal_mode 是 delete,不是 WAL。这个默认值决定了后面那节的现象,读和写要靠文件锁互相错开。

第三步:量一下一条语句实际会等多久

实验是这样搭的:一个连接 BEGIN IMMEDIATE 拿住写锁,加一条 UPDATE,然后挂着 12 秒不提交;另一个连接用不同的 timeout 去抢,记录它从开始等到报错用了多久。

timeout   实际等待   超出 timeout
0         0.00s     0.00s
0.1       0.56s     0.46s
0.5       1.55s     1.05s
1.0       2.47s     1.47s
2.0       4.33s     2.33s
5.0       10.48s    5.48s

每一行报的都是 database is locked。实际等待大概是 timeout 的两倍上下,timeout 越大,绝对超出越多。又把等待方的操作换成「只做 BEGIN」和「BEGIN 加三条写」重测(timeout=1.0),结果是 2.94s、2.68s、2.99s,差别在噪声范围内,说明超出的部分不是按语句条数叠加的。

为什么正好接近两倍,我没查到站得住的解释。实际影响很清楚:它不是「最多等这么久」的保证,靠它保证某条语句在几秒内一定返回,做不到。

第四步:一读一写就复现了

delete 模式下,写入者要拿到写锁才能改数据。先起一个读事务(BEGIN 之后 SELECT 一次,然后不提交),再让另一个连接去写:

journal_mode=delete 写入者 COMMIT 失败,耗时 5.20s → database is locked
journal_mode=wal    写入者 COMMIT 成功,耗时 0.00s

同一份数据,切成 WAL 之后写入者一下就过了。

第五步:根因是一个没关的读事务

这就是「偶发 locked」最常见的形态:某个读接口开了事务,事务中间做了一次慢一点的网络调用,事务一直开着,写入者撞上去被顶回来。写事务本身很短,所以看起来像是随机发生的,其实取决于读接口那一次调用有多慢。

前面那个 30 秒的 timeout 之所以只能缓解,也是这个原因:等待方撞上一个一直开着的事务,等多久都可能不够。

第六步:切到 WAL 之后,读到的可能是旧值

读者事务内第一次读: committed
写入者 UPDATE 了但没提交
读者事务内再读: committed   ← 还是旧值
写入者提交后,读者事务内读: committed
读者开新事务读: uncommitted

WAL 下读到的是快照,同一个事务里的读稳定不变,写入者提交了也不影响正在进行的读。代价就是上面这个:读到的可能是旧数据,报表类查询要记得自己在看哪个时刻的库。写入者还是只有一个能动手,被解开的只是读和写之间的争抢。

第七步:切完 WAL 还要补几处

切 WAL 后 synchronous = 2           ← 还是 FULL,WAL 没有帮你降
c2 上设 synchronous=NORMAL → c2 = 1 / 另一个连接 c1 = 2
busy_timeout 也是连接级:c3(timeout=0.3) = 300 / c1(默认) = 5000

synchronous、busy_timeout、foreign_keys 都是连接级设置,改在一个连接上不影响别的连接,所以它们该待的地方是「开连接的那个函数」,而不是启动时跑一次的初始化脚本。journal_mode 反过来是持久设置,改成 WAL 会写进库文件头,重开还是 WAL。

foreign_keys 默认是 0,外键约束根本没生效,顺手记一下。

第八步:-wal 和 -shm 什么时候在

刚建好(rollback journal)      db:8192B  -wal:无     -shm:无
一条连接切 WAL 并写过           db:8192B  -wal:4152B  -shm:32768B
关掉最后一条连接                db:8192B  -wal:无     -shm:无

只要有连接活着,这两个文件就在。最后一条连接正常关闭时 SQLite 会做一次 checkpoint 再删掉它们。进程被强行终止就没人做这件事了:

子进程运行中                 db:4096B  -wal:12392B  -shm:32768B
子进程收到 SIGTERM 之后       db:4096B  -wal:12392B  -shm:32768B
子进程被 kill -9 之后        db:4096B  -wal:12392B  -shm:32768B
重开后读到的数据: ['先写进去的一行'] / journal_mode=wal
重开又关掉之后                db:8192B  -wal:无  -shm:无

主库文件只有 4KB,写进去的数据其实还在 WAL 里。好消息是重开之后数据一条不少地读出来了,SQLite 自己做了恢复,关闭时也把 WAL 清干净了。所以看到残留的 -wal 文件不用慌,它不等于数据损坏。但按 WAL 的机制,还没合并进主库的数据就写在里面,这两个文件不建议动。

第九步:多线程别共用一个 connection

主线程建的 connection 在子线程用 → ProgrammingError: SQLite objects created in a thread
can only be used in that same thread. ...

check_same_thread=False 就不报这个错了,但那只是关掉了 Python 这层的保护。同一个连接的事务状态是共用的,A 线程 BEGIN、B 线程 COMMIT 这种交叉出现时,SQLite 不会去分辨语句属于谁。现在每个线程自己 connect,连接不跨线程传递。

顺带说一个 Python 自己的坑:

默认 isolation_level='',执行完 INSERT 后 in_transaction=True
另一个连接此刻看到的行数: 0 ← 没提交,看不到
commit 之后另一个连接看到的行数: 1
isolation_level=None,执行完 INSERT 后 in_transaction=False
另一个连接此刻看到的行数: 2 ← 已经落盘了

sqlite3.connect() 默认会在 DML 之前自动开一个事务,忘了 commit,数据就一直没写进去,另一个连接也读不到。习惯上把 isolation_level=None 显式写出来,事务边界自己控制,排查时看得清。

最后落地的连接函数

import sqlite3

def connect(path):
    conn = sqlite3.connect(path, timeout=5.0, isolation_level=None)  # timeout 直接变成 busy_timeout
    conn.execute("PRAGMA journal_mode=WAL")       # 持久设置:改一次就写进库文件头
    conn.execute("PRAGMA synchronous=NORMAL")     # 连接级:WAL 下这个组合是常规做法
    conn.execute("PRAGMA foreign_keys=ON")        # 连接级:默认是关的,别忘了
    return conn

这段原样跑了一遍,pragma 读回来是 journal_mode=walsynchronous=1busy_timeout=5000foreign_keys=1,然后开四个线程各自 connect() 并发写四行,最后总行数 5,都成功了。事务短的时候本来就撞不上,所以这不构成并发能力的证明。

还有两条在这台机器上没法验证的:NFS/SMB 上跑 WAL 的具体行为,以及 SQLite 文档里「WAL 不要用在网络文件系统上」这句,是转述文档,不是实测结论。

取舍:读事务里绝不放网络调用,事务能短就短;写入并发不拿 SQLite 扛多进程高频写,真要那个量级就换 PostgreSQL,而不是继续调 timeout。

    🤞 分享