1. 为什么大结果集导出总在半夜 OOM:MyBatis 流式查询到底解决什么问题
先说结论:MyBatis 流式查询(Streaming Query)指的是查询成功后不返回List,而是返回一个可迭代的Cursor,应用每次从迭代器取一条结果。它适合谁?适合做大数据量导出、批处理、对账、数据迁移这类"结果集可能几十万到上千万行"的场景。核心检索词就是 MyBatis 流式查询,它能做什么?把原本一次性塞进 JVM 堆内存的整张结果表,改成边读边处理,内存占用从"随数据量线性增长"变成"基本恒定"。
我见过太多导出接口是这么写的:List<Order> list = orderMapper.selectAll(condition);然后for循环写 Excel。本地测试 1 万条没问题,上线后运营点了个"导出全部",200 万行直接把堆撑爆,日志里躺着java.lang.OutOfMemoryError: Java heap space。你可能会想,那我分页不就行了?分页当然可以,但分页查询效率高度依赖表设计和索引,limit 1000000, 20这种深分页在 MySQL 上会越翻越慢,因为数据库仍要扫描并丢弃前面 100 万行。流式查询绕开了这个问题:它底层通常配合 JDBC 的fetchSize(MySQL 需要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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- API 基址:https://taotoken.net/api (这个不加 UTM,直接作为 Base URL 用)
- 模型对话页:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
- Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
- 控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
- API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
操作路径很直接:进 API Keys 页面创建一个 Key,复制出来,然后把它放进环境变量而不是硬编码进代码。为什么强调环境变量?因为流式批处理任务经常跑在容器或定时任务里,硬编码的 Key 一旦提交进 Git,轮换成本极高。你可以这样设置:
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://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/api,Key 用刚创建的,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_VALUE,MySQL 流式关键 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 返回 Cursor
Mapper 接口把返回值声明为Cursor<T>,MyBatis 就知道这是流式查询:
@Mapper public interface OrderMapper { @Select("select id, order_no, amount, created_at from t_order where status = #{status}") Cursor<Order> streamByStatus(@Param("status") int status); }Service 层必须保证连接在消费期间是打开的。最稳的是用SqlSessionFactory手工开 session,或者用@Transactional。这里给SqlSessionFactory版本,因为它不依赖 Spring 事务传播,行为最可控:
@Service public class OrderExportService { @Autowired private SqlSessionFactory sqlSessionFactory; public void export(int status, Consumer<Order> consumer) { try (SqlSession session = sqlSessionFactory.openSession()) { Cursor<Order> 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, ResultHandler<Order> handler); }调用时传入自定义 handler:
sqlSession.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 的调用封装成一个 Bean,Base 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) { Map<String, 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 Map<String, Object> stream(@RequestParam int status) { AtomicLong count = new AtomicLong(); long start = System.currentTimeMillis(); exportService.export(status, order -> count.incrementAndGet()); Map<String, 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 | 约 320MB | 41s | 深分页越翻越慢 |
| Cursor 流式 | 约 90MB | 18s | 内存平稳,耗时更短 |
| ResultHandler | 约 85MB | 17s | 与 Cursor 接近 |
内存峰值用jconsole或VisualVM观察,也可以加-Xmx512m强制小堆来放大差异。流式的内存曲线是一条平线,分页是锯齿状,一次性加载是直冲云霄然后崩。耗时上流式反而更快,因为省掉了深分页的重复扫描。
验证模型调用是否通,可以去模型对话页发一条测试消息,确认 Key 和 Base URL 没问题:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。如果那边能通,代码里还报错,基本就是配置字段名或路径写错了。
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_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。控制台里能看到调用量和 Key 状态,方便排查异常:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。
最后给一个实用技巧:流式查询的消费逻辑一定要做幂等和断点记录。因为长事务中途失败时,你已经处理了一部分数据,重跑不能重复写。可以在消费时记录getCurrentIndex(),失败后从断点续跑。另外,fetchSize设成Integer.MIN_VALUE后,MySQL 连接会一直保持到消费结束,记得给连接池的maxLifetime留足余量,别让连接在消费中途被池子回收,否则又会看到 Cursor closed。把这些细节处理好,流式查询才能真正在生产环境稳定扛住大结果集。