1. 从一次 AI 数据清洗任务说起:ResultHandler 为什么没省下内存
先说结论:MySQL 连接串里加上useCursorFetch=true只是打开了服务端游标的大门,真正决定 ResultHandler 是逐行回调还是批量回调、内存峰值有多高的,是FetchSize这个参数。很多人以为开了useCursorFetch就自动变成流式读取,结果跑完发现 JVM 堆里还是堆了几百 MB 的byte[],问题就出在FetchSize没配对。
我最近在做一个 AI 工具调用链的数据预处理任务:从一张约 55 万行的日志表里把历史对话记录捞出来,逐条做脱敏和向量化前的清洗,再通过 TaoToken 的统一 API 通道批量送给模型做意图分类。整条链路对内存很敏感,因为清洗进程和模型调用进程跑在同一台 4C8G 的机器上,如果 JDBC 一次性把 55 万行全拉到客户端,堆直接爆掉。
于是很自然地用上了 MyBatis 的ResultHandler,想着「逐行处理,内存恒定」。结果第一次跑完,用 JVisualVM 一看,byte[]的占用仅次于char[],每次波动差不多 10MB 上下,而且第一次全量获取耗时 10 秒左右。这说明数据其实还是被批量搬到了客户端,ResultHandler只是在这批数据之上做遍历,并没有真正实现「数据库搬一批、客户端处理一批」的节奏。
原因就是没开useCursorFetch。不开这个参数时,MySQL Connector/J 默认把整个结果集读到客户端内存里,ResultHandler拿到的只是一个已经完整加载的ResultSet的迭代器,省内存的打算自然落空。开了useCursorFetch=true之后,驱动才会用服务端游标,按FetchSize一批一批地取。这时候FetchSize的取值就直接决定了回调时机和内存曲线。
这篇就把我实测的几种FetchSize配置拆开讲:Integer.MIN_VALUE(流式)、10000、500、1,分别对应什么样的 ResultHandler 回调行为、内存占用和总耗时,以及为什么Integer.MIN_VALUE反而比10000快一倍。适合正在做大数据量导出、AI 训练数据预处理、批量调用模型接口的 Java 后端同学。
2. TaoToken 前置:统一 Key 与 API 通道在数据读取链路里的位置
在讲 JDBC 参数之前,先交代一下这条链路里 TaoToken 扮演的角色,因为后面验证请求会用到它。
我的清洗任务流程是这样的:MySQL 逐行读 → 本地脱敏 → 攒批 → 调用模型接口做分类 → 写回结果表。模型调用这一层,我用的是 TaoToken 的统一 API 通道。它的价值在于:不管底层换哪个模型,我的代码里只需要维护一个 Base URL 和一个 Key,模型 ID 通过参数切换。对于这种「读数据库 → 调模型 → 写数据库」的长链路任务,少一层适配就少一类报错。
TaoToken 的接入信息如下,后面配置和验证都会用到:
- 官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- API 地址:https://taotoken.net/api
- 模型对话(验证模型是否通):https://taotoken.net/api/chat/completions
- Coding Plan(长期编码/Agent 场景):https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- 控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
为什么要在 JDBC 文章里提这个?因为FetchSize配错导致的后果,在 AI 数据链路里会被放大。假设你FetchSize设成1,55 万行每行都要和数据库往返一次,光 JDBC 层就跑了 40 分钟;而你的模型调用是攒批发的,批次迟迟攒不满,整个任务就卡在「等数据」上。反过来,FetchSize设得太大,内存峰值上来,和模型调用进程抢内存,容易触发 GC 抖动甚至 OOM。所以FetchSize不是一个孤立的 JDBC 参数,它决定了整条 AI 数据管道的吞吐节奏。
我实测下来,Integer.MIN_VALUE配合服务端游标,是「内存可控 + 吞吐最高」的组合。下面给出完整可复制的配置。
3. 可复制配置:JDBC 参数、MyBatis ResultHandler 与 TaoToken 调用片段
这一节给三份可直接抄的配置:数据源连接串、ResultHandler 实现、TaoToken 调用封装。
3.1 数据源连接串(application.yml)
关键参数是useCursorFetch=true和defaultFetchSize。注意defaultFetchSize只在useCursorFetch=true时对服务端游标生效。
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/ai_clean?useCursorFetch=true&defaultFetchSize=10000&useServerPrepStmts=true&rewriteBatchedStatements=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: clean_user password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 4 minimum-idle: 1 connection-timeout: 30000这里有个坑:useServerPrepStmts=true建议一起开。服务端游标依赖服务端预处理语句,虽然 Connector/J 8.x 在某些情况下会自动处理,但显式打开更稳。defaultFetchSize设成10000是本文实测的基准值,后面会对比其他取值。
如果你用 MyBatis,defaultFetchSize会被@Options(fetchSize = ...)覆盖,所以也可以在 Mapper 方法上单独指定:
@Options(fetchSize = Integer.MIN_VALUE, resultSetType = ResultSetType.FORWARD_ONLY) @Select("SELECT id, content, created_at FROM chat_log WHERE status = 0") void streamAll(ResultHandler<ChatLog> handler);resultSetType必须是FORWARD_ONLY,否则服务端游标不生效。这一点文档里写得比较隐晦,但实测中如果设成SCROLL_INSENSITIVE,useCursorFetch会被忽略,又退回全量加载。
3.2 ResultHandler 实现片段
下面这个 Handler 做两件事:统计回调次数(用来观察是逐行还是批量)、攒够 200 条就调一次 TaoToken。
@Component public class ChatLogHandler implements ResultHandler<ChatLog> { private final TaoTokenClient taoTokenClient; private final List<ChatLog> buffer = new ArrayList<>(200); private int callbackCount = 0; public ChatLogHandler(TaoTokenClient taoTokenClient) { this.taoTokenClient = taoTokenClient; } @Override public void handleResult(ResultContext<? extends ChatLog> context) { ChatLog log = context.getResultObject(); callbackCount++; buffer.add(log); if (buffer.size() >= 200) { flush(); } } private void flush() { if (buffer.isEmpty()) { return; } List<String> contents = buffer.stream() .map(ChatLog::getContent) .collect(Collectors.toList()); taoTokenClient.classifyBatch(contents); buffer.clear(); } public int getCallbackCount() { return callbackCount; } }handleResult每被调用一次,就说明驱动从服务端取回了一条记录并交给了你。callbackCount的增速,就是判断FetchSize行为的最直接指标。
3.3 TaoToken 调用封装(Java)
Base URL 用https://taotoken.net/api,Key 从环境变量读,模型 ID 走参数。
@Component public class TaoTokenClient { private static final String BASE_URL = "https://taotoken.net/api"; private final String apiKey; private final HttpClient httpClient; public TaoTokenClient(@Value("${taotoken.api-key}") String apiKey) { this.apiKey = apiKey; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); } public void classifyBatch(List<String> contents) { String body = buildRequestBody(contents); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/chat/completions")) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); try { HttpResponse<String> response = httpClient.send( request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new IllegalStateException("TaoToken 调用失败: " + response.statusCode()); } } catch (Exception e) { throw new RuntimeException("TaoToken 请求异常", e); } } private String buildRequestBody(List<String> contents) { // 实际项目里用 Jackson 构造,这里简化示意 return "{\"model\":\"your-model-id\",\"messages\":[...]}"; } }对应的application.yml里加一行:
taotoken: api-key: ${TAOTOKEN_API_KEY}Key 从环境变量注入,不要写死在代码里。到 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 创建即可。
4. 验证请求与成功结果:四种 FetchSize 的内存与耗时对比
配置就绪后,跑四组对比。每组都清空本地缓存、重启 JVM,用 JVisualVM 观察byte[]占用,同时记录callbackCount增速和总耗时。数据量固定 55.6 万行。
4.1 测试代码骨架
@SpringBootTest class FetchSizeCompareTest { @Autowired private SqlSessionFactory sqlSessionFactory; @Test void testFetchSize() { try (SqlSession session = sqlSessionFactory.openSession()) { ChatLogMapper mapper = session.getMapper(ChatLogMapper.class); ChatLogHandler handler = new ChatLogHandler(new TaoTokenClient("test")); long start = System.currentTimeMillis(); mapper.streamAll(handler); long cost = System.currentTimeMillis() - start; System.out.println("回调次数=" + handler.getCallbackCount() + ", 耗时=" + cost + "ms"); } } }4.2 四组实测结果
| FetchSize | 回调行为 | byte[] 内存峰值 | 第一次全量耗时 | 第二次全量耗时 |
|---|---|---|---|---|
| Integer.MIN_VALUE | 流式,逐条回调 | 几乎看不到 byte[] 消耗 | 10.093s | 10.12s |
| 10000 | 每批 1 万条回调 | 每次波动约 10MB | 21.892s | 22.376s |
| 500 | 每批 500 条回调 | 波动更小但回调频繁 | 28.81s | 28.188s |
| 1 | 逐条往返数据库 | 内存最低 | 约 2438s(40 分钟) | 未测 |
几个关键观察:
第一,Integer.MIN_VALUE的内存表现最好,byte[]几乎看不到消耗。这不是因为它每次只取一条,而是因为 Connector/J 对Integer.MIN_VALUE有特殊处理,走的是真正的流式读取,服务端游标保持打开,客户端按需拉取,不缓存整批。
第二,Integer.MIN_VALUE的耗时只有10000的一半左右。这有点反直觉:如果把它理解成「每次取一条」,那应该比10000慢才对。实测说明它并不是FetchSize=1,而是文档里说的 stream 方式,驱动内部做了优化,减少了往返次数。
第三,FetchSize=1才是真正的灾难。每取一条就和数据库交互一次,55 万行跑了 40 分钟。我一度以为进程死了,加断点确认还活着,吃完饭回来才看到结果。这个值在生产环境基本不能用。
第四,FetchSize=500比10000慢,说明批次太小会导致往返次数增加,吞吐下降。批次大小和耗时不是线性关系,存在一个甜点区。
4.3 验证 TaoToken 通道是否通
在跑全量之前,先用一条最小请求确认 TaoToken 通道正常,避免 JDBC 跑完了才发现模型调用失败。
curl -X POST https://taotoken.net/api/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "your-model-id", "messages": [{"role": "user", "content": "ping"}] }'返回 200 且 body 里有choices字段,说明 Key 和 Base URL 都对。如果返回 401,去 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 检查 Key 是否复制完整。模型 ID 不确定的话,可以在 https://taotoken.net/api/chat/completions 对应的模型对话页面先试一条,确认可用再写进代码。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节把我在实测中真实撞到的报错和排查路径列出来,对照着看能省不少时间。
5.1 401 Unauthorized
现象:TaoToken 调用返回 401,body 里提示鉴权失败。
排查顺序:先确认Authorization头是不是Bearer加 Key,中间有空格;再确认 Key 没有多余换行(从网页复制时经常带上);最后确认环境变量TAOTOKEN_API_KEY在当前 shell 里真的生效,echo $TAOTOKEN_API_KEY看一下。如果 Key 是在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 刚创建的,确认没有误删。
5.2 local proxy failed
现象:请求发不出去,报连接失败或代理错误。
这个报错通常和本机网络环境有关。检查HTTP_PROXY/HTTPS_PROXY环境变量是否指向了一个不可用的地址,临时unset掉再试。另外确认 Base URL 写的是https://taotoken.net/api,不要多加路径或斜杠。如果公司网络有出口限制,确认taotoken.net在允许列表里。
5.3 reading choices 相关报错
现象:解析响应时抛异常,提示读取choices字段失败。
这通常是响应体不是预期的 JSON 结构。先打印原始响应体看看到底返回了什么。常见原因是模型 ID 写错,服务端返回了错误结构而不是正常的choices数组。把模型 ID 换成在模型对话页面验证过的值再试。另外确认Content-Type是application/json,body 是合法 JSON。
5.4 OAuth 相关报错
现象:某些工具链(比如 Claude Code 类客户端)报 OAuth 失败。
这类客户端如果走的是 OAuth 流程,需要确认配置的是 API Key 模式而不是 OAuth 模式。在接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 里有对应的配置说明。如果是 Cline MCP 或 Codex 这类工具,配置项要写全三件套:Base URL、API Key、Model ID,缺一个都会报鉴权或模型找不到。
5.5 FetchSize 相关的隐性坑
除了网络层报错,JDBC 层还有两个容易忽略的点:
一是resultSetType没设成FORWARD_ONLY,导致useCursorFetch静默失效,又退回全量加载。表现是内存峰值和没开游标时一样。
二是连接池复用。如果连接池里的连接是之前用其他参数建立的,新参数可能不生效。测试时建议重启应用或清空连接池。
6. 语义一致 CTA:把 FetchSize 调对,再谈模型调用吞吐
回到最初的问题:useCursorFetch=true打开后,FetchSize到底怎么影响 ResultHandler 行为?
实测结论很清晰。Integer.MIN_VALUE走真正的流式,内存几乎不涨,耗时还最短;10000是批量回调,内存每次波动约 10MB,耗时翻倍;500更慢;1基本不可用。ResultHandler 的回调时机完全由FetchSize决定:流式模式下逐条回调,批量模式下按批回调。
对于 AI 数据清洗这类「读库 → 调模型 → 写库」的长链路任务,我的建议是:JDBC 层用useCursorFetch=true加FetchSize=Integer.MIN_VALUE,把内存压到最低;模型调用层用 TaoToken 统一通道,Base URL 固定https://taotoken.net/api,Key 从环境变量注入,模型 ID 参数化。这样 JDBC 层的吞吐节奏和模型调用层的批次节奏可以解耦,互不拖累。
如果你还在用FetchSize=1或者没开useCursorFetch,先按第 3 节的配置改一遍,再用第 4 节的对比方法验证一次。内存和耗时这两个指标会直接告诉你配对了没有。Key 和接入细节在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 和 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 都能查到,长期跑 Agent 任务的话可以看 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 的额度方案。