十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

sqlite3.OperationalError: database is locked——为什么 timeout=10 秒没生效?SQLite 锁升级死锁路径完整排障 title: Python 生产环境报错速查 13sqlite3.OperationalError: database is locked——为什么 timeout10 秒没生效SQLite 锁升级死锁路径完整排障 column: Python生产环境报错速查从崩溃到修复 tags: [sqlite3, database is locked, OperationalError, 锁升级, timeout, busy handler, WAL]TL;DRPython 连接 SQLite 时设置timeout10遇到并发写入依然立即抛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 并发测试共享临时库。最常见的心路历程是第一次遇到去搜索答案说「连接加timeout10」加了 timeout重启看起来好了流量一上来又崩了而且崩溃瞬间完全没有等待打开 SQLite 官方文档发现 timeout 只对「第一次尝试获取锁」生效对锁升级无效于是死循环调大 timeout → 无效 → 怀疑版本 → 换 WAL → 又遇到别的坑。本文的目标是让第 3 步的人直接跳到第 5 步的正确解法并把第 4 步的机制讲透。关键问题为什么 timeout 无效SQLite 的 busy handler 机制是这样的连接在首次尝试拿锁失败时会进入等待循环循环上限就是 timeout。但有一类失败不进入这个循环——锁升级失败。SQLite 官方文档原文sqlite.org/lang_transaction.htmlIf 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_levelNone 手动BEGIN这是最能暴露锁行为的写法。路径一首次加锁失败timeout 生效进程 A 先BEGINUPDATE持有 RESERVED 写锁sleep 4 秒后 commit。进程 B 直接INSERT第一次尝试拿写锁# 进程 B直接 INSERT无前置 SELECTconsqlite3.connect(DB,timeout10)curcon.cursor()cur.execute(INSERT INTO t VALUES (2))# 首次拿写锁失败 → 进入 busy 等待实测输出B: 成功写入, 耗时 1.84s A: 已持RESERVED锁(已UPDATE), sleep 4s A: commitB 等了约 1.84 秒A 释放锁后写入成功——timeout 生效行为符合直觉。路径二锁升级失败timeout 失效核心坑进程 A 同上BEGINUPDATE sleep 4 秒。进程 B 先SELECT拿到 SHARED 读锁然后再UPDATE# 进程 B先 SELECT 拿 SHARED 锁再 UPDATE 升级consqlite3.connect(DB,timeout10)curcon.cursor()cur.execute(BEGIN)cur.execute(SELECT * FROM t)# 拿到 SHARED 锁cur.execute(UPDATE t SET a98 WHERE a1)# 尝试升级 RESERVED → 立即失败实测输出B: SELECT 完成 (0.00s) B: OperationalError: database is locked (耗时 0.00s) -- timeout10 没等就报错 A: 已持RESERVED锁(已UPDATE), sleep 4s A: stderr: sqlite3.OperationalError: database is locked两个关键观察B 的 UPDATE 0.00 秒立即报错timeout10 完全没起作用A 的 commit 也报错了——因为 B 虽然 UPDATE 失败但连接还活着仍然持有 SHARED 锁A 想升级到 EXCLUSIVE 提交也失败。这就是「锁升级死锁」的完整闭环两个连接互相拿着对方需要的锁谁都写不进去。这就是为什么生产环境一旦进入这种状态所有写请求会连续失败直到某个连接超时关闭——表现上很像「数据库卡死」。机制拆解SQLite 五级锁与升级路径为什么 SQLite 要这么设计SQLite 是单写多读的嵌入式数据库锁模型有五个级别级别名称允许并存说明1UNLOCKED所有连接初始状态2SHARED多个连接可同时持有读锁SELECT 时获取3RESERVED一个写者 多个读者写者已预留升级权可继续读4PENDING一个写者写者等所有读者退出禁止新 SHARED5EXCLUSIVE独占写锁commit 时持有升级路径UNLOCKED → SHARED读→ RESERVED准备写→ PENDING → EXCLUSIVE提交。死锁场景发生在「两个连接都想从 SHARED 升级到 RESERVED」连接 ASHARED → RESERVED ✅先到先得 连接 BSHARED → RESERVED ❌立即 SQLITE_BUSYSQLite 内核在sqlite3BtreeBeginTrans里判断如果自己持有了 SHARED 而对方持有 RESERVED继续等待必然死锁你要的锁在对方手里对方要的锁有一部分在你手里所以直接返回SQLITE_BUSY连 busy handler 都不调用。这是 SQLite 的内核级防死锁设计Python 的timeout参数只是把 busy handler 的超时传进去对这条路径无能为力。Python 为什么更容易踩坑Python 的sqlite3模块默认isolation_level会自动帮你包事务任何 DML 语句INSERT/UPDATE/DELETE执行前自动 BEGIN。但 SELECT 不会自动 BEGIN除非 autocommitFalse 的新 API。这就造成一个常见的隐性组合# 典型踩坑代码先查后写rowscur.execute(SELECT ...).fetchall()# 拿到 SHARED 锁隐式# ... 处理逻辑耗时操作 ...cur.execute(UPDATE ...)# 升级 RESERVED → 可能立即失败代码看起来毫无问题但 SELECT 和 UPDATE 之间一旦有其他连接抢占了 RESERVED 锁你的 UPDATE 就 0 秒报错。而且由于 Python 自动 BEGIN 的时机不可见很多人根本不知道自己的 SELECT 已经持锁了。解决方案四条路径按优先级方案一最推荐BEGIN IMMEDIATE提前抢写锁写操作一开始就声明要写避免从 SHARED 升级consqlite3.connect(DB,timeout10)curcon.cursor()cur.execute(BEGIN IMMEDIATE)# 直接拿 RESERVED 锁不走 SHAREDtry:cur.execute(UPDATE ...)con.commit()exceptException:con.rollback()raiseBEGIN IMMEDIATE拿锁失败时会正常走 busy handlertimeout 生效。这是官方推荐的写事务打开方式把「可能死锁的升级」变成「一开始就竞争」。方案二WAL 模式读多写少的首选consqlite3.connect(DB)con.execute(PRAGMA journal_modeWAL)WAL 模式下读者不持 SHARED 锁阻塞写者写者之间仍互斥但读写完全并行锁升级冲突大幅减少。注意 WAL 模式需要 SQLite 3.72009 年后所有版本都有且会产生-wal和-shm文件备份/拷库时要一起拷。方案三应用层重试兜底即使用了 IMMEDIATE/WAL极端并发下仍可能遇到database is locked。加个重试装饰器importsqlite3,timedefretry_on_locked(max_retries5,delay0.1):defdeco(fn):defwrapper(*args,**kwargs):foriinrange(max_retries):try:returnfn(*args,**kwargs)exceptsqlite3.OperationalErrorase:iflockedinstr(e)andimax_retries-1:time.sleep(delay*(i1))continueraisereturnfn(*args,**kwargs)returnwrapperreturndeco注意重试必须重新开启事务rollback 后重来不能在同一事务里重试同一语句——因为失败后事务已处于不可用状态。方案四缩短事务窗口把「SELECT 业务处理 UPDATE」拆开业务处理放到事务外# 事务外先读rowscur.execute(SELECT ...).fetchall()# 计算完成后再开事务写cur.execute(BEGIN IMMEDIATE)forrowinrows: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 分钟错误日志统计报错出现在哪个操作。如果全部出现在「更新某表」而读操作正常基本锁定写锁竞争如果读操作也报错先查连接数是否打满。grepdatabase is lockedapp.log|awk{print $NF}|sort|uniq-c|sort-rn第二步数连接与事务时长。SQLite 的连接状态可以从/proc看Linux或直接在代码里给每个事务打印起止时间戳。重点找「持锁超过 1 秒的事务」——这种长事务是锁竞争的主要来源。第三步查锁等待源码路径。把代码里所有SELECT后接UPDATE/INSERT的写法列出来逐个确认是否处于同一连接/同一事务——这是锁升级死锁的高发点。grep-rnSELECTapp/|grep-iupdate\|insert# 先查后写模式第四步对症下药。按上文方案一~四逐条落地写事务改BEGIN IMMEDIATE→ 长事务拆短 → WAL → 应用层重试。每改一步压测一轮观察报错率曲线。第五步回归验证。用并发脚本模拟 50 个并发写入确认报错率从「全部失败」降到「偶发 重试成功」且无 0 秒立即失败说明锁升级路径已被消除。这个 walkthrough 的核心理念先分类读锁还是写锁、首次还是升级、再定位长事务还是短事务、最后才动代码。直接改 WAL 而不看路径往往解决不了锁升级问题。常见误区表误区为什么错正确做法「timeout30 一定能等 30 秒」锁升级路径不调用 busy handlertimeout 直接被跳过认清两条路径首次加锁等待 vs 升级立即失败「加大 timeout 就能解决并发」timeout 只是等待上限不是并发能力治本是短事务 WAL IMMEDIATE「WAL 模式就完全不怕锁了」WAL 下写者之间仍互斥锁升级仍可能失败WAL 解决读写冲突写写冲突仍需重试「SQLite 不适合并发换数据库吧」大多数场景是事务写法问题不是 SQLite 不行先改事务模式实测压测后再决定「报错后在原事务里重试同一语句」失败后事务已处于不可用状态重试同一语句还会失败rollback 后重新开启事务再试Python 3.12 的 autocommit 新 APIPython 3.12 起sqlite3.connect()新增autocommit参数把事务控制从「隐式自动 BEGIN」变成显式# 3.12autocommitTrue 时每个语句立即提交DML 不自动 BEGINconsqlite3.connect(DB,autocommitTrue)con.execute(INSERT ...)# 立即生效# autocommitFalse 时任何语句含 SELECT都开启事务consqlite3.connect(DB,autocommitFalse)con.execute(SELECT ...)# 自动 BEGINDEFERREDcon.execute(UPDATE ...)# 锁升级路径可能立即失败con.commit()这个 API 的价值是让事务边界可见旧 APIisolation_level里 SELECT 后什么时候持锁、什么时候升级全是隐式的新 API 里你能清楚地看到「SELECT 已经开了事务」。如果项目可以升级 Python 3.12强烈建议显式使用autocommitBEGIN IMMEDIATE把锁竞争变成显式决策而不是靠猜。版本行为对比场景SQLite 3.x 全版本说明首次加锁失败timeout 生效busy handler 正常等待SHARED→RESERVED 升级失败立即 SQLITE_BUSYtimeout 无效内核防死锁设计如此Python 3.12autocommitTrue需显式 BEGIN新 API行为更透明WAL 模式读写并行冲突减少写者间仍互斥实测环境Python 3.10.12 SQLite 3.51.3与 cpython#124510Python 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#124510The timeout setting is not honored when a transaction is active、cpython#130971sqlite: timeout doesnt seem to work两者均被核心维护者以 not_planned 关闭设计行为本文复现脚本与 SQLite 锁模型分析基于 sqlite.org 官方文档。
返回列表