1. 百万行查询把内存打满:SSCursor 流式游标到底解决什么问题
如果你用 Python 的 pymysql 从 MySQL 里捞过百万级数据,大概率见过这个画面:脚本刚跑起来内存还正常,几十秒后内存曲线一路往上冲,最后被系统 OOM Killer 干掉,或者本地直接卡死。我第一次遇到这个问题时,还以为是数据量太大机器扛不住,后来才发现根因不在数据量,而在游标的读取方式。
pymysql 默认使用的游标是Cursor,它执行execute()之后,会把 MySQL 返回的整个结果集一次性拉到客户端内存里。也就是说,你查 100 万行、每行 1KB,客户端就要先准备好约 1GB 的内存来装这批数据,然后你才轮到fetchone()一行行取。fetchone()看起来是"逐行读",但它读的是已经躺在内存里的结果集,内存峰值早在execute()那一刻就定死了。
SSCursor(Server-Side Cursor,服务端游标)换了个思路:它不把结果集一次性搬回客户端,而是让 MySQL 服务端保持查询上下文,客户端每次fetchone()才通过网络取一行(或一小批)。这样客户端内存占用基本是常数级,跟你查 10 行还是 1000 万行关系不大。代价是网络往返变多、查询期间服务端连接被占用,不能在这条连接上再发别的查询。
这篇文章面向的是正在被 pymysql 大数据量查询内存问题困扰的 Python 开发者,尤其是做数据迁移、离线清洗、报表导出这类批量任务的场景。我会从 SSCursor 的原理讲起,给出可直接复制的连接参数和游标切换配置,用memory_profiler实测两种游标的内存差异,再补上多工具调用凭证统一管理的部分——当你的清洗脚本、定时任务、AI 辅助编码工具都要连数据库或调模型时,Key 散落各处本身就是一类隐患。
核心检索词先摆出来:SSCursor 流式游标解决 pymysql 大数据量查询内存过高,这是本篇要落地的目标。适合谁?写过SELECT * FROM 大表然后被内存教做人的 Python 后端、数据工程同学,以及想搞清楚"逐行读取"和"流式读取"区别的人。
先说结论:普通游标是"先全搬回家再慢慢看",SSCursor 是"看一行取一行"。理解这一句,后面的配置和验证都是围绕它展开的。
2. 前置准备:TaoToken 统一 Key 与 pymysql 环境搭建
在动手改游标之前,先把环境和凭证这两件事理清楚。环境部分很直接:Python 3.8+、pymysql、memory_profiler,再加一个能连的 MySQL 实例。凭证部分是我更想聊的——很多人的排查脚本里硬编码了数据库密码,同时又在别的工具里散落着各种 API Key,时间一长自己都记不清哪个 Key 对应哪个服务。
TaoToken 在这里的角色是统一管理多工具调用凭证。你可以把它理解成一个集中的 Key 分发入口:数据库排查脚本要调模型做日志分析、AI 编码助手要连模型、定时任务要调接口,这些凭证不必各自为政地写在代码或环境变量里,而是通过一个统一的 Key 来管理。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api 。
先把依赖装上:
pip install pymysql memory_profiler如果你打算在排查脚本里顺带调用模型能力(比如让模型帮你分析慢查询日志),可以再装一个 OpenAI 兼容的客户端:
pip install openai然后配置统一 Key。TaoToken 的 API 兼容 OpenAI 风格,所以客户端初始化时把base_url指向 TaoToken 的 API 地址即可:
from openai import OpenAI client = OpenAI( api_key="你的_TaoToken_Key", base_url="https://taotoken.net/api" )这里有个细节值得强调:Base URL、Key、Model ID 三件套要配套。Base URL 用https://taotoken.net/api,Key 从控制台生成,Model ID 按你实际要用的模型填。三者缺一或者对不上,最常见的表现就是 401 或者模型找不到。如果你用的是 Claude Code 这类编码工具,配置逻辑一样,把 Base URL 和 Key 填进对应位置,Model ID 选对就行。
数据库这边,我建议单独建一个测试库,造一张百万行的表来复现问题。下面这段 SQL 可以快速造数据(用存储过程或 Python 批量插入都行,这里给个 Python 造数脚本):
import pymysql import random conn = pymysql.connect( host="127.0.0.1", user="root", password="your_password", database="test_db", charset="utf8mb4" ) cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS big_table ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64), payload VARCHAR(512), created_at DATETIME ) """) rows = [] for i in range(1_000_000): rows.append(( f"user_{i}", "x" * 400, "2024-01-01 00:00:00" )) if len(rows) >= 5000: cursor.executemany( "INSERT INTO big_table (name, payload, created_at) VALUES (%s, %s, %s)", rows ) rows = [] if rows: cursor.executemany( "INSERT INTO big_table (name, payload, created_at) VALUES (%s, %s, %s)", rows ) conn.commit() cursor.close() conn.close()百万行、每行 payload 400 字节,总量大概 400MB 上下,足够把普通游标的内存问题暴露出来。造数过程本身可能有点慢,耐心等它跑完,或者把行数降到 50 万先验证逻辑。
环境就绪后,下一步是真正切换游标并对比内存。这里先埋一个点:SSCursor 有两种,SSCursor返回元组,SSDictCursor返回字典,后者更直观但内存略高一点点,按需选。
3. 可复制配置:普通游标与 SSCursor 的切换写法
这一节给可直接复制的代码。先看普通游标的写法,也就是大多数人默认在用的:
import pymysql conn = pymysql.connect( host="127.0.0.1", user="root", password="your_password", database="test_db", charset="utf8mb4" ) # 默认游标:结果集一次性加载到客户端内存 cursor = conn.cursor() cursor.execute("SELECT * FROM big_table") while True: row = cursor.fetchone() if row is None: break # 处理 row cursor.close() conn.close()这段代码的fetchone()循环看着很"流式",但内存峰值出现在execute()返回时。你可以用memory_profiler验证,后面会给命令。
再看 SSCursor 的写法,改动其实很小,关键在cursor()的参数:
import pymysql conn = pymysql.connect( host="127.0.0.1", user="root", password="your_password", database="test_db", charset="utf8mb4", cursorclass=pymysql.cursors.SSDictCursor # 关键:服务端游标 ) cursor = conn.cursor() cursor.execute("SELECT * FROM big_table") while True: row = cursor.fetchone() if row is None: break # 处理 row,此时 row 是 dict cursor.close() conn.close()两种写法可以放在同一个连接配置里,通过cursorclass切换。如果你不想改连接参数,也可以在获取游标时指定:
cursor = conn.cursor(pymysql.cursors.SSDictCursor)SSDictCursor返回字典,SSCursor返回元组。字典可读性好,元组内存更省,百万行级别两者差异不大,按团队习惯选。
这里必须提醒几个 SSCursor 的硬约束,踩过坑的人都知道:
第一,同一条连接在 SSCursor 未读完之前不能发新查询。因为服务端还在为这个游标保持结果集,你再execute()会报Commands out of sync。解决办法是读完再发,或者另开连接。
第二,SSCursor 不支持cursor.rowcount的准确值,读之前拿不到总行数。需要进度条的话,自己用SELECT COUNT(*)单独查一次。
第三,连接超时。流式读取期间如果处理逻辑太慢,MySQL 的net_write_timeout可能把连接掐掉。大批量任务建议调大这个参数,或者在连接里设置:
conn = pymysql.connect( host="127.0.0.1", user="root", password="your_password", database="test_db", charset="utf8mb4", cursorclass=pymysql.cursors.SSDictCursor, read_timeout=600, write_timeout=600 )如果你用配置文件管理连接,可以写成 TOML 或 JSON,方便多环境切换。比如db_config.json:
{ "host": "127.0.0.1", "user": "root", "password": "your_password", "database": "test_db", "charset": "utf8mb4", "cursorclass": "pymysql.cursors.SSDictCursor", "read_timeout": 600, "write_timeout": 600 }读取时把cursorclass字符串映射回类对象即可。这样切换普通游标和流式游标只改一个字段,排查时非常方便。
配置给完了,下一节用memory_profiler实测,把"内存到底降了多少"这件事量化出来。
4. 验证请求与成功结果:memory_profiler 实测内存差异
光说原理不够,得拿数据说话。memory_profiler可以按行报告内存占用,正好用来对比两种游标。
先写一个对比脚本compare_cursor.py:
import pymysql from memory_profiler import profile DB_CONF = dict( host="127.0.0.1", user="root", password="your_password", database="test_db", charset="utf8mb4" ) @profile def read_with_normal_cursor(): conn = pymysql.connect(**DB_CONF) cursor = conn.cursor() cursor.execute("SELECT * FROM big_table") count = 0 while True: row = cursor.fetchone() if row is None: break count += 1 cursor.close() conn.close() return count @profile def read_with_ss_cursor(): conn = pymysql.connect( **DB_CONF, cursorclass=pymysql.cursors.SSDictCursor ) cursor = conn.cursor() cursor.execute("SELECT * FROM big_table") count = 0 while True: row = cursor.fetchone() if row is None: break count += 1 cursor.close() conn.close() return count if __name__ == "__main__": print("normal cursor rows:", read_with_normal_cursor()) print("ss cursor rows:", read_with_ss_cursor())运行方式:
python -m memory_profiler compare_cursor.pymemory_profiler会逐行打印内存增量。实测下来(百万行、payload 400 字节的表),普通游标在execute()那一行的内存增量会冲到几百 MB 甚至接近 1GB,而 SSCursor 的execute()增量通常只有几 MB,整个循环过程内存曲线基本是平的。这就是流式游标的核心价值:内存占用从"跟结果集大小成正比"变成"跟单行大小成正比"。
如果你想看整体峰值而不是逐行,可以用mprof:
mprof run compare_cursor.py mprof plotmprof plot会生成内存随时间变化的曲线图,普通游标是一条陡峭上升的斜线,SSCursor 是一条接近水平的线,对比非常直观。
成功结果长这样:脚本跑完,两种方式返回的行数一致(都是 1000000),但普通游标峰值内存可能是 SSCursor 的几十倍。我在一台 8GB 内存的机器上试过,普通游标查 200 万行直接触发 OOM,换成 SSCursor 后稳定跑完,峰值内存不到 100MB。
这里补一个实际场景:如果你在清洗脚本里还要调用模型做数据分类,可以把 TaoToken 的调用嵌进循环,但注意别在 SSCursor 未读完时用同一条数据库连接做别的操作。模型调用走的是 HTTP,跟数据库连接无关,所以不冲突。统一 Key 的好处在这里体现出来——清洗脚本、模型调用、编码工具共用一个 Key 管理体系,排查时不用满世界找凭证。
验证模型是否连通,可以用模型对话入口快速测一下:https://taotoken.net/api 配上 Key 发一条测试消息即可。如果返回正常,说明 Key 和 Base URL 都对。
5. 本篇常见错排查:401、Commands out of sync 与内存不降
排查环节按真实报错来。下面这几个是我和身边同学都踩过的。
报错一:pymysql.err.ProgrammingError: (2014, 'Commands out of sync; you can't run this command now')
这是 SSCursor 最经典的坑。原因是在流式游标还没读完的情况下,同一条连接上又执行了新的 SQL。比如你在while循环里顺手cursor.execute("UPDATE ..."),就会炸。解决办法:要么把更新操作放到另一条连接,要么先把结果读完再操作。下面这种写法就是错的:
cursor = conn.cursor(pymysql.cursors.SSDictCursor) cursor.execute("SELECT * FROM big_table") for row in cursor: conn.cursor().execute("UPDATE other SET x=1") # 报错正确做法是另开连接处理写操作。
报错二:pymysql.err.OperationalError: (2013, 'Lost connection to MySQL server during query')
流式读取期间连接被服务端断开,通常是net_write_timeout太小,或者处理逻辑太慢。调大超时参数,或者检查网络稳定性。如果用了连接池,注意 SSCursor 占用的连接在读完前不能归还。
报错三:内存没降下来
有人换了 SSCursor 发现内存还是高,排查下来常见两个原因。一是fetchall()又用上了——SSCursor 配fetchall()等于把流式的优势全抹掉,结果集还是全进内存。二是循环里把每行append到一个大列表里,内存自然又上去了。流式读取要配合"边读边处理边丢弃",不要攒。
报错四:401 Unauthorized(TaoToken 调用侧)
如果你在脚本里调模型报 401,先检查三件套:Base URL 是不是https://taotoken.net/api,Key 是不是从控制台复制的完整串,Model ID 是不是当前账号可用的。三者任一不对都会 401。另外注意别把 Key 硬编码进 Git 仓库,用环境变量或配置文件管理。
报错五:local proxy failed类连接错误
这类错误通常出现在客户端配置了本地转发但目标不可达时。检查你的 Base URL 是否写成了本地地址,正确写法应直接指向 TaoToken 的 API 地址。如果你在 Cline、CC Switch 这类工具里配置,Base URL、Key、Model ID 三件套要填全,缺一个都可能连不上。
报错六:reading choices解析失败
调用模型返回的 JSON 结构不符合预期时会出现。常见于 Base URL 指向了非兼容端点,或者 Model ID 填错导致返回了错误结构。确认 Base URL 是 OpenAI 兼容端点,Model ID 拼写正确。
排查顺序建议:先确认数据库侧游标类型对不对,再确认内存是否真的降了,最后才看模型调用侧。数据库和模型是两条独立的链路,别混在一起排查。
6. 把统一 Key 和流式游标一起用起来
到这里,SSCursor 的配置、验证、排错都走完了。回到最初的问题:百万行查询内存飙升,根因是普通游标一次性加载结果集,解法是换成服务端流式游标,内存占用从跟结果集成正比变成跟单行成正比。memory_profiler能把这个差异量化出来,mprof plot能画出直观曲线。
实际项目里,我建议把游标类型做成可配置项,默认用普通游标,遇到大表查询自动切 SSCursor。判断依据可以是预估行数,也可以是表的数据量。切换成本很低,就一个cursorclass参数。
凭证管理这块,TaoToken 的统一 Key 思路值得用起来。当你的数据清洗脚本、定时任务、AI 编码助手都要调模型时,把 Base URL 统一成https://taotoken.net/api,Key 集中管理,比每个工具各配一套要省心得多。需要生成或管理 Key 的话,控制台入口在 https://taotoken.net/api-keys ;接入细节看文档 https://taotoken.net/doc ;想先验证模型连通性,用模型对话 https://taotoken.net/api 发条消息就行;如果是长期编码或 Agent 场景,Coding Plan 入口在 https://taotoken.net/coding-plan 。
最后留一个实用技巧:SSCursor 读取时,把fetchone()换成fetchmany(size=1000)批量取,能在内存和网络往返之间取个平衡,比单行取快不少,内存依然可控。这个参数按你的单行大小和网络延迟调,一般 500 到 2000 之间比较合适。