1. 多线程写 Sqlite3 为什么会撞上 Recursive use of cursors not allowed
Recursive use of cursors not allowed这个报错,字面意思是「不允许递归使用游标」。它跟 SQL 语法没关系,也不是数据库文件损坏,而是 Python 的sqlite3模块在告诉你:同一个Cursor对象正在被一个线程使用,另一个线程又拿着它去执行语句了。
Sqlite3 本身是支持多线程读的,但 Python 的sqlite3默认把连接和游标绑定到创建它的线程上。当你开 50 个线程共用一个conn和一个cursor,某个线程的execute还没走完,另一个线程就插进来复用同一个游标,模块内部的状态机直接判定为递归调用,于是抛出这个异常。它出现的典型场景有三个:多线程爬虫批量入库、异步任务里共享连接、以及用线程池跑数据库写入。
我试过最直接的复现方式:建一个全局cursor,开 20 个线程各插 100 条,几乎必崩。崩的位置不固定,有时在execute,有时在commit,因为游标状态被并发踩踏了。
这篇要解决的就是这个场景:Python 多线程/异步下 Sqlite3 游标递归复用报错的定位与修复。我会给出可复制的连接池与游标管理骨架,顺带把 TaoToken 统一 Key 接进 AI 辅助排查的settings.json片段,最后给一份「复现报错 → 修复 → 回归验证」的完整动作清单。适合正在写多线程入库、被这个报错卡住的 Python 开发者。
2. 前置准备:TaoToken 统一 Key 与 settings.json 骨架
排查这类并发问题时,我习惯让 AI 帮我读堆栈、比对线程模型。TaoToken 的作用是把多个模型的调用收敛到一个 Key 上,省得在排查脚本里到处塞不同厂商的凭证。它的官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。
先拿 Key:进控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 创建一个,复制出来。这个 Key 后面会写进settings.json,供排查脚本调用模型对话接口。
settings.json片段长这样,放在项目根目录:
{ "taotoken": { "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model": "claude-sonnet-4-20250514", "timeout": 60 }, "sqlite": { "db_path": "./data/crawl.db", "check_same_thread": false, "pool_size": 8, "busy_timeout_ms": 5000 } }读取它的代码:
import json def load_settings(path="settings.json"): with open(path, "r", encoding="utf-8") as f: return json.load(f) CFG = load_settings()注意:
check_same_thread=False只是解除线程绑定检查,它不会自动帮你加锁。很多人以为设了它就万事大吉,结果报错照旧,原因就在这里。
如果你要长期跑编码类排查任务,可以看 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ;单纯验证模型输出是否正常,用模型对话 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 就够。
3. 可复制配置:连接池与游标管理骨架
修复的核心思路只有一句话:每个线程用自己的连接和游标,或者用锁把共享游标的访问串行化。下面给两套骨架,按你的并发量选。
3.1 方案 A:线程本地连接(推荐,读多写少)
用threading.local()给每个线程分配独立连接,游标随用随建,彻底避开共享。
import sqlite3 import threading class SQLitePool: def __init__(self, db_path, busy_timeout_ms=5000): self.db_path = db_path self.busy_timeout_ms = busy_timeout_ms self._local = threading.local() def _conn(self): conn = getattr(self._local, "conn", None) if conn is None: conn = sqlite3.connect( self.db_path, check_same_thread=False, timeout=self.busy_timeout_ms / 1000, ) conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA synchronous=NORMAL;") self._local.conn = conn return conn def execute(self, sql, params=()): conn = self._conn() cur = conn.cursor() try: cur.execute(sql, params) conn.commit() return cur.fetchall() finally: cur.close() def executemany(self, sql, seq): conn = self._conn() cur = conn.cursor() try: cur.executemany(sql, seq) conn.commit() finally: cur.close()关键点:cur在函数内创建、函数内关闭,不跨线程传递。WAL模式让读写可以并行,busy_timeout避免瞬时锁冲突直接抛异常。
3.2 方案 B:全局锁 + 共享连接(写密集、逻辑简单)
如果你就是想共用一个连接,那必须给每次游标操作加锁,把并发写变成串行写。
import sqlite3 import threading class LockedSQLite: def __init__(self, db_path): self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.execute("PRAGMA journal_mode=WAL;") self.lock = threading.Lock() def execute(self, sql, params=()): with self.lock: cur = self.conn.cursor() try: cur.execute(sql, params) self.conn.commit() return cur.fetchall() finally: cur.close() def batch_insert(self, sql, rows, batch=1000): with self.lock: cur = self.conn.cursor() try: for i in range(0, len(rows), batch): cur.executemany(sql, rows[i:i + batch]) self.conn.commit() finally: cur.close()batch_insert里每 1000 条 commit 一次,比每条都 commit 快很多,这也是原日志里提到的经验。锁的粒度覆盖「取游标 → 执行 → 提交 → 关游标」整段,中间不能有别的线程插进来。
3.3 两套方案对照
| 维度 | 方案 A 线程本地连接 | 方案 B 全局锁 |
|---|---|---|
| 并发读 | 真并行 | 串行 |
| 并发写 | WAL 下可并行读、写排队 | 完全串行 |
| 代码复杂度 | 中 | 低 |
| 适合场景 | 爬虫入库 + 查询混合 | 纯批量写入 |
| 游标复用风险 | 无 | 靠锁规避 |
4. 验证请求:复现报错与修复后回归
先写一个必崩的复现脚本,确认你遇到的就是这个问题:
import sqlite3 import threading conn = sqlite3.connect("test.db", check_same_thread=False) conn.execute("CREATE TABLE IF NOT EXISTS t (id INTEGER, v TEXT)") cursor = conn.cursor() # 全局共享游标,错误根源 def worker(n): for i in range(100): cursor.execute("INSERT INTO t VALUES (?, ?)", (n * 100 + i, f"v{i}")) conn.commit() threads = [threading.Thread(target=worker, args=(n,)) for n in range(20)] for t in threads: t.start() for t in threads: t.join()跑起来大概率在几秒内抛Recursive use of cursors not allowed。记下这个堆栈,它就是基线。
换成方案 A 后,回归脚本:
from pool import SQLitePool pool = SQLitePool("test.db") def worker(n): for i in range(100): pool.execute("INSERT INTO t VALUES (?, ?)", (n * 100 + i, f"v{i}")) threads = [threading.Thread(target=worker, args=(n,)) for n in range(20)] for t in threads: t.start() for t in threads: t.join() rows = pool.execute("SELECT COUNT(*) FROM t") print("total rows:", rows[0][0])预期输出total rows: 2000,且无异常。如果数字对得上、进程正常退出,说明游标复用问题已经解决。
想用 AI 帮你读这段堆栈,可以把报错贴给模型对话接口,请求体走 TaoToken:
import requests def ask_ai(prompt): r = requests.post( "https://taotoken.net/api/v1/chat/completions", headers={"Authorization": f"Bearer {CFG['taotoken']['api_key']}"}, json={ "model": CFG["taotoken"]["model"], "messages": [{"role": "user", "content": prompt}], }, timeout=CFG["taotoken"]["timeout"], ) return r.json()["choices"][0]["message"]["content"]把复现脚本的堆栈和你的线程模型描述一起丢进去,让它判断是共享游标还是事务未提交导致的。
5. 本篇常见错排查
报错依旧出现,但我已经加了锁。检查锁的范围是不是只包了execute,没包commit。commit也会操作游标内部状态,必须一起锁。另外确认没有别的地方绕过封装直接用了全局cursor。
换成线程本地连接后报 database is locked。这是写锁竞争,不是游标问题。把busy_timeout调大,并确认开了 WAL 模式。WAL 下读不阻塞写,写之间仍会排队,超时就抛这个错。
异步场景(asyncio)里用同步 sqlite3 卡住事件循环。sqlite3是阻塞库,别直接在协程里调。用asyncio.to_thread(pool.execute, sql, params)把它丢到线程池,或者干脆用aiosqlite。但注意aiosqlite内部也是线程池,游标管理逻辑一样要遵守「不跨任务共享游标」。
check_same_thread=False 设了还是报错。这个参数只关闭「连接创建线程校验」,不解决游标并发。它和加锁是两件事,别混。
批量插入时部分数据丢失。检查executemany后有没有 commit,以及异常分支里是否吞掉了错误。建议在finally里只关游标,commit 放在 try 成功路径上,失败时显式 rollback。
多进程而不是多线程时报错。多进程各自有独立连接,一般不会出这个错。如果出现,多半是 fork 之前就建好了连接,子进程继承了父进程的游标状态。改成在子进程内建连接。
6. 把统一 Key 接进你的排查流程
回到实际工程:多线程 Sqlite3 的游标问题,本质是「共享可变状态 + 并发访问」。修复手段无非隔离(线程本地)或串行(加锁),选哪个看你的读写比例。我自己的爬虫项目最后用的是方案 A,因为查询和写入混在一起,线程本地连接最省心。
TaoToken 在这里的角色是排查助手:把报错堆栈、线程模型、你的封装代码一起发给模型,让它帮你确认锁粒度够不够、有没有漏掉的共享游标。统一 Key 的好处是不用在排查脚本里维护多套凭证,settings.json里改一个字段就能换模型。
接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有完整的请求格式和错误码说明。如果你用 Claude Code 做这类排查,参考 https://taotoken.net/claudecode-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode-anthropic&utm_campaign=rewrite 的配置方式,把 base_url 指向 TaoToken 即可。
最后留一个我踩过的坑:别在finally里 commit。异常发生时 commit 可能再次触发游标状态异常,把原始错误盖掉。commit 只在正常路径做,异常路径 rollback 或直接关连接。