sqlite3.OperationalError: database is locked 为什么 timeout=10 秒没生效?SQLite 锁升级死锁路径完整排障

con = sqlite3.connect(DB, timeout=10) cur = con.cursor() cur.execute("INSERT INTO t VALUES (2)") # 首次拿写锁,失败 → 进入 busy 等待

vbnet 复制代码
实测输出:

```text
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

python 复制代码
# 进程 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 → 立即失败

实测输出:

text 复制代码
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

两个关键观察:

  1. B 的 UPDATE 0.00 秒立即报错,timeout=10 完全没起作用;
  2. 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」:

text 复制代码
连接 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)。这就造成一个常见的隐性组合:

python 复制代码
# 典型踩坑代码:先查后写
rows = cur.execute("SELECT ...").fetchall()   # 拿到 SHARED 锁(隐式)
# ... 处理逻辑,耗时操作 ...
cur.execute("UPDATE ...")                      # 升级 RESERVED → 可能立即失败

代码看起来毫无问题,但 SELECT 和 UPDATE 之间一旦有其他连接抢占了 RESERVED 锁,你的 UPDATE 就 0 秒报错。而且由于 Python 自动 BEGIN 的时机不可见,很多人根本不知道自己的 SELECT 已经持锁了。

解决方案:四条路径按优先级

方案一(最推荐):BEGIN IMMEDIATE 提前抢写锁

写操作一开始就声明要写,避免从 SHARED 升级:

python 复制代码
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 模式(读多写少的首选)

python 复制代码
con = sqlite3.connect(DB)
con.execute("PRAGMA journal_mode=WAL")

WAL 模式下读者不持 SHARED 锁阻塞写者,写者之间仍互斥,但读写完全并行 ,锁升级冲突大幅减少。注意 WAL 模式需要 SQLite 3.7+(2009 年后所有版本都有),且会产生 -wal-shm 文件,备份/拷库时要一起拷。

方案三:应用层重试(兜底)

即使用了 IMMEDIATE/WAL,极端并发下仍可能遇到 database is locked。加个重试装饰器:

python 复制代码
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」拆开,业务处理放到事务外:

python 复制代码
# 事务外先读
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 分钟错误日志,统计报错出现在哪个操作。如果全部出现在「更新某表」而读操作正常,基本锁定写锁竞争;如果读操作也报错,先查连接数是否打满。

bash 复制代码
grep "database is locked" app.log | awk '{print $NF}' | sort | uniq -c | sort -rn

第二步:数连接与事务时长 。SQLite 的连接状态可以从 /proc 看(Linux),或直接在代码里给每个事务打印起止时间戳。重点找「持锁超过 1 秒的事务」------这种长事务是锁竞争的主要来源。

第三步:查锁等待源码路径 。把代码里所有 SELECT 后接 UPDATE/INSERT 的写法列出来,逐个确认是否处于同一连接/同一事务------这是锁升级死锁的高发点。

bash 复制代码
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」变成显式:

python 复制代码
# 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 官方文档。

相关推荐
starrysky8101 小时前
SMBus is busy, can't use it! + task blocked 120s - i2c-i801 错误路径释放未获取资源致 hung task(CVE-2026-64205)
angular.js
starrysky81013 天前
kubectl logs 看不了日志?fsnotify 报 too many open files 的真相是 inotify 实例耗尽
angular.js
shmily麻瓜小菜鸡15 天前
vue3里“devDependencies 是开发环境依赖,dependencies 是生产环境依赖”
前端·javascript·vue.js·vscode·vue·angular.js
starrysky81018 天前
RuntimeError: dictionary changed size during iteration——多线程 Pydantic cached_property 竞态条件
angular.js
starrysky81018 天前
Agent 安全 #06:Agent 灰度发布与回滚 —— 上线新 Prompt 炸了生产,你敢回滚吗?
angular.js
starrysky81018 天前
CPU 70% idle 但 load average 84?D 状态进程不可中断睡眠的深度排查
angular.js
shmily麻瓜小菜鸡19 天前
浏览器在请求外部图片(403 Forbidden)问题
vue.js·vscode·echarts·angular.js
界面开发小八哥21 天前
界面控件DevExtreme v26.1新版亮点——支持Angular 22
前端·javascript·angular.js·devexpress·ui开发·devextreme
巴勒个啦25 天前
Vue 3.6 Vapor Mode 实战:我把一个 Vue3 项目的渲染性能提升了 4 倍
前端·angular.js