錯(cuò) Recursive use of cursors not allowed:用 TaoToken 統(tǒng)一 Key 排查與修復(fù)配置骨架)
1. 多線程寫 Sqlite3 為什么會(huì)撞上 Recursive use of cursors not allowedRecursive use of cursors not allowed這個(gè)報(bào)錯(cuò)字面意思是「不允許遞歸使用游標(biāo)」。它跟 SQL 語(yǔ)法沒(méi)關(guān)系也不是數(shù)據(jù)庫(kù)文件損壞而是 Python 的sqlite3模塊在告訴你同一個(gè)Cursor對(duì)象正在被一個(gè)線程使用另一個(gè)線程又拿著它去執(zhí)行語(yǔ)句了。Sqlite3 本身是支持多線程讀的但 Python 的sqlite3默認(rèn)把連接和游標(biāo)綁定到創(chuàng)建它的線程上。當(dāng)你開(kāi) 50 個(gè)線程共用一個(gè)conn和一個(gè)cursor某個(gè)線程的execute還沒(méi)走完另一個(gè)線程就插進(jìn)來(lái)復(fù)用同一個(gè)游標(biāo)模塊內(nèi)部的狀態(tài)機(jī)直接判定為遞歸調(diào)用于是拋出這個(gè)異常。它出現(xiàn)的典型場(chǎng)景有三個(gè)多線程爬蟲(chóng)批量入庫(kù)、異步任務(wù)里共享連接、以及用線程池跑數(shù)據(jù)庫(kù)寫入。我試過(guò)最直接的復(fù)現(xiàn)方式建一個(gè)全局cursor開(kāi) 20 個(gè)線程各插 100 條幾乎必崩。崩的位置不固定有時(shí)在execute有時(shí)在commit因?yàn)橛螛?biāo)狀態(tài)被并發(fā)踩踏了。這篇要解決的就是這個(gè)場(chǎng)景Python 多線程/異步下 Sqlite3 游標(biāo)遞歸復(fù)用報(bào)錯(cuò)的定位與修復(fù)。我會(huì)給出可復(fù)制的連接池與游標(biāo)管理骨架順帶把 TaoToken 統(tǒng)一 Key 接進(jìn) AI 輔助排查的settings.json片段最后給一份「復(fù)現(xiàn)報(bào)錯(cuò) → 修復(fù) → 回歸驗(yàn)證」的完整動(dòng)作清單。適合正在寫多線程入庫(kù)、被這個(gè)報(bào)錯(cuò)卡住的 Python 開(kāi)發(fā)者。2. 前置準(zhǔn)備TaoToken 統(tǒng)一 Key 與 settings.json 骨架排查這類并發(fā)問(wèn)題時(shí)我習(xí)慣讓 AI 幫我讀堆棧、比對(duì)線程模型。TaoToken 的作用是把多個(gè)模型的調(diào)用收斂到一個(gè) Key 上省得在排查腳本里到處塞不同廠商的憑證。它的官網(wǎng)是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。先拿 Key進(jìn)控制臺(tái) https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 頁(yè)面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 創(chuàng)建一個(gè)復(fù)制出來(lái)。這個(gè) Key 后面會(huì)寫進(jìn)settings.json供排查腳本調(diào)用模型對(duì)話接口。settings.json片段長(zhǎng)這樣放在項(xiàng)目根目錄{ 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只是解除線程綁定檢查它不會(huì)自動(dòng)幫你加鎖。很多人以為設(shè)了它就萬(wàn)事大吉結(jié)果報(bào)錯(cuò)照舊原因就在這里。如果你要長(zhǎng)期跑編碼類排查任務(wù)可以看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 單純驗(yàn)證模型輸出是否正常用模型對(duì)話 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 就夠。3. 可復(fù)制配置連接池與游標(biāo)管理骨架修復(fù)的核心思路只有一句話每個(gè)線程用自己的連接和游標(biāo)或者用鎖把共享游標(biāo)的訪問(wèn)串行化。下面給兩套骨架按你的并發(fā)量選。3.1 方案 A線程本地連接推薦讀多寫少用threading.local()給每個(gè)線程分配獨(dú)立連接游標(biāo)隨用隨建徹底避開(kāi)共享。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()關(guān)鍵點(diǎn)cur在函數(shù)內(nèi)創(chuàng)建、函數(shù)內(nèi)關(guān)閉不跨線程傳遞。WAL模式讓讀寫可以并行busy_timeout避免瞬時(shí)鎖沖突直接拋異常。3.2 方案 B全局鎖 共享連接寫密集、邏輯簡(jiǎn)單如果你就是想共用一個(gè)連接那必須給每次游標(biāo)操作加鎖把并發(fā)寫變成串行寫。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 快很多這也是原日志里提到的經(jīng)驗(yàn)。鎖的粒度覆蓋「取游標(biāo) → 執(zhí)行 → 提交 → 關(guān)游標(biāo)」整段中間不能有別的線程插進(jìn)來(lái)。3.3 兩套方案對(duì)照維度方案 A 線程本地連接方案 B 全局鎖并發(fā)讀真并行串行并發(fā)寫WAL 下可并行讀、寫排隊(duì)完全串行代碼復(fù)雜度中低適合場(chǎng)景爬蟲(chóng)入庫(kù) 查詢混合純批量寫入游標(biāo)復(fù)用風(fēng)險(xiǎn)無(wú)靠鎖規(guī)避4. 驗(yàn)證請(qǐng)求復(fù)現(xiàn)報(bào)錯(cuò)與修復(fù)后回歸先寫一個(gè)必崩的復(fù)現(xiàn)腳本確認(rèn)你遇到的就是這個(gè)問(wèn)題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() # 全局共享游標(biāo)錯(cuò)誤根源 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()跑起來(lái)大概率在幾秒內(nèi)拋Recursive use of cursors not allowed。記下這個(gè)堆棧它就是基線。換成方案 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])預(yù)期輸出total rows: 2000且無(wú)異常。如果數(shù)字對(duì)得上、進(jìn)程正常退出說(shuō)明游標(biāo)復(fù)用問(wèn)題已經(jīng)解決。想用 AI 幫你讀這段堆??梢园褕?bào)錯(cuò)貼給模型對(duì)話接口請(qǐng)求體走 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]把復(fù)現(xiàn)腳本的堆棧和你的線程模型描述一起丟進(jìn)去讓它判斷是共享游標(biāo)還是事務(wù)未提交導(dǎo)致的。5. 本篇常見(jiàn)錯(cuò)排查報(bào)錯(cuò)依舊出現(xiàn)但我已經(jīng)加了鎖。檢查鎖的范圍是不是只包了execute沒(méi)包c(diǎn)ommit。commit也會(huì)操作游標(biāo)內(nèi)部狀態(tài)必須一起鎖。另外確認(rèn)沒(méi)有別的地方繞過(guò)封裝直接用了全局cursor。換成線程本地連接后報(bào) database is locked。這是寫鎖競(jìng)爭(zhēng)不是游標(biāo)問(wèn)題。把busy_timeout調(diào)大并確認(rèn)開(kāi)了 WAL 模式。WAL 下讀不阻塞寫寫之間仍會(huì)排隊(duì)超時(shí)就拋這個(gè)錯(cuò)。異步場(chǎng)景asyncio里用同步 sqlite3 卡住事件循環(huán)。sqlite3是阻塞庫(kù)別直接在協(xié)程里調(diào)。用asyncio.to_thread(pool.execute, sql, params)把它丟到線程池或者干脆用aiosqlite。但注意aiosqlite內(nèi)部也是線程池游標(biāo)管理邏輯一樣要遵守「不跨任務(wù)共享游標(biāo)」。check_same_threadFalse 設(shè)了還是報(bào)錯(cuò)。這個(gè)參數(shù)只關(guān)閉「連接創(chuàng)建線程校驗(yàn)」不解決游標(biāo)并發(fā)。它和加鎖是兩件事別混。批量插入時(shí)部分?jǐn)?shù)據(jù)丟失。檢查executemany后有沒(méi)有 commit以及異常分支里是否吞掉了錯(cuò)誤。建議在finally里只關(guān)游標(biāo)commit 放在 try 成功路徑上失敗時(shí)顯式 rollback。多進(jìn)程而不是多線程時(shí)報(bào)錯(cuò)。多進(jìn)程各自有獨(dú)立連接一般不會(huì)出這個(gè)錯(cuò)。如果出現(xiàn)多半是 fork 之前就建好了連接子進(jìn)程繼承了父進(jìn)程的游標(biāo)狀態(tài)。改成在子進(jìn)程內(nèi)建連接。6. 把統(tǒng)一 Key 接進(jìn)你的排查流程回到實(shí)際工程多線程 Sqlite3 的游標(biāo)問(wèn)題本質(zhì)是「共享可變狀態(tài) 并發(fā)訪問(wèn)」。修復(fù)手段無(wú)非隔離線程本地或串行加鎖選哪個(gè)看你的讀寫比例。我自己的爬蟲(chóng)項(xiàng)目最后用的是方案 A因?yàn)椴樵兒蛯懭牖煸谝黄鹁€程本地連接最省心。TaoToken 在這里的角色是排查助手把報(bào)錯(cuò)堆棧、線程模型、你的封裝代碼一起發(fā)給模型讓它幫你確認(rèn)鎖粒度夠不夠、有沒(méi)有漏掉的共享游標(biāo)。統(tǒng)一 Key 的好處是不用在排查腳本里維護(hù)多套憑證settings.json里改一個(gè)字段就能換模型。接入文檔在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的請(qǐng)求格式和錯(cuò)誤碼說(shuō)明。如果你用 Claude Code 做這類排查參考 https://taotoken.net/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecode-anthropicutm_campaignrewrite 的配置方式把 base_url 指向 TaoToken 即可。最后留一個(gè)我踩過(guò)的坑別在finally里 commit。異常發(fā)生時(shí) commit 可能再次觸發(fā)游標(biāo)狀態(tài)異常把原始錯(cuò)誤蓋掉。commit 只在正常路徑做異常路徑 rollback 或直接關(guān)連接。