
1. 为什么大结果集导出总在半夜 OOMMyBatis 流式查询到底解决什么问题先说结论MyBatis 流式查询Streaming Query指的是查询成功后不返回List而是返回一个可迭代的Cursor应用每次从迭代器取一条结果。它适合谁适合做大数据量导出、批处理、对账、数据迁移这类结果集可能几十万到上千万行的场景。核心检索词就是 MyBatis 流式查询它能做什么把原本一次性塞进 JVM 堆内存的整张结果表改成边读边处理内存占用从随数据量线性增长变成基本恒定。我见过太多导出接口是这么写的ListOrder list orderMapper.selectAll(condition);然后for循环写 Excel。本地测试 1 万条没问题上线后运营点了个导出全部200 万行直接把堆撑爆日志里躺着java.lang.OutOfMemoryError: Java heap space。你可能会想那我分页不就行了分页当然可以但分页查询效率高度依赖表设计和索引limit 1000000, 20这种深分页在 MySQL 上会越翻越慢因为数据库仍要扫描并丢弃前面 100 万行。流式查询绕开了这个问题它底层通常配合 JDBC 的fetchSizeMySQL 需要Integer.MIN_VALUE才真正逐行拉取服务端游标保持打开客户端一条条消费。代价也要讲清楚流式查询过程中数据库连接是保持打开的框架不再替你自动关闭连接取完数据后必须由应用自己关闭。这意味着长事务、连接占用时间变长如果消费逻辑里再调用别的慢接口连接池很容易被拖垮。所以流式查询不是银弹它是用连接时长换内存峰值的权衡。理解了这个前提后面的配置和排错才有意义。这篇我会从ResultHandler和Cursor两种方式切入给出可复制的 MyBatis 配置片段、Mapper 写法、分页对比验证步骤并演示怎么用 TaoToken 统一 Key/API 通道管理调用凭证最后用压测数据说明内存与耗时的收益。全程按能跟着做来写不堆概念。2. TaoToken 前置准备统一 Key 与 API 通道管好调用凭证在讲配置之前先把凭证管理这件事说清楚因为流式查询的批处理任务往往会调用外部模型做数据清洗、字段补全或摘要凭证散落在各个application.yml里改一次要翻好几个仓库。TaoToken 在这里的角色是统一 Key/API 通道你在一处管理调用凭证业务代码通过统一的 Base URL 和 Key 去访问不用在每个服务里各配一套。先明确几个地址后面配置会用到官网入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 基址https://taotoken.net/api 这个不加 UTM直接作为 Base URL 用模型对话页https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewriteCoding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite操作路径很直接进 API Keys 页面创建一个 Key复制出来然后把它放进环境变量而不是硬编码进代码。为什么强调环境变量因为流式批处理任务经常跑在容器或定时任务里硬编码的 Key 一旦提交进 Git轮换成本极高。你可以这样设置export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api然后在 Spring Boot 的application.yml里引用taotoken: base-url: ${TAOTOKEN_BASE_URL} api-key: ${TAOTOKEN_API_KEY} model: gpt-4o-mini这里有个关键点Base URL、Key、Model ID 三件套要成套出现缺一个都会在调用时报错。如果你用的是 Claude Code 这类编码工具配置思路一样把 Base URL 指向https://taotoken.net/apiKey 用刚创建的Model ID 按文档里列出的填。文档页有完整的参数说明遇到不确定的字段名先去那里核对别靠猜。我试过把 Key 放在配置中心统一下发好处是流式任务扩容时新实例自动拿到凭证不用改镜像。踩过的坑是配置中心里 Key 带了首尾空格导致请求一直 401排查了半天。所以复制 Key 后建议trim一下再用。3. 可复制配置Cursor 与 ResultHandler 两种流式写法这一节是核心直接给能跑的配置。先看 MyBatis 的全局配置关键是fetchSize和resultSetType。MySQL 下要让流式真正生效fetchSize必须设为Integer.MIN_VALUE否则驱动会把结果集全部读进内存流式就白做了。mybatis: configuration: default-fetch-size: -2147483648 # Integer.MIN_VALUEMySQL 流式关键 default-statement-timeout: 3600 map-underscore-to-camel-case: true如果你用 MyBatis-Plus配置项在mybatis-plus.configuration下字段名一致。注意default-fetch-size设成-2147483648只对 MySQL 有意义PostgreSQL 用默认的fetchSize比如 1000即可别照搬。3.1 Cursor 方式Mapper 返回 CursorMapper 接口把返回值声明为CursorTMyBatis 就知道这是流式查询Mapper public interface OrderMapper { Select(select id, order_no, amount, created_at from t_order where status #{status}) CursorOrder streamByStatus(Param(status) int status); }Service 层必须保证连接在消费期间是打开的。最稳的是用SqlSessionFactory手工开 session或者用Transactional。这里给SqlSessionFactory版本因为它不依赖 Spring 事务传播行为最可控Service public class OrderExportService { Autowired private SqlSessionFactory sqlSessionFactory; public void export(int status, ConsumerOrder consumer) { try (SqlSession session sqlSessionFactory.openSession()) { CursorOrder cursor session.getMapper(OrderMapper.class).streamByStatus(status); cursor.forEach(consumer); } } }try-with-resources保证Cursor和SqlSession都会关闭。如果你用Transactional记住一个坑注解只在外部调用时生效同类内部方法自调用不会开启事务Cursor 会报A Cursor is already closed。3.2 ResultHandler 方式逐条回调ResultHandler是另一种流式思路Mapper 方法返回void通过回调处理每一行Mapper public interface OrderMapper { Select(select id, order_no, amount, created_at from t_order where status #{status}) Options(fetchSize Integer.MIN_VALUE, resultSetType ResultSetType.FORWARD_ONLY) void streamWithHandler(Param(status) int status, ResultHandlerOrder handler); }调用时传入自定义 handlersqlSession.getMapper(OrderMapper.class).streamWithHandler(status, ctx - { Order order ctx.getResultObject(); // 逐条写文件 / 调用 TaoToken 做字段补全 writer.write(order); });两种方式怎么选Cursor更符合 Java 迭代器习惯能配合Stream做中间操作但必须自己管连接生命周期ResultHandler由 MyBatis 在查询过程中回调连接管理交给框架适合边查边写的纯消费场景。我一般导出用Cursor对账用ResultHandler。3.3 在流式消费里调用 TaoToken批处理经常要给每条记录补一个模型生成的标签。把 TaoToken 的调用封装成一个 BeanBase URL、Key、Model ID 三件套从配置读Component public class TaotokenClient { Value(${taotoken.base-url}) private String baseUrl; Value(${taotoken.api-key}) private String apiKey; Value(${taotoken.model}) private String model; private final RestClient restClient RestClient.create(); public String tag(String text) { MapString, Object body Map.of( model, model, messages, List.of(Map.of(role, user, content, 给这条订单打标签 text)) ); return restClient.post() .uri(baseUrl /v1/chat/completions) .header(Authorization, Bearer apiKey) .body(body) .retrieve() .body(String.class); } }注意流式消费里调用外部接口要控制并发别在forEach里同步阻塞调用几十万次否则连接占用时间会被拉得很长。可以攒批比如每 100 条调一次或者用有界队列异步处理。4. 验证请求与成功结果分页对比 内存耗时实测配置写完必须验证不然你不知道流式到底有没有生效。验证分两步先确认 Cursor 能正常迭代再做分页对比压测。第一步写个最小验证接口打印消费条数和当前索引GetMapping(/export/stream) public MapString, Object stream(RequestParam int status) { AtomicLong count new AtomicLong(); long start System.currentTimeMillis(); exportService.export(status, order - count.incrementAndGet()); MapString, Object result new HashMap(); result.put(rows, count.get()); result.put(costMs, System.currentTimeMillis() - start); return result; }成功结果长这样{rows: 2000000, costMs: 18432}说明 200 万行在 18 秒左右消费完且没有 OOM。如果报A Cursor is already closed回到第 3 节检查连接是否保持打开。第二步分页对比。用同样的数据量分别跑流式和limit offset分页记录内存峰值和耗时。下面是我在一台 4C8G、MySQL 8.0、200 万行测试表上的实测数据仅作参考你的环境会有差异方式内存峰值总耗时备注一次性 List 加载OOM 崩溃未完成堆 2G 直接爆分页 limit 1000约 320MB41s深分页越翻越慢Cursor 流式约 90MB18s内存平稳耗时更短ResultHandler约 85MB17s与 Cursor 接近内存峰值用jconsole或VisualVM观察也可以加-Xmx512m强制小堆来放大差异。流式的内存曲线是一条平线分页是锯齿状一次性加载是直冲云霄然后崩。耗时上流式反而更快因为省掉了深分页的重复扫描。验证模型调用是否通可以去模型对话页发一条测试消息确认 Key 和 Base URL 没问题https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。如果那边能通代码里还报错基本就是配置字段名或路径写错了。5. 本篇常见错排查401、Cursor closed、OAuth 报错逐个拆流式查询 外部调用的组合报错集中在几个地方我按真实日志对照着说。报错一401 Unauthorized或invalid api key。这是 TaoToken 调用凭证问题。先确认环境变量有没有生效echo $TAOTOKEN_API_KEY。常见原因是 Key 复制时带了空格、或者用了已删除的 Key。去 API Keys 页面重新生成一个替换后重启服务。注意 Base URL 要写https://taotoken.net/api别多加/v1之外的路径路径拼接错误也会返回 401 或 404。报错二java.lang.IllegalStateException: A Cursor is already closed。这是流式查询最经典的错。根因是 Mapper 方法执行完连接就关了Cursor 跟着失效。解决就是第 3 节说的三种方案SqlSessionFactory手工开 session、TransactionTemplate、或Transactional。用Transactional时务必确认是外部调用同类内部自调用不生效。报错三local proxy failed或连接超时。这类通常是网络出口或代理配置问题。检查你的 HTTP 客户端有没有被系统代理拦截容器环境里确认 DNS 能解析taotoken.net。如果是公司内网确认出口白名单包含该域名。别在代码里硬编码代理地址用环境变量统一管理。报错四Cannot read the array length because choices is null或reading choices相关空指针。这是解析模型响应时choices字段为空。原因可能是请求体格式不对比如messages写成了字符串、或者模型 ID 填错导致返回了错误结构。先打印原始响应体看结构再对照接入文档核对字段。Model ID 一定要用文档里列出的别自己拼。报错五OAuth 相关报错比如OAuth token exchange failed。如果你用的是 Claude Code 或 Codex 这类工具配置里同时存在 OAuth 和 API Key 两套凭证时会冲突。用 API Key 模式时把 OAuth 相关配置清掉Base URL、Key、Model ID 三件套保持一致。Codex 的auth.json里如果残留旧凭证也会导致鉴权失败清空后重新写入。排查顺序建议先看 HTTP 状态码401/403 是凭证404 是路径5xx 是服务端再看异常栈第一行Cursor closed 是连接生命周期最后看响应体结构choices 为空是解析问题。按这个顺序走大部分问题五分钟内能定位。6. 把凭证和流式链路收口长期批处理任务怎么管流式查询跑通之后真正要长期维护的是凭证和任务调度。批处理任务往往按天跑Key 会轮换模型会升级如果每个任务里都散落着 Base URL 和 Key维护成本会越来越高。我的做法是把 TaoToken 的调用统一收口到一个客户端 Bean所有批处理任务注入它Key 和 Base URL 只在一处配置。这样轮换 Key 时只改环境变量不用动业务代码。对于需要长期跑编码或 Agent 类任务的场景可以了解下 Coding Plan它更适合持续性的调用需求https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。控制台里能看到调用量和 Key 状态方便排查异常https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。最后给一个实用技巧流式查询的消费逻辑一定要做幂等和断点记录。因为长事务中途失败时你已经处理了一部分数据重跑不能重复写。可以在消费时记录getCurrentIndex()失败后从断点续跑。另外fetchSize设成Integer.MIN_VALUE后MySQL 连接会一直保持到消费结束记得给连接池的maxLifetime留足余量别让连接在消费中途被池子回收否则又会看到 Cursor closed。把这些细节处理好流式查询才能真正在生产环境稳定扛住大结果集。