SQLite 锁超时无效?先查事务升级冲突
文章定位 WAL 模式下先读后写事务触发 SQLITE_BUSY_SNAPSHOT、绕过 busy_timeout 的问题,并解释 Python 隐式事务配置可能造成的重复处理。作者最终使用单条 UPDATE ... RETURNING 领取任务,在其测试中消除了锁错误和重复执行。
AI 深度解读
SQLite 设置了 30 秒超时,却在约 2 毫秒内报出 database is locked。作者的 Claude 批处理队列一晚遇到 187 次这种错误,问题出在事务先读后写:读取的快照过期后,SQLite 会直接拒绝写入,根本不会进入等待流程。
这篇最有用的地方,是把「报锁错误」「等待锁」「安全领取任务」拆开了。作者先靠提前加锁消除了报错,又踩进重复调用 API 的坑,最后用一条 SQL 完成任务领取。对用 SQLite 跑 AI 批处理的程序员,这比继续调大超时更值得研究。
为什么设置了 30 秒,2 毫秒就失败
作者的队列配置很简单:一个 SQLite 文件,开启 WAL 模式,六个 worker 进程,每轮处理 2,400 个任务。每个任务调用 Claude API,需要几秒钟;领取任务原本应该只占很小一部分时间。
连接使用 timeout=30 和 isolation_level=None,通过 PRAGMA journal_mode=WAL 开启 WAL。领取时手动执行 BEGIN,查询按 id 排序的第一条 pending 任务,再把它更新为 running,最后 COMMIT。
问题藏在默认的延迟事务(deferred transaction)里。BEGIN 没有提前取得写锁,第一次 SELECT 才固定读取快照。如果另一个连接随后提交了修改,当前事务再执行 UPDATE,就可能遇到 SQLITE_BUSY_SNAPSHOT。
例如,两个 worker 都读到任务 812。A 更新并提交后,B 还拿着提交前的快照。SQLite 不能允许 B 基于旧快照继续升级为写事务,于是立即拒绝。
等待能等来锁释放,却不能让旧快照重新有效。
所以,这条路径会跳过 busy handler,busy_timeout 没有介入机会。Python 展示的却仍是笼统的 database is locked,很容易让人误判成普通锁竞争。
作者把 timeout 从 5 秒改到 30 秒、60 秒,同样的 2,400 个任务分别出现 187、179、191 次错误,没有看到有效改善。每次错误都会导致 worker 崩溃,监督进程重启一次约需 6 秒;187 次累计约损失 19 分钟的 worker 工作时间,这不是整批任务额外耗时的直接测量。
定位时,作者同时记录错误码和耗时。Python 3.11 起,可以通过异常的 sqlite_errorname 查看扩展错误名称:
import time
t0 = time.perf_counter()
try:
claim(conn, worker_id)
except sqlite3.OperationalError as e:
ms = (time.perf_counter() - t0) * 1000
log.error("claim failed %s after %.1fms", e.sqlite_errorname, ms)
raise
关键是日志里的 e.sqlite_errorname。作者所有失败都记录为 SQLITE_BUSY_SNAPSHOT,耗时中位数约 2ms;判断原因时,错误码比报错文案可靠。
提前加锁有效,为什么驱动配置反而制造重复任务
第一种有效修复,是把手动执行的 BEGIN 改成 BEGIN IMMEDIATE。
它在读取任务前先申请写锁。如果已有写事务,就在这里等待,此时 busy_timeout 才能发挥作用。拿到写锁以后,其他 writer 无法抢先提交,后续读取和更新也就避开了快照过期的问题。
作者实测,2,400 个任务的错误数降到 0,领取耗时 p99 为 41ms。竞争没有消失,而是变成了等待写锁。对于后面还要等待几秒 Claude 响应的任务,这个成本在作者的场景里可以接受。
但作者最初上线的修复,是给连接设置 isolation_level="IMMEDIATE",然后删除手动 BEGIN。结果同样没有报错,却为 2,400 张工单生成了 2,426 份摘要,其中 26 个任务被处理了两次。
原因是 Python 的 legacy transaction mode 只会在 INSERT、UPDATE、DELETE、REPLACE 等修改语句前隐式开启事务。前面的 SELECT 并没有被包进去。
于是两个 worker 可以先读到同一条 pending 任务,再依次开启各自的写事务,分别执行更新。数据库操作合法,业务领取却重复了。
没有锁错误,不等于任务领取正确。
如果保留这种两步操作,更新条件需要增加 WHERE id=? AND status='pending',并在 cursor.rowcount == 0 时放弃处理。后一个 worker 即使读到了旧候选,也必须承认自己没有领取成功。
这里还要保留原文的前提:上述隐式事务行为说的是 legacy transaction mode,不能把一句连接配置理解成所有事务模式下都有效的边界声明。
一条 SQL 怎样把领取变成原子操作
作者最终选择 UPDATE ... RETURNING,要求 SQLite 3.35+。它把选择候选任务、更新状态和返回任务内容放进同一条写语句:
def claim(conn, worker_id):
return conn.execute(
"""
UPDATE jobs
SET status = 'running', worker = ?
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 1
)
RETURNING id, payload
""",
(worker_id,),
).fetchone()
要看的是 WHERE id = (...) 和 RETURNING id, payload:前者选择待领取记录,后者直接返回本次更新的任务,调用方不必先单独查一次。
虽然里面仍然有子查询,但整个操作是一条写语句,会在读取前取得所需写锁,避开应用层「先查到任务,再争取写入」的窗口。
连接使用 isolation_level=None 时,这条语句在自动提交模式下自行构成事务。原文最终结果是:同样处理 2,400 个任务,0 次错误、0 次重复,领取耗时 p99 为 11ms。
这个结果有明确的负载边界:六个进程、本地 SSD 上的一个数据库文件,每个 worker 每隔几秒写一次。原文没有提供更高并发的完整测试,也没有足够数据说明 11ms 和 41ms 的差距能否在其他环境复现。
无论采用哪种方案,事务都应足够短。领取后先提交,再调用 Claude,最后通过另一个短事务写回结果。把网络请求放进持锁事务,会让其他 writer 跟着等待。作者还明确提醒,不要把 WAL 数据库放在网络文件系统上。
我的判断
作者最有说服力的部分,是把错误现象和机制对应起来:扩展错误码、毫秒级失败、调整 timeout 无效,三者共同支持快照冲突的诊断。相比只看到报错就增加重试或延长等待,这种排查顺序更容易找到真正的问题。
不过,「毫秒级失败就是 SQLITE_BUSY_SNAPSHOT」不能直接当成通用判据。耗时可以提供线索,最终仍应检查错误码。原文把普通 SQLITE_BUSY 描述成接近 timeout 才失败,也是便于理解的简化,不能反过来仅凭耗时分类。
对于这个领取流程,我更倾向于单条 UPDATE ... RETURNING:事务边界更集中,也减少了漏写条件检查的机会。如果业务必须先读多项数据再决定如何更新,显式 BEGIN IMMEDIATE 仍然有用,但需要控制持锁时间。
作者「会写的事务一律提前加锁或使用单条语句」的规则,以及「数十个 writer 高频争写时该换队列」的建议,都带有工作负载前提。原文没有给出与其他队列系统的对照测试,应把它们视为工程经验。
还要区分原子领取与完整的任务可靠性:这次实测没有重复,不代表整个系统获得了严格的恰好一次执行保证。worker 领取后崩溃怎么办、Claude 调用成功但结果未落库怎么办,原文都没有展开。
能拿来做什么
- 排查 SQLite 队列报错时,同时记录扩展错误码、领取耗时和事务起止位置;Python 3.11+ 可以使用
sqlite_errorname,不要只收集database is locked文案。 - 如果 AI 批处理用 SQLite 领取任务,检查是否存在独立的
SELECT再UPDATE;满足 SQLite 3.35+ 条件时,可以评估原文的UPDATE ... RETURNING写法。 - 如果必须保留多语句领取,明确使用
BEGIN IMMEDIATE包住读取与更新;采用条件更新方案时,必须检查rowcount,确认领取成功后再调用 API。 - 把 Claude 等外部请求放到领取事务提交之后,并同时观察错误数与重复处理数;只验证「不再报错」,会漏掉作者已经踩过的业务问题。