1. 百万级数据导出把内存打爆:MyBatis Cursor 流式查询到底解决什么问题
做数据导出或者批量对账的时候,很多人第一反应是selectList一把梭,结果本地跑得好好的,一上生产就 OOM。原因不复杂:MyBatis 默认会把整个ResultSet映射成List<T>塞进堆内存,1000 万行数据哪怕每行只有几百字节,累计起来也是几个 G 的占用,JVM 还没开始处理业务逻辑就已经被压垮了。
MyBatis Cursor 流式查询就是冲着这个场景来的。它返回的不是一个集合,而是一个迭代器Cursor<T>,应用每次从迭代器取一条结果,数据库连接保持打开,结果集按需拉取。这样内存里始终只有当前处理的那一条(或一小批)数据,堆占用从「全量」降到「常数级」。适合谁用?做大数据量导出、逐行清洗、批量写文件、对账核销这类「读多写少、单条处理」的后端同学。不适合谁?需要随机访问、需要多次遍历同一结果集的场景,因为 Cursor 是一次性的,取完就没了。
我试过在一个对账任务里把selectList换成Cursor,同样的 800 万行数据,原来堆峰值 3.2G 直接 OOM,换完之后稳定在 200M 以内,任务从「跑不完」变成「跑得稳」。但这里有个前提:流式查询期间数据库连接是占着的,事务边界没处理好,连接池分分钟被耗尽。所以这篇不只是讲怎么配 Cursor,还要把事务、fetchSize、连接释放这几件事串起来讲清楚,最后再演示怎么把数据库连接和模型调用统一到 TaoToken 的 Key/API 通道,让整个链路只有一个凭证要管。
核心检索词先摆出来:MyBatis Cursor 流式查询是一种让查询结果以迭代器方式逐条返回的机制,能显著降低大数据量场景下的内存占用,适合逐行处理与批量导出。下面从配置到验证一步步来。
2. 接入前的准备:TaoToken 统一 Key 与 MyBatis 环境怎么摆
在写 Cursor 配置之前,先把「凭证」这件事理顺。很多团队的问题是:数据库连接一套账号密码,模型调用又是另一套 Key,散落在各个配置文件里,换环境就得改一堆地方。TaoToken 的思路是把模型调用统一到一个 API 通道上,Base URL 固定为https://taotoken.net/api,你只需要在控制台生成一个 Key,所有走 OpenAI 兼容协议的地方都用这一个 Key。
先做前置准备。第一步,去 TaoToken 控制台创建 API Key,地址是https://taotoken.net/api-keys,登录后在密钥管理页新建一个,复制出来形如sk-xxxxxxxx的字符串,先存到环境变量里,别硬编码进代码。第二步,确认你的项目里 MyBatis 版本不低于 3.4.0,因为 Cursor 是 3.4.0 才引入的。用 Maven 的话看一眼pom.xml:
<dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency>第三步,把 TaoToken 的接入信息写进配置。如果你用的是 Spring Boot,可以在application.yml里加一段模型调用的配置,Base URL 指向https://taotoken.net/api,Key 从环境变量读:
taotoken: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model: gpt-4o-mini这里要强调一点:TaoToken 是模型调用的统一通道,不是数据库连接池,别把 JDBC 的 URL 和它混在一起。数据库连接还是走你自己的 MySQL/PostgreSQL 配置,TaoToken 负责的是「处理完数据之后要调模型做摘要/分类/清洗」这一步。两者通过同一个 Key 体系管理,运维上少一份凭证要轮换。
如果你用的是 Claude Code 或者 Cline 这类编码工具,想让它们也走同一个通道,可以在工具里配置 Base URL 为https://taotoken.net/api,Key 填刚才生成的那个。Cline 的 MCP 配置里需要写全三件套:Base URL、API Key、Model ID,缺一个都连不上。Model ID 按你实际用的模型填,比如gpt-4o-mini或者claude-3-5-sonnet,具体以控制台模型列表为准。
环境摆好之后,先别急着写 Cursor,用一条最简单的请求验证 Key 是通的。可以用 curl 打一下模型对话接口:
curl 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"}] }'返回里有choices字段就说明通道没问题。这一步过了,再往下做 Cursor 的数据库部分,出问题的时候才能分清是数据库的事还是模型通道的事。
3. 可复制的 Cursor 配置:XML、注解、fetchSize 与事务边界
这一节是核心,把 Cursor 的三种写法都过一遍,再讲 fetchSize 和事务这两个最容易踩坑的点。
先说 XML 写法。Mapper 接口里方法返回值声明成Cursor<T>:
public interface AppUserMapper { Cursor<AppUser> streamAllUsers(); }对应的 XML 里,resultType照常写,select标签不需要特殊属性:
<select id="streamAllUsers" resultType="com.example.entity.AppUser"> SELECT id, name, phone, created_at FROM app_user WHERE status = 1 ORDER BY id </select>注意这里没有fetchSize属性,MyBatis 的 fetchSize 是在Statement层面设的,XML 里可以通过fetchSize属性传,但更推荐在全局配置或方法参数里控制。注解写法更直接:
@Select("SELECT id, name, phone, created_at FROM app_user WHERE status = 1 ORDER BY id") @Options(fetchSize = 1000, resultSetType = ResultSetType.FORWARD_ONLY) Cursor<AppUser> streamAllUsersByAnnotation();fetchSize是关键参数。它告诉 JDBC 驱动每次从数据库拉多少行到客户端。MySQL 的默认行为是把整个结果集拉到内存,所以必须配合fetchSize = Integer.MIN_VALUE才能触发真正的流式读取,或者用useCursorFetch=true加正数 fetchSize。PostgreSQL 则要求连接处于事务中且autoCommit=false,fetchSize 才生效。这块差异很大,下面用表格对照:
| 数据库 | 触发流式的条件 | 推荐 fetchSize | 注意事项 |
|---|---|---|---|
| MySQL | useCursorFetch=true且 fetchSize>0,或 fetchSize=Integer.MIN_VALUE | 1000 或 MIN_VALUE | MIN_VALUE 模式不支持游标前后移动 |
| PostgreSQL | autoCommit=false 且 fetchSize>0 | 1000 | 必须在事务内,否则驱动忽略 fetchSize |
| Oracle | fetchSize>0 即可 | 1000 | 默认已支持,无需额外 URL 参数 |
事务边界是第二个大坑。流式查询期间连接必须保持打开,所以方法上要加@Transactional,而且这个注解只在外部调用时生效。如果你在同一个类里 A 方法调 B 方法,B 上有@Transactional,Spring 的代理不会介入,事务不生效,Cursor 取到一半连接可能就被回收了。正确做法是把流式查询方法放到独立的 Service 里,由外部注入调用。
@Service public class UserExportService { @Autowired private AppUserMapper appUserMapper; @Transactional(readOnly = true) public void exportToFile(Writer writer) throws IOException { try (Cursor<AppUser> cursor = appUserMapper.streamAllUsers()) { Iterator<AppUser> it = cursor.iterator(); while (it.hasNext()) { AppUser user = it.next(); writer.write(user.toCsvLine()); writer.write("\n"); } } } }try-with-resources保证 Cursor 关闭,@Transactional保证连接在迭代期间不被释放。readOnly = true是优化项,告诉数据库这是只读事务,某些驱动会走更快的路径。如果你处理完数据还要调模型,比如把每行数据丢给模型做分类,那模型调用要放在事务外面,别让 HTTP 请求占着数据库连接。可以先把数据攒成小批,事务提交后再批量调 TaoToken 的接口。
4. 验证请求与成功结果:从 Cursor 取数到模型调用跑通
配置写完,得验证两件事:Cursor 是不是真的流式取数,模型通道是不是真的通。先验证 Cursor。写一个测试方法,打印每次取数的耗时和内存占用:
@Test @Transactional(readOnly = true) public void testCursorStream() { long start = System.currentTimeMillis(); AtomicInteger count = new AtomicInteger(); try (Cursor<AppUser> cursor = appUserMapper.streamAllUsers()) { cursor.forEach(user -> { count.incrementAndGet(); if (count.get() % 100000 == 0) { System.out.println("已处理 " + count.get() + " 行,耗时 " + (System.currentTimeMillis() - start) + "ms"); } }); } System.out.println("总计 " + count.get() + " 行"); }跑起来之后,你会看到每处理 10 万行打印一次进度,内存曲线是平的,不会随行数增长。如果内存还是往上涨,八成是 fetchSize 没生效,回去检查数据库 URL 参数。MySQL 的 URL 要带上useCursorFetch=true:
spring.datasource.url=jdbc:mysql://localhost:3306/demo?useCursorFetch=true&useServerPrepStmts=trueuseServerPrepStmts=true是配合useCursorFetch用的,少了它游标可能不生效。PostgreSQL 则确认autoCommit是 false,Spring 的@Transactional会自动处理。
Cursor 验证通过后,接模型调用。假设你要把每行用户数据丢给模型做标签分类,可以在事务外批量调:
public void classifyUsers(List<AppUser> batch) { String prompt = batch.stream() .map(u -> u.getName() + ":" + u.getPhone()) .collect(Collectors.joining("\n")); // 调用 TaoToken 模型对话接口 // POST https://taotoken.net/api/v1/chat/completions // Header: Authorization: Bearer ${TAOTOKEN_API_KEY} // Body: {"model":"gpt-4o-mini","messages":[{"role":"user","content":prompt}]} }成功的结果是:Cursor 逐行取数不涨内存,攒够 500 行提交一次模型调用,返回的choices[0].message.content里有分类结果。整个链路只有一个 Key 要管,数据库连接和模型通道各司其职。如果你想先在网页上试试模型返回长什么样,可以去模型对话页面手动发一条,确认格式对了再写进代码。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth 逐个拆
流式查询加模型调用,报错集中在几个地方,逐个说。
401 Unauthorized。这个基本是 Key 的问题。先确认环境变量TAOTOKEN_API_KEY真的被读到了,echo $TAOTOKEN_API_KEY看一眼。如果 Key 是对的还报 401,检查请求头是不是写成了Authorization: Bearer sk-xxx,少个空格或者多个换行都会挂。还有一种情况是 Key 被禁用或过期,去控制台https://taotoken.net/api-keys重新生成一个。
local proxy failed。这个报错通常出现在你本地配了代理,但代理没起来或者端口不对。TaoToken 的 API 地址是https://taotoken.net/api,直连即可,不需要额外代理配置。如果你在代码里或者环境变量里设了HTTP_PROXY、HTTPS_PROXY,先清掉再试。Cline 或 Claude Code 里如果配了自定义代理,也会报这个,检查工具的网络设置。
reading choices 相关报错。比如Cannot read properties of undefined (reading 'choices'),意思是返回体里没有choices字段,通常是请求根本没成功,返回的是错误 JSON。打印完整响应体看一眼,常见原因是 model ID 写错了,或者请求体格式不对。模型 ID 要以控制台列表为准,别自己猜。请求体里messages必须是数组,role和content都不能少。
OAuth 相关报错。如果你用的是 Claude Code 这类工具,它可能默认走 OAuth 登录而不是 API Key。要切到 API Key 模式,在工具配置里把认证方式改成 Key,Base URL 填https://taotoken.net/api,然后填上你的 Key。Codex 的auth.json里也要对应改,确保api_key字段有值,base_url指向正确地址。三件套 Base URL、Key、Model ID 缺一不可,少一个就连不上。
Cursor 取数取到一半断了。这个多半是事务边界问题。检查流式查询方法是不是被同类内部调用,@Transactional没生效。或者连接池的maxLifetime比查询时间短,连接被池子回收了。把maxLifetime调大,或者确保查询在独立事务里跑完。
fetchSize 不生效,内存还是涨。MySQL 确认 URL 带useCursorFetch=true&useServerPrepStmts=true,PostgreSQL 确认autoCommit=false。还有一个隐蔽的点:如果你在 Cursor 上做了collect或者toList,那等于把流式又变回全量了,内存照样爆。流式查询必须用iterator逐条取,别中途收集。
6. 把数据库与模型调用统一到一个 Key:长期编码与 Agent 场景的落地建议
Cursor 流式查询解决的是「数据怎么低内存地读出来」,TaoToken 解决的是「读出来之后调模型怎么统一管凭证」。两件事合在一起,才是完整的大数据量处理链路。对于长期做数据管道、批量清洗、Agent 自动化的团队,建议把模型调用的 Base URL 固定成https://taotoken.net/api,Key 走环境变量注入,别散落在代码里。
如果你要跑长期的编码任务或者 Agent 流程,可以了解一下 Coding Plan,它适合需要持续调用模型、按量计费的场景,比每次手动管 Key 省事。接入文档在https://taotoken.net/doc,里面有各语言的示例,照着改 Base URL 和 Key 就能跑。验证模型返回格式的话,模型对话页面可以直接试,不用写代码。
最后给一个实操建议:Cursor 的try-with-resources一定要写,别指望框架帮你关连接。事务方法放独立 Service,别同类自调用。fetchSize 按数据库类型调,MySQL 用useCursorFetch,PostgreSQL 靠事务。模型调用放事务外,攒批再发。这四条做到,800 万行导出加模型分类的链路就能稳稳跑下来。