1. 多线程写 Sqlite3 为什么会撞上 Recursive use of cursors not allowedRecursive 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。先拿 Key进控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 创建一个复制出来。这个 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(pathsettings.json): with open(path, r, encodingutf-8) as f: return json.load(f) CFG load_settings()注意check_same_threadFalse只是解除线程绑定检查它不会自动帮你加锁。很多人以为设了它就万事大吉结果报错照旧原因就在这里。如果你要长期跑编码类排查任务可以看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 单纯验证模型输出是否正常用模型对话 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 就够。3. 可复制配置连接池与游标管理骨架修复的核心思路只有一句话每个线程用自己的连接和游标或者用锁把共享游标的访问串行化。下面给两套骨架按你的并发量选。3.1 方案 A线程本地连接推荐读多写少用threading.local()给每个线程分配独立连接游标随用随建彻底避开共享。import sqlite3 import threading class SQLitePool: def __init__(self, db_path, busy_timeout_ms5000): 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_threadFalse, timeoutself.busy_timeout_ms / 1000, ) conn.execute(PRAGMA journal_modeWAL;) conn.execute(PRAGMA synchronousNORMAL;) 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_threadFalse) self.conn.execute(PRAGMA journal_modeWAL;) 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, batch1000): 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_threadFalse) 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, fv{i})) conn.commit() threads [threading.Thread(targetworker, 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, fv{i})) threads [threading.Thread(targetworker, 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 帮你读这段堆栈可以把报错贴给模型对话接口请求体走 TaoTokenimport requests def ask_ai(prompt): r requests.post( https://taotoken.net/api/v1/chat/completions, headers{Authorization: fBearer {CFG[taotoken][api_key]}}, json{ model: CFG[taotoken][model], messages: [{role: user, content: prompt}], }, timeoutCFG[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_threadFalse 设了还是报错。这个参数只关闭「连接创建线程校验」不解决游标并发。它和加锁是两件事别混。批量插入时部分数据丢失。检查executemany后有没有 commit以及异常分支里是否吞掉了错误。建议在finally里只关游标commit 放在 try 成功路径上失败时显式 rollback。多进程而不是多线程时报错。多进程各自有独立连接一般不会出这个错。如果出现多半是 fork 之前就建好了连接子进程继承了父进程的游标状态。改成在子进程内建连接。6. 把统一 Key 接进你的排查流程回到实际工程多线程 Sqlite3 的游标问题本质是「共享可变状态 并发访问」。修复手段无非隔离线程本地或串行加锁选哪个看你的读写比例。我自己的爬虫项目最后用的是方案 A因为查询和写入混在一起线程本地连接最省心。TaoToken 在这里的角色是排查助手把报错堆栈、线程模型、你的封装代码一起发给模型让它帮你确认锁粒度够不够、有没有漏掉的共享游标。统一 Key 的好处是不用在排查脚本里维护多套凭证settings.json里改一个字段就能换模型。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的请求格式和错误码说明。如果你用 Claude Code 做这类排查参考 https://taotoken.net/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecode-anthropicutm_campaignrewrite 的配置方式把 base_url 指向 TaoToken 即可。最后留一个我踩过的坑别在finally里 commit。异常发生时 commit 可能再次触发游标状态异常把原始错误盖掉。commit 只在正常路径做异常路径 rollback 或直接关连接。