TL;DR
Python 连接 SQLite 时设置 timeout=10,遇到并发写入依然立即抛 sqlite3.OperationalError: database is locked——这不是 timeout 没设对,而是 SQLite 的锁升级(lock upgrade)路径根本不调用 busy handler。当你的连接已经通过 SELECT 持有 SHARED 锁、再尝试 UPDATE/INSERT 升级为 RESERVED 锁时,SQLite 判定这是潜在死锁,直接返回 SQLITE_BUSY,不等待、不重试、timeout 形同虚设。实测本机(Python 3.10 + SQLite 3.51.3)复现:两个连接分别持有 SHARED 与 RESERVED 锁,第三个写请求 0.00 秒立即报错。这不是 Python 的 bug,而是 SQLite 事务模型的设计行为(cpython#124510、cpython#130971 均以 not_planned 关闭)。解法不是加大 timeout,而是:短事务 + BEGIN IMMEDIATE 提前抢写锁 / WAL 模式 / 应用层重试。
现象:timeout 参数完全失效
你看到的报错
sqlite3.OperationalError: database is locked
这条报错出现在:Flask/Django 应用高并发写 SQLite、爬虫多进程入库、pytest 并发测试共享临时库。最常见的心路历程是:
- 第一次遇到:去搜索,答案说「连接加
timeout=10」; - 加了 timeout,重启,看起来好了;
- 流量一上来,又崩了,而且崩溃瞬间完全没有等待;
- 打开 SQLite 官方文档,发现 timeout 只对「第一次尝试获取锁」生效,对锁升级无效;
- 于是死循环:调大 timeout → 无效 → 怀疑版本 → 换 WAL → 又遇到别的坑。
本文的目标是让第 3 步的人直接跳到第 5 步的正确解法,并把第 4 步的机制讲透。
关键问题:为什么 timeout 无效
SQLite 的 busy handler 机制是这样的:连接在首次尝试拿锁失败时,会进入等待循环,循环上限就是 timeout。但有一类失败不进入这个循环——锁升级失败。SQLite 官方文档原文(sqlite.org/lang_transaction.html):
If a transaction does not start with a BEGIN IMMEDIATE, then it starts as a read transaction with a SHARED lock. ... If a second connection tries to upgrade ... the upgrade will fail immediately with SQLITE_BUSY.
翻译成大白话:两个连接都先 SELECT(各自拿到 SHARED 读锁),然后都想写,谁先尝试升级谁立即失败。因为 SQLite 判断:如果让你等,你也等不到——对方也在等你的锁释放,这就是死锁,干脆直接拒绝。
实测复现:两种路径,天壤之别
为了确认机制,我在本机(Python 3.10.12 + SQLite 3.51.3)写了一个最小复现。注意两个进程都用 isolation_level=None + 手动 BEGIN,这是最能暴露锁行为的写法。
路径一:首次加锁失败(timeout 生效)
进程 A 先 BEGIN + UPDATE(持有 RESERVED 写锁),sleep 4 秒后 commit。进程 B 直接 INSERT(第一次尝试拿写锁):
# 进程 B:直接 INSERT,无前置 SELECT
con = sqlite3.connect(DB, timeout=10)
cur = con.cursor()
cur.execute("INSERT INTO t VALUES (2)") # 首次拿写锁,失败 → 进入 busy 等待
实测输出:
B: 成功写入, 耗时 1.84s
A: 已持RESERVED锁(已UPDATE), sleep 4s
A: commit
B 等了约 1.84 秒(A 释放锁后)写入成功——timeout 生效,行为符合直觉。
路径二:锁升级失败(timeout 失效,核心坑)
进程 A 同上:BEGIN + UPDATE + sleep 4 秒。进程 B 先 SELECT(拿到 SHARED 读锁),然后再 UPDATE:
# 进程 B:先 SELECT 拿 SHARED 锁,再 UPDATE 升级
con = sqlite3.connect(DB, timeout=10)
cur = con.cursor()
cur.execute("BEGIN")
cur.execute("SELECT * FROM t") # 拿到 SHARED 锁
cur.execute("UPDATE t SET a=98 WHERE a=1") # 尝试升级 RESERVED → 立即失败
实测输出:
B: SELECT 完成 (0.00s)
B: OperationalError: database is locked (耗时 0.00s) <-- timeout=10 没等就报错
A: 已持RESERVED锁(已UPDATE), sleep 4s
A: stderr: sqlite3.OperationalError: database is locked
两个关键观察:
- B 的 UPDATE 0.00 秒立即报错,timeout=10 完全没起作用;
- A 的 commit 也报错了——因为 B 虽然 UPDATE 失败,但连接还活着,仍然持有 SHARED 锁,A 想升级到 EXCLUSIVE 提交也失败。这就是「锁升级死锁」的完整闭环:两个连接互相拿着对方需要的锁,谁都写不进去。
这就是为什么生产环境一旦进入这种状态,所有写请求会连续失败,直到某个连接超时关闭——表现上很像「数据库卡死」。
机制拆解:SQLite 五级锁与升级路径
为什么 SQLite 要这么设计
SQLite 是单写多读的嵌入式数据库,锁模型有五个级别:
| 级别 | 名称 | 允许并存 | 说明 |
|---|---|---|---|
| 1 | UNLOCKED | 所有连接 | 初始状态 |
| 2 | SHARED | 多个连接可同时持有 | 读锁,SELECT 时获取 |
| 3 | RESERVED | 一个写者 + 多个读者 | 写者已预留升级权,可继续读 |
| 4 | PENDING | 一个写者 | 写者等所有读者退出,禁止新 SHARED |
| 5 | EXCLUSIVE | 独占 | 写锁,commit 时持有 |
升级路径:UNLOCKED → SHARED(读)→ RESERVED(准备写)→ PENDING → EXCLUSIVE(提交)。
死锁场景发生在「两个连接都想从 SHARED 升级到 RESERVED」:
连接 A:SHARED → RESERVED ✅(先到先得)
连接 B:SHARED → RESERVED ❌(立即 SQLITE_BUSY)
SQLite 内核在 sqlite3BtreeBeginTrans 里判断:如果自己持有了 SHARED 而对方持有 RESERVED,继续等待必然死锁(你要的锁在对方手里,对方要的锁有一部分在你手里),所以直接返回 SQLITE_BUSY,连 busy handler 都不调用。这是 SQLite 的内核级防死锁设计,Python 的 timeout 参数只是把 busy handler 的超时传进去,对这条路径无能为力。
Python 为什么更容易踩坑
Python 的 sqlite3 模块默认 isolation_level="",会自动帮你包事务:任何 DML 语句(INSERT/UPDATE/DELETE)执行前自动 BEGIN。但 SELECT 不会自动 BEGIN(除非 autocommit=False 的新 API)。这就造成一个常见的隐性组合:
# 典型踩坑代码:先查后写
rows = cur.execute("SELECT ...").fetchall() # 拿到 SHARED 锁(隐式)
# ... 处理逻辑,耗时操作 ...
cur.execute("UPDATE ...") # 升级 RESERVED → 可能立即失败
代码看起来毫无问题,但 SELECT 和 UPDATE 之间一旦有其他连接抢占了 RESERVED 锁,你的 UPDATE 就 0 秒报错。而且由于 Python 自动 BEGIN 的时机不可见,很多人根本不知道自己的 SELECT 已经持锁了。
解决方案:四条路径按优先级
方案一(最推荐):BEGIN IMMEDIATE 提前抢写锁
写操作一开始就声明要写,避免从 SHARED 升级:
con = sqlite3.connect(DB, timeout=10)
cur = con.cursor()
cur.execute("BEGIN IMMEDIATE") # 直接拿 RESERVED 锁,不走 SHARED
try:
cur.execute("UPDATE ...")
con.commit()
except Exception:
con.rollback()
raise
BEGIN IMMEDIATE 拿锁失败时会正常走 busy handler,timeout 生效。这是官方推荐的写事务打开方式:把「可能死锁的升级」变成「一开始就竞争」。
方案二:WAL 模式(读多写少的首选)
con = sqlite3.connect(DB)
con.execute("PRAGMA journal_mode=WAL")
WAL 模式下读者不持 SHARED 锁阻塞写者,写者之间仍互斥,但读写完全并行,锁升级冲突大幅减少。注意 WAL 模式需要 SQLite 3.7+(2009 年后所有版本都有),且会产生 -wal 和 -shm 文件,备份/拷库时要一起拷。
方案三:应用层重试(兜底)
即使用了 IMMEDIATE/WAL,极端并发下仍可能遇到 database is locked。加个重试装饰器:
import sqlite3, time
def retry_on_locked(max_retries=5, delay=0.1):
def deco(fn):
def wrapper(*args, **kwargs):
for i in range(max_retries):
try:
return fn(*args, **kwargs)
except sqlite3.OperationalError as e:
if "locked" in str(e) and i < max_retries - 1:
time.sleep(delay * (i + 1))
continue
raise
return fn(*args, **kwargs)
return wrapper
return deco
注意:重试必须重新开启事务(rollback 后重来),不能在同一事务里重试同一语句——因为失败后事务已处于不可用状态。
方案四:缩短事务窗口
把「SELECT + 业务处理 + UPDATE」拆开,业务处理放到事务外:
# 事务外先读
rows = cur.execute("SELECT ...").fetchall()
# 计算完成后再开事务写
cur.execute("BEGIN IMMEDIATE")
for row in rows:
cur.execute("UPDATE ...", ...)
con.commit()
原则:事务里只放必要的读写,任何耗时操作(网络请求、sleep、用户交互)一律移出事务。这比任何锁模式优化都根本。
排障决策表:遇到 database is locked 先对号入座
| 场景 | 根因 | 第一动作 |
|---|---|---|
| 偶发一次,重试后成功 | 对方事务瞬间释放 | 无需处理,或应用层加重试 |
| 高并发写入时连续失败 | 写锁竞争(多进程同写一个库) | WAL + BEGIN IMMEDIATE |
| 先查后写必现失败 | SHARED→RESERVED 锁升级死锁 | 写事务改用 BEGIN IMMEDIATE |
| 长事务 + 外部调用(网络/sleep) | 持锁时间过长 | 缩短事务,外部调用移出事务 |
| 只读进程也报 locked | 有连接持锁不提交(SELECT 后挂起) | 排查连接泄漏,加超时释放 |
报错出现在 commit() 而非 DML |
提交时升级 EXCLUSIVE 失败 | 同锁升级:BEGIN IMMEDIATE + 重试 |
实战排查 walkthrough:一次真实的生产锁死
假设你的 Web 服务(Gunicorn 多 worker + SQLite)在高流量时段开始大量报 database is locked,按这个顺序查:
第一步:确认报错模式。抓 10 分钟错误日志,统计报错出现在哪个操作。如果全部出现在「更新某表」而读操作正常,基本锁定写锁竞争;如果读操作也报错,先查连接数是否打满。
grep "database is locked" app.log | awk '{print $NF}' | sort | uniq -c | sort -rn
第二步:数连接与事务时长。SQLite 的连接状态可以从 /proc 看(Linux),或直接在代码里给每个事务打印起止时间戳。重点找「持锁超过 1 秒的事务」——这种长事务是锁竞争的主要来源。
第三步:查锁等待源码路径。把代码里所有 SELECT 后接 UPDATE/INSERT 的写法列出来,逐个确认是否处于同一连接/同一事务——这是锁升级死锁的高发点。
grep -rn "SELECT" app/ | grep -i "update\|insert" # 先查后写模式
第四步:对症下药。按上文方案一~四逐条落地:写事务改 BEGIN IMMEDIATE → 长事务拆短 → WAL → 应用层重试。每改一步压测一轮,观察报错率曲线。
第五步:回归验证。用并发脚本模拟 50 个并发写入,确认:报错率从「全部失败」降到「偶发 + 重试成功」,且无 0 秒立即失败(说明锁升级路径已被消除)。
这个 walkthrough 的核心理念:先分类(读锁还是写锁、首次还是升级)、再定位(长事务还是短事务)、最后才动代码。直接改 WAL 而不看路径,往往解决不了锁升级问题。
常见误区表
| 误区 | 为什么错 | 正确做法 |
|---|---|---|
| 「timeout=30 一定能等 30 秒」 | 锁升级路径不调用 busy handler,timeout 直接被跳过 | 认清两条路径:首次加锁等待 vs 升级立即失败 |
| 「加大 timeout 就能解决并发」 | timeout 只是等待上限,不是并发能力 | 治本是短事务 + WAL + IMMEDIATE |
| 「WAL 模式就完全不怕锁了」 | WAL 下写者之间仍互斥,锁升级仍可能失败 | WAL 解决读写冲突,写写冲突仍需重试 |
| 「SQLite 不适合并发,换数据库吧」 | 大多数场景是事务写法问题,不是 SQLite 不行 | 先改事务模式,实测压测后再决定 |
| 「报错后在原事务里重试同一语句」 | 失败后事务已处于不可用状态,重试同一语句还会失败 | rollback 后重新开启事务再试 |
Python 3.12+ 的 autocommit 新 API
Python 3.12 起 sqlite3.connect() 新增 autocommit 参数,把事务控制从「隐式自动 BEGIN」变成显式:
# 3.12+:autocommit=True 时每个语句立即提交,DML 不自动 BEGIN
con = sqlite3.connect(DB, autocommit=True)
con.execute("INSERT ...") # 立即生效
# autocommit=False 时任何语句(含 SELECT)都开启事务
con = sqlite3.connect(DB, autocommit=False)
con.execute("SELECT ...") # 自动 BEGIN(DEFERRED)
con.execute("UPDATE ...") # 锁升级路径,可能立即失败
con.commit()
这个 API 的价值是让事务边界可见:旧 API(isolation_level="")里 SELECT 后什么时候持锁、什么时候升级,全是隐式的;新 API 里你能清楚地看到「SELECT 已经开了事务」。如果项目可以升级 Python 3.12+,强烈建议显式使用 autocommit + BEGIN IMMEDIATE,把锁竞争变成显式决策,而不是靠猜。
版本行为对比
| 场景 | SQLite 3.x 全版本 | 说明 |
|---|---|---|
| 首次加锁失败 | timeout 生效 | busy handler 正常等待 |
| SHARED→RESERVED 升级失败 | 立即 SQLITE_BUSY,timeout 无效 | 内核防死锁,设计如此 |
Python 3.12+ autocommit=True |
需显式 BEGIN | 新 API,行为更透明 |
| WAL 模式 | 读写并行,冲突减少 | 写者间仍互斥 |
实测环境:Python 3.10.12 + SQLite 3.51.3,与 cpython#124510(Python 3.11 + SQLite 3.40)行为一致——该行为跨版本稳定存在。
自检清单
- [ ] 写事务用的是
BEGIN IMMEDIATE而不是裸INSERT/UPDATE? - [ ] 事务内没有网络请求/sleep/用户交互?
- [ ] 高并发读多写少场景已开 WAL?
- [ ] 应用层有「rollback + 重试」兜底,而不是单次尝试?
- [ ] 排查过是否有连接长期持有 SHARED 锁(
BEGIN后只 SELECT 不提交)?
启示
这条报错最有价值的认知是:SQLite 的 timeout 不是万能等待开关,它只在「公平竞争」时生效;一旦进入「锁升级」路径,SQLite 选择直接拒绝而不是等待——因为等待等于死锁。理解了这一点,所有「timeout 没用」的困惑都会消散:不是参数没生效,是它根本没被调用的机会。生产环境写 SQLite 的正确姿势永远是「短事务 + 一开始就声明写意图 + 应用层重试」,把锁竞争控制在最早、最公平的阶段。
另一个值得记住的细节:SQLite 的设计哲学是「宁可快速失败,也不死锁」。它对锁升级的立即拒绝,本质上是一种死锁预防——与其让两个连接无限等待,不如让后来的那个立刻知道「这条路走不通」。这种「fail fast 优于 wait forever」的思路,在数据库、分布式系统、并发编程里都值得借鉴:让错误尽早暴露,比让系统卡在未知状态要好得多。你在写自己的并发代码时,也可以把这种思想用进去:检测到潜在死锁就直接报错,而不是傻等超时。
原始出处:cpython#124510(The timeout setting is not honored when a transaction is active)、cpython#130971(sqlite: timeout doesn't seem to work),两者均被核心维护者以 not_planned 关闭(设计行为);本文复现脚本与 SQLite 锁模型分析基于 sqlite.org 官方文档。
本文由 admin 原创,转载请注明出处。
评论
0