
1. 循环里 SSCursor 卡住到底卡在哪一次真实排查复盘python里用SSCursor流式游标读大表本来是为了省内存结果在while循环里跑完第一轮就卡住不动不报错、不退出、CPU 也不高这种「假死」状态最折磨人。我先把结论摆出来SSCursor 是无缓冲游标结果集没取完之前它绑定的那条连接不能执行任何其他 SQL包括新建游标、包括写另一张表。你如果在同一个conn上一边fetchone()一边execute(insert)第一轮之后连接就进入了一种「半读半写」的僵持状态MySQL 服务端在等客户端把结果集读完客户端在等服务端返回 insert 结果双方互等于是卡住。这个场景适合谁适合正在用pymysql做 ETL、数据迁移、批量清洗的同学尤其是那种「从表 A 流式读、处理后写表 B」的循环任务。它不是什么高深问题但搜索关键词不对就很容易绕远路我当初搜「pymysql 卡死」「SSCursor 无响应」都没命中最后是从「流式游标 结果集未消费完」这个角度才想通的。先看最小复现这段代码几乎一定会卡import pymysql conn pymysql.connect( host127.0.0.1, userroot, passwordroot, databasedemo, charsetutf8mb4 ) cursor pymysql.cursors.SSCursor(conn) cursor.execute(SELECT id, name FROM source_table) while True: row cursor.fetchone() if row is None: break # 处理 row然后写回 cursor.execute( INSERT INTO target_table (id, name) VALUES (%s, %s), row ) conn.commit()跑起来第一行能进去fetchone()拿到第一条execute(insert)也可能成功一次然后第二轮fetchone()就再也不返回了。原因就是上面说的SSCursor对应的结果集还挂在连接上你却在同一条连接上发了新的写请求协议层面这条连接已经被「占用」了。还有一个更隐蔽的变体有人为了「干净」在循环里cursor.close()再重新conn.cursor()照样卡。因为问题不在游标对象而在连接。只要结果集没读完这条连接就不能复用。那为什么改了NET_WRITE_TIMEOUT之后「能继续跑」但row全变成None因为超时把服务端那次写等待强行打断了连接状态被破坏后续fetchone()读到的是空结果看起来像「读完了」其实是连接已经不可信了。这个坑我踩过改超时只是掩盖症状不是修复。所以排查方向要收敛到三件事连接是否被复用、结果集是否被完整消费、游标和连接是否在正确的作用域里关闭。下面几节我会把连接配置、可复制的代码片段、验证动作和报错对照一项项拆开你可以直接照着改。2. TaoToken 前置准备把连接配置统一到可复用的入口在动手改 SSCursor 之前先把「连接从哪来」这件事理顺。很多卡住问题表面是游标根子是连接参数散落在各处、超时和字符集不一致。我现在的习惯是所有数据库/模型服务的连接配置集中到一个入口读源库和写目标库各自独立绝不共用。如果你除了 MySQL 还要调模型做数据清洗比如把文本字段送去模型打标再写回可以把模型侧的调用也统一走 TaoToken。它的 API 地址是https://taotoken.net/api官网入口在https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。这样你的 ETL 脚本里数据库连接和模型连接是两套独立资源互不干扰正好对应 SSCursor「读写必须分连接」的原则。拿 Key 的路径很直接进控制台创建 API Key然后在接入文档里对照 Base URL 和 Model ID。常用入口我列一下方便你按需跳模型对话体验https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite长期编码/Agent 场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite这里要强调一个原则源库连接、目标库连接、模型调用连接三者物理隔离。SSCursor 卡住的本质就是「一条连接想干两件事」模型调用如果也塞进同一条链路排查难度会翻倍。把配置抽成独立函数后面改超时、改字符集都只动一处。一个可复用的连接工厂大概长这样注意cursorclass只在需要流式读的那条连接上指定import pymysql def make_conn(host, user, password, database, streamingFalse): kwargs dict( hosthost, useruser, passwordpassword, databasedatabase, charsetutf8mb4, autocommitFalse, read_timeout120, write_timeout120, ) if streaming: kwargs[cursorclass] pymysql.cursors.SSCursor return pymysql.connect(**kwargs)read_timeout/write_timeout是客户端侧的超时和 MySQL 服务端的net_read_timeout/net_write_timeout是两回事两边都要看。很多人只改了一边结果还是卡。下一节我把完整的可复制配置和最小复现脚本给全。3. 可复制配置双连接 流式游标的最小可用脚本这一节是核心直接给能跑的代码。思路就一句话读用一条连接写用另一条连接各自独立提交。下面这段我实测过从表 A 流式读、处理后写表 B循环几千轮不卡。import pymysql # 读连接流式游标只负责 SELECT read_conn pymysql.connect( host127.0.0.1, userroot, passwordroot, databasedemo, charsetutf8mb4, cursorclasspymysql.cursors.SSCursor, read_timeout300, ) # 写连接普通游标只负责 INSERT/UPDATE write_conn pymysql.connect( host127.0.0.1, userroot, passwordroot, databasedemo, charsetutf8mb4, autocommitFalse, write_timeout300, ) read_cur read_conn.cursor() write_cur write_conn.cursor() read_cur.execute(SELECT id, name FROM source_table) batch [] BATCH_SIZE 500 while True: row read_cur.fetchone() if row is None: break batch.append(row) if len(batch) BATCH_SIZE: write_cur.executemany( INSERT INTO target_table (id, name) VALUES (%s, %s), batch ) write_conn.commit() batch.clear() if batch: write_cur.executemany( INSERT INTO target_table (id, name) VALUES (%s, %s), batch ) write_conn.commit() read_cur.close() read_conn.close() write_cur.close() write_conn.close()几个关键点必须说清楚。第一SSCursor通过cursorclass在连接级别指定这样read_conn.cursor()出来的就是流式游标不用手动pymysql.cursors.SSCursor(read_conn)。第二写操作全部走write_conn和读连接没有任何交集。第三用executemany攒批减少往返次数也顺带缩短了单轮处理时间避免超过服务端超时。如果你确实需要单条插入而不是攒批把executemany换成execute即可但一定要保证写连接是独立的write_cur.execute( INSERT INTO target_table (id, name) VALUES (%s, %s), row ) write_conn.commit()再说超时配置。MySQL 服务端默认net_write_timeout是 60 秒意思是服务端往客户端写数据时如果 60 秒没写完就断开。流式读的时候如果你每取一行就去做很重的处理比如调模型、算大矩阵超过 60 秒服务端可能就把这条连接掐了。查看当前值SHOW GLOBAL VARIABLES LIKE %timeout%;调大它有两种方式。临时生效SET GLOBAL net_write_timeout 600; SET GLOBAL net_read_timeout 600;永久生效改配置文件my.iniWindows或my.cnfLinux在[mysqld]段加[mysqld] net_write_timeout 600 net_read_timeout 600改完重启 MySQL 服务。注意这是全局参数影响所有连接别设得离谱大600 到 1800 秒通常够用。客户端侧的read_timeout/write_timeout也要同步放大否则客户端先超时。还有一个容易忽略的点SSCursor读的时候如果中途break跳出循环结果集没读完这条连接就废了必须close()掉不能还回连接池。所以流式读的连接不要放进连接池复用用完即弃最安全。4. 验证请求与成功结果逐项确认卡住位置改完代码别急着跑全量先用小数据验证。我一般分四步确认每步都有明确的预期结果。第一步确认读连接真的在流式读。在fetchone()前后打日志import time n 0 while True: t0 time.time() row read_cur.fetchone() if row is None: print(结果集读完共, n, 行) break n 1 if n % 1000 0: print(f已读 {n} 行单次 fetch 耗时 {time.time()-t0:.4f}s)预期是每行 fetch 很快毫秒级日志持续输出。如果某次 fetch 卡住超过几秒说明服务端在等或者连接有问题。第二步确认写连接独立提交成功。在write_conn.commit()后查一下目标表行数write_cur.execute(SELECT COUNT(*) FROM target_table) print(目标表当前行数:, write_cur.fetchone()[0])注意这里用的是write_cur不是read_cur。如果你不小心用读连接去查又会触发「结果集未读完不能执行其他 SQL」的卡住。第三步确认没有跨连接混用。全局搜一下代码里有没有read_conn.cursor()之后又read_conn.commit()或read_conn.cursor()第二次的情况。流式读连接上除了fetchone()/fetchmany()/fetchall()不应该出现任何execute写操作。第四步压一轮真实数据。拿 1 万行跑一遍观察内存和耗时。SSCursor的内存占用应该基本平稳不会随行数线性增长。如果内存飙升说明你某处用了fetchall()那就退化成普通游标了。成功的结果长这样日志显示「已读 10000 行」目标表行数从 0 涨到 10000脚本正常退出没有卡顿。如果中途卡住对照下一节的报错排查。顺带说一句如果你的处理逻辑里要调模型比如给每行文本打标签把模型调用放在读和写之间但不要在读连接上做任何数据库操作。模型调用走 TaoToken 的 API和数据库连接完全解耦这样即使模型响应慢也只是拖慢单轮不会破坏数据库连接状态。5. 常见报错对照排查401、local proxy failed、reading choices、OAuth这一节把循环里可能撞到的报错逐条对照。注意SSCursor 卡住本身通常不报错但你在排查过程中改配置、加模型调用可能会引出下面这些。401 Unauthorized如果你在处理逻辑里调了模型 APIKey 没配或配错会返回 401。检查请求头里的Authorization: Bearer 你的KeyKey 从 API Keys 页面拿。注意 Base URL 要写全模型调用用https://taotoken.net/api别漏了/api。local proxy failed / connection refused这类是本地网络或代理配置问题。检查你的 HTTP 客户端有没有误设代理环境变量HTTP_PROXY/HTTPS_PROXY如果指向一个不存在的本地端口就会报这个。清掉环境变量再试unset HTTP_PROXY HTTPS_PROXYreading choices 相关报错调模型返回结构解析失败时会出现通常是响应体不是预期的 JSON。先打印原始响应确认再检查 Model ID 是否写对。Model ID 要和文档里的一致别自己拼。OAuth / token 过期如果你用的是需要 OAuth 的客户端比如某些 CLI 工具token 过期会报这个。重新走一遍授权流程或者换成 API Key 方式。Codex auth.json / Cline MCP / CC Switch 三件套如果你在 ETL 脚本之外还用这些工具做辅助开发配置时记住三件套必须齐全——Base URL、Key、Model ID。以auth.json为例{ base_url: https://taotoken.net/api, api_key: sk-你的Key, model: 你的ModelID }Cline 的 MCP 配置和 CC Switch 同理Base URL 填https://taotoken.net/apiKey 填控制台生成的Model ID 填文档里对应的。三者缺一工具就连不上报错往往很含糊。回到 SSCursor 本身最该记住的排查顺序是先确认读写是否分连接再确认结果集是否读完最后才看超时。90% 的卡住都是第一条。我试过把NET_WRITE_TIMEOUT调到很大结果只是让卡住来得晚一点根因没解决。真正修好是在拆成双连接那一刻。6. 把配置沉淀成模板下次直接复用排查完别急着删代码把双连接模板沉淀下来。我的做法是写一个db.py里面两个工厂函数一个给流式读一个给普通写参数从环境变量读。这样下次遇到类似任务直接 import不用重新踩坑。import os import pymysql def read_conn(): return pymysql.connect( hostos.getenv(DB_HOST, 127.0.0.1), useros.getenv(DB_USER, root), passwordos.getenv(DB_PASS, root), databaseos.getenv(DB_NAME, demo), charsetutf8mb4, cursorclasspymysql.cursors.SSCursor, read_timeout300, ) def write_conn(): return pymysql.connect( hostos.getenv(DB_HOST, 127.0.0.1), useros.getenv(DB_USER, root), passwordos.getenv(DB_PASS, root), databaseos.getenv(DB_NAME, demo), charsetutf8mb4, autocommitFalse, write_timeout300, )用的时候各取各的读连接用完就close()不进池。写连接可以复用但每次批量提交后记得commit()。如果处理逻辑里要调模型把模型客户端也单独封装Base URL 用https://taotoken.net/api和数据库连接彻底分开。最后留一个实用技巧给循环加一个「心跳日志」每处理 N 行打一次时间戳和已处理行数。卡住的时候看最后一条日志停在哪一行就能快速判断是读卡了还是写卡了。这个习惯帮我省了很多瞎猜的时间。