1. 从 500 万行导出说起:MySQL 流式查询与游标查询到底怎么选
业务系统里有一张wh_b_inventory库存流水表,单表 500 万行,某天产品提了个需求:把全量数据导出来做对账,顺便跑一遍批量修正。你打开 IDE 写下select * from wh_b_inventory,本地跑得好好的,一上测试环境就java.lang.OutOfMemoryError: GC overhead limit exceeded。这不是 SQL 写错了,而是 JDBC 默认把整个结果集一次性拉进 JVM 堆内存。
MySQL 的 JDBC 驱动提供了三种结果集读取模式:常规查询(RowDataStatic)、流式查询(RowDataDynamic)、游标查询(RowDataCursor)。它们对应三种完全不同的内存行为:常规查询把全部行缓存到客户端内存;流式查询每次只从网络读一行;游标查询按fetchSize批量读取并缓存在客户端缓冲区。理解这三者的差异,是处理大结果集的基本功。
这篇内容聚焦工程落地:在 TaoToken 统一 Key/API 通道下,怎么配置连接参数、怎么写游标读取代码、怎么验证内存占用差异、怎么排查常见报错。适合正在做数据迁移、批量导出、离线对账的后端同学,也适合刚接触 JDBC 调优、想搞清楚setFetchSize到底干了什么的小白。下面所有代码都可以直接复制到本地跑,连接参数部分我会给出完整片段。
2. TaoToken 统一 Key 前置准备:Base URL、Key 与 Model ID 三件套
在开始写 JDBC 代码之前,先把模型调用通道准备好。TaoToken 提供统一的 API 入口,把不同模型的调用收敛到一套 Key 和 Base URL 上,这样你在写数据处理的辅助脚本(比如让模型帮忙生成对账 SQL、解释执行计划)时,不用来回切换配置。
你需要准备三样东西:Base URL、API Key、Model ID。Base URL 固定为https://taotoken.net/api,注意这个地址不带任何查询参数。API Key 在控制台的 API Keys 页面创建,创建后只显示一次,复制下来存到环境变量里,别硬编码进代码。Model ID 根据你要用的模型填,比如做代码辅助可以选对应的编码模型。
我习惯把这三个值写进环境变量,避免每次改代码:
export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_MODEL_ID="你的模型ID"如果你用的是 Claude Code 这类编码工具,配置方式略有不同。Claude Code 读取的是~/.claude/settings.json,你需要把 Base URL 和 Key 写进去。这里给出一个可复制的 settings 片段,路径和字段名保持原样:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的key", "ANTHROPIC_MODEL": "你的模型ID" } }如果你用的是 Cline 配合 MCP,配置写在 Cline 的 MCP 设置里,同样是 Base URL + Key + Model ID 三件套。Codex 用户则编辑~/.codex/auth.json,把对应的字段填进去。不管哪种工具,核心都是这三个值,缺一个就连不上。
创建 Key 的入口在控制台的 API Keys 页面,文档在接入文档里,里面有各语言的调用示例。建议先把 Key 创建好、环境变量配好,再往下看 JDBC 部分,因为后面的验证脚本会用到模型来辅助分析执行结果。
3. 可复制配置:三种查询模式的连接参数与代码片段
这一节是全文的核心,给出三种模式的完整可复制代码。先看常规查询,也就是默认行为。
常规查询不需要任何额外参数,PreparedStatement执行后,驱动会把整个结果集读进RowDataStatic,然后next()从内存里逐行取。代码长这样:
String url = "jdbc:mysql://127.0.0.1:3306/mysql_demo?useSSL=false&serverTimezone=UTC"; Connection conn = DriverManager.getConnection(url, "root", "你的密码"); PreparedStatement ps = conn.prepareStatement("select * from wh_b_inventory limit 50000"); ResultSet rs = ps.executeQuery(); int count = 0; while (rs.next()) { count++; } System.out.println(count);把 JVM 堆调到-Xms70m -Xmx70m,跑 5 万行就会看到堆内存逐步增长,最终 OOM。这是预期行为,因为RowDataStatic在生成对象时就把所有记录读进内存了。
流式查询的关键是setFetchSize(Integer.MIN_VALUE),同时Statement必须用TYPE_FORWARD_ONLY和CONCUR_READ_ONLY:
String url = "jdbc:mysql://127.0.0.1:3306/mysql_demo?useSSL=false&serverTimezone=UTC"; Connection conn = DriverManager.getConnection(url, "root", "你的密码"); Statement stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); stmt.setFetchSize(Integer.MIN_VALUE); ResultSet rs = stmt.executeQuery("select * from wh_b_inventory limit 50000"); int count = 0; while (rs.next()) { count++; } System.out.println(count);Integer.MIN_VALUE是 MySQL 驱动的约定值,表示启用流式模式,底层用RowDataDynamic,每次next()触发一次网络 IO 读一行。注意流式查询会独占连接,在读完或关闭ResultSet之前,不能对这个连接发起其他查询,否则抛异常。
游标查询需要在连接串里加useCursorFetch=true,然后设置一个正数的fetchSize:
String url = "jdbc:mysql://127.0.0.1:3306/mysql_demo?useSSL=false&serverTimezone=UTC&useCursorFetch=true"; Connection conn = DriverManager.getConnection(url, "root", "你的密码"); Statement stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); stmt.setFetchSize(1000); ResultSet rs = stmt.executeQuery("select * from wh_b_inventory limit 50000"); int count = 0; while (rs.next()) { count++; } System.out.println(count);游标模式底层用RowDataCursor,内部维护一个缓冲区,先看缓冲区有没有数据,有就返回下一行;缓冲区空了就向服务端发一次请求,拉fetchSize条回来缓存,再逐行返回。fetchSize设成 1000,意味着每 1000 行一次网络往返。
三种模式的参数对照如下:
| 模式 | 连接参数 | fetchSize | 底层实现 | 内存行为 |
|---|---|---|---|---|
| 常规查询 | 无 | 不设置 | RowDataStatic | 全量加载到 JVM |
| 流式查询 | 无 | Integer.MIN_VALUE | RowDataDynamic | 每次读一行 |
| 游标查询 | useCursorFetch=true | 正整数(如 1000) | RowDataCursor | 按 fetchSize 批量缓存 |
如果你在 TaoToken 的 Coding Plan 里让模型帮你生成这些代码,记得把 Base URL、Key、Model ID 三件套配好,模型才能正常返回。配置片段参考上一节的 settings.json。
4. 验证请求与成功结果:内存占用、耗时与并发表现
配好代码后,怎么确认真的生效了?我一般从三个维度验证:内存、耗时、并发。
内存验证最简单:把 JVM 参数设成-Xms70m -Xmx70m,分别跑三种模式,观察堆内存曲线。常规查询跑 5 万行会看到堆内存持续攀升,最终 OOM。流式查询和游标查询在同样 70m 堆下都能跑完,不 OOM。这说明后两者确实没有把全量数据堆在 JVM 里。
耗时验证用同一张表、同样的limit 50000,记录System.currentTimeMillis()差值。实测下来,常规查询和流式查询耗时接近,流式查询并没有因为逐行读而变快,反而因为网络往返次数多,单行读取的开销更大。游标查询在fetchSize=1000时耗时明显短于流式,因为它把 1000 行的网络往返合并成一次,减少了 IO 次数。这个结论和很多人的直觉相反:流式查询解决的是内存问题,不是速度问题。
并发验证用 JMeter 起 10 个线程,1 秒内并发调用。流式查询在并发下堆内存走势稳定,不会叠加式增长,因为每个连接每次只持有一行数据。游标查询在并发下内存占用会随并发数乘以fetchSize增长,10 个线程各缓存 1000 行,总量还是可控的,但如果fetchSize设得很大、并发又高,就要留意。
验证游标查询是否真的走了服务端游标,可以在 MySQL 侧看SHOW PROCESSLIST,或者观察服务端临时空间。启用useCursorFetch读大表时,MySQL 需要建临时空间存放待取数据,你会看到 IOPS 上升、磁盘空间增长、客户端发起 SQL 后有一段等待时间(服务端在准备数据),数据准备完成后网络传输阶段 IOPS 从读写转为读取。这些都是正常现象,不是故障。
如果你用 TaoToken 的模型对话功能让模型分析这些监控数据,把 Base URL 和 Key 配好,直接把SHOW PROCESSLIST的输出贴进去,模型能帮你判断当前连接处于哪个阶段。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
实际跑的时候,报错往往不在 JDBC 本身,而在通道配置。下面几个是我踩过的坑。
第一个是401 Unauthorized。这个通常出现在调用 TaoToken API 时 Key 没配对。检查环境变量TAOTOKEN_API_KEY是否设置、是否有多余空格、是否用了已删除的 Key。Claude Code 用户检查~/.claude/settings.json里的ANTHROPIC_API_KEY字段,注意 JSON 不能有尾逗号。
第二个是local proxy failed。这个报错一般出现在本地代理配置和实际网络环境不匹配时。检查你的 Base URL 是否写成了https://taotoken.net/api,不要多加路径或参数。如果你在 settings.json 里把 Base URL 写成了带/v1的地址,也可能触发这个错。统一用文档里给的地址。
第三个是reading choices相关报错。这个通常出现在模型返回体解析阶段,说明请求发出去了但返回格式不符合预期。检查 Model ID 是否填对,不同模型的返回结构可能不同。如果你在代码里硬编码了某个模型的返回字段,换模型后就会解析失败。建议先用模型对话功能手动发一条请求,确认返回结构,再写解析代码。
第四个是 OAuth 相关报错。Claude Code 首次使用会走 OAuth 流程,如果你已经配了 API Key,就不需要再走 OAuth。检查 settings.json 里是否同时存在 OAuth 相关字段和 API Key 字段,两者冲突时以哪个为准取决于工具版本。最稳妥的做法是只保留 API Key 配置,清掉 OAuth 缓存。
排查顺序建议:先确认 Base URL、Key、Model ID 三件套齐全且正确,再用 curl 直接打一次 API 确认通道通,最后才怀疑 JDBC 代码。通道不通的情况下调 JDBC 是白费功夫。
6. 语义一致收尾:把连接参数和读取模式固化进项目
回到最初的问题:500 万行数据怎么读。结论很清晰。常规查询只适合小结果集,数据量一大就 OOM。流式查询适合内存极度受限、对速度不敏感的场景,它保证不 OOM,但逐行网络往返开销大。游标查询在内存和速度之间取得平衡,通过fetchSize控制批量大小,既避免全量加载,又减少网络往返,是大多数大结果集场景的首选。
落地时把连接参数固化进配置:useCursorFetch=true写进连接串,fetchSize设成 500 到 2000 之间的值,根据单行大小和网络延迟调。记得在finally块里关闭ResultSet、Statement、Connection,游标查询在结果集未读完时关闭连接,服务端临时空间才会释放。分页读取时,如果业务允许,用主键范围分页比limit offset更稳,避免深分页扫描。
通道侧把 TaoToken 的 Base URL、Key、Model ID 配好,需要模型辅助分析执行计划或生成对账 SQL 时直接调用。API Keys 在控制台创建,接入文档里有各语言示例,模型对话可以快速验证通道是否通,长期编码任务用 Coding Plan 更省心。把这些配置和 JDBC 参数一起写进项目模板,下次遇到大表读取就不用重新踩一遍坑了。