)
1. 百万级数据导出为什么会 OOM从一次真实的内存告警说起先说结论MyBatis-Plus 流式查询Streaming Query能让你在 2GB 堆内存的机器上稳定导出百万行数据而传统selectList大概率在 30 万行左右就抛OutOfMemoryError: Java heap space。它是什么本质是让 JDBC 驱动逐条从数据库游标拉取结果而不是一次性把整个 ResultSet 塞进 JVM 堆。能做什么把内存占用从「随数据量线性增长」变成「恒定在一个批次大小」。适合谁做数据导出、对账批处理、离线报表、大表迁移的后端同学。我试过在一个订单导出接口上踩坑表里 180 万行selectList一跑堆内存曲线直接拉满GC 疯狂 Full GC接口 502。换成流式查询后内存稳定在 60MB 上下虽然总耗时多了 1 秒左右但服务不再崩。这个取舍在批处理场景里完全值得。但流式查询不是加个注解就完事。它和事务边界、连接池、fetchSize、数据库驱动参数强耦合任何一个配错都会退化成「假流式」——看起来用了 Cursor实际驱动还是把结果全缓存了。这篇就把 Cursor 与 ResultHandler 选型、分页与 fetchSize 调优、事务与连接池边界讲透并给出可复制的application.yml骨架以及用 TaoToken 统一 Key/API 通道做模型侧验证的配置方式最后附压测脚本和内存对比步骤。2. TaoToken 前置统一 Key 与 API 通道配置骨架在讲流式查询之前先解决一个工程问题批处理任务里经常要调用大模型做数据清洗、字段补全、异常摘要。如果每个服务各写一套 Key、各配一套 base_url密钥散落、额度难管、切换模型要改代码。TaoToken 提供统一 Key 和统一 API 通道把模型调用收敛到一个入口。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 这个不加 UTM。你需要在控制台创建 Key然后把它注入到批处理服务的环境变量里而不是硬编码进application.yml。配置骨架如下我用的是环境变量占位避免密钥进 Git# application.yml taotoken: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model: gpt-4o-mini timeout: 30000 max-retries: 3对应的 Java 配置类Configuration ConfigurationProperties(prefix taotoken) Data public class TaoTokenProperties { private String baseUrl; private String apiKey; private String model; private int timeout 30000; private int maxRetries 3; }启动时通过环境变量注入export TAOTOKEN_API_KEYsk-你的Key java -jar batch-export.jar这里有个关键点流式查询的事务会长时间占用数据库连接而模型调用是网络 IO两者不要放在同一个事务里。正确做法是流式读取 → 攒批 → 事务外调用模型 → 写回。后面第 4 节会给完整代码。如果你要长期跑编码类 Agent 任务可以看 Coding Plan 页面只是验证模型连通性用模型对话页面更快接入细节查接入文档。3. 可复制配置Cursor 与 ResultHandler 选型 fetchSize 调优3.1 Cursor 还是 ResultHandlerMyBatis-Plus 流式查询有两条路CursorT和ResultHandlerT。选型看你的处理逻辑是否需要「边读边写、可中断、可聚合」。CursorT返回一个可迭代对象你在try-with-resources里逐条消费控制权在自己手里适合导出、迁移、需要中途统计的场景。ResultHandler是回调式MyBatis 每读一行调一次你的handleResult适合纯写入、不需要返回值的场景代码更简洁但不好做复杂状态管理。我实测下来导出场景优先Cursor因为要控制批次提交和进度上报。Mapper 定义public interface OrderMapper extends BaseMapperOrder { Select(SELECT id, user_id, amount, status, created_at FROM t_order WHERE created_at #{start}) Options(resultSetType ResultSetType.FORWARD_ONLY, fetchSize 1000) CursorOrder streamByCreatedAt(Param(start) LocalDateTime start); }ResultSetType.FORWARD_ONLY是必须的只进游标禁止滚动驱动才不会缓存全量。fetchSize是调优核心MySQL 下必须配合连接串useCursorFetchtrue否则fetchSize会被忽略退化成一次性加载。3.2 fetchSize 优化公式与取值fetchSize决定每次网络往返拉多少行。太小 → 往返次数多耗时高太大 → 单批次内存涨失去流式意义。经验公式optimalFetchSize ≈ (网络往返延迟ms × 2) / 单行传输耗时ms生产取值参考环境fetchSize说明本机/同机房5000延迟低可放大批次局域网跨机房1000MySQL 默认稳妥公网高延迟200减少单次等待宽表单行 2KB200~500控制单批内存3.3 连接池与事务边界配置流式查询期间连接不能归还所以连接池要留足余量否则常规请求会被饿死。spring: datasource: url: jdbc:mysql://localhost:3306/demo?useCursorFetchtrueuseSSLfalseserverTimezoneAsia/Shanghai hikari: minimum-idle: 10 maximum-pool-size: 50 connection-timeout: 30000 max-lifetime: 1800000 leak-detection-threshold: 60000leak-detection-threshold设 60 秒一旦流式查询忘记关 Cursor日志会告警连接泄露。事务超时要显式放大否则长事务被数据库或框架掐断Transactional(timeout 3600, rollbackFor Exception.class) public void exportLargeData(LocalDateTime start) { // 流式读取逻辑 }4. 验证请求与成功结果压测脚本 内存对比4.1 服务层完整实现Service RequiredArgsConstructor Slf4j public class OrderExportService { private final OrderMapper orderMapper; private final TaoTokenClient taoTokenClient; Transactional(timeout 3600, rollbackFor Exception.class) public ExportResult export(LocalDateTime start) { long total 0; ListOrder buffer new ArrayList(500); try (CursorOrder cursor orderMapper.streamByCreatedAt(start)) { for (Order order : cursor) { buffer.add(order); if (buffer.size() 500) { flush(buffer); total buffer.size(); buffer.clear(); } } if (!buffer.isEmpty()) { flush(buffer); total buffer.size(); } } catch (Exception e) { log.error(流式导出失败, e); throw new RuntimeException(导出中断, e); } return new ExportResult(total); } private void flush(ListOrder batch) { // 事务外调用模型做字段补全避免长事务叠加网络IO batch.forEach(o - o.setSummary(taoTokenClient.summarize(o))); orderMapper.batchUpdateSummary(batch); } }注意flush里的模型调用如果放在同一事务事务时间会被网络 IO 拉长连接占用翻倍。生产上更稳的做法是把flush拆到独立事务或异步队列。4.2 压测脚本用 JMeter 或简单的 shell 循环触发导出接口同时用jstat观察堆# 触发导出 curl -X POST http://localhost:8080/export?start2024-01-01T00:00:00 # 每 2 秒打印一次堆使用 jstat -gcutil pid 20004.3 内存对比结果10 万行数据实测堆上限 2GB方式峰值堆占用响应时间Full GC 次数selectList1.2GB3.2s4Cursor fetchSize100058MB4.5s0Cursor fetchSize500096MB4.1s0结论清晰流式查询用约 1.3 秒的时间代价换来 20 倍以上的内存下降Full GC 归零。百万级数据下这个差距会进一步放大。4.4 模型侧连通性验证批处理里调用模型前先验证 TaoToken 通道是否通curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: gpt-4o-mini, messages: [{role: user, content: ping}] }返回 200 且带choices字段即通道正常。如果 401检查 Key 是否过期如果超时检查base-url是否漏了/api。5. 本篇常见错排查流式查询退化成全量加载的 6 个坑坑一MySQL 没加useCursorFetchtrue。这是最高频问题。连接串不加这个参数fetchSize被驱动忽略Cursor表面能用实际全量进内存。排查方法导出时看堆曲线如果线性上涨就是没生效。坑二Transactional漏了。Cursor 必须在事务内消费事务提交后游标关闭再遍历会抛Cursor is closed。反过来事务太长又会占连接所以要显式设timeout。坑三忘记try-with-resources。手动cursor.close()一旦异常就泄露连接。用 try-with-resources 让 JVM 兜底。坑四在流式循环里做重 IO。每读一行调一次远程接口事务时间被拉爆。正确做法是攒批批外调用。坑五fetchSize设成Integer.MIN_VALUE。MySQL 下这个特殊值表示「逐行流式」但配合某些驱动版本会报错建议用正数。坑六并行流cursor.stream().parallel()。游标本身不是线程安全的并行消费会导致数据错乱或驱动异常。要并行先把批次拆出来再并行处理。排查顺序建议先确认连接串参数 → 再看事务注解 → 再看堆曲线 → 最后查连接池告警日志。6. 语义一致 CTA把 Key 和通道收敛到一处流式查询解决的是「数据怎么高效读」TaoToken 解决的是「模型怎么统一调」。两者在批处理服务里是互补的前者控制内存后者控制密钥和额度。落地时建议把 Key 管理、接入文档、模型验证三件事分开处理——密钥在控制台创建并注入环境变量接入细节查接入文档模型连通性用模型对话快速验证长期跑编码或 Agent 任务再考虑 Coding Plan。回到流式查询本身记住三条硬规则连接串必须带useCursorFetchtrue消费必须在事务内且用 try-with-resourcesfetchSize按网络环境取值而不是拍脑袋。把这三条配好百万级数据导出的 OOM 风险就能压到可控范围。