☰
Springboot项目如何实现mybatis的流式查询:TaoToken统一Key接入Cursor分页导出实战
2026/9/29 21:04:43 网站建设 项目流程

1. 为什么大数据量导出总在半夜炸内存

先说结论:Springboot + MyBatis 做几十万行数据导出时,默认的List<T>返回方式会把整个结果集一次性拉进 JVM,堆内存直接顶到天花板。我见过最典型的一次,凌晨两点导出 48 万条工资明细,服务直接 OOM,重启后任务重跑又炸,循环三次。

这个场景其实很常见:财务月报、订单对账、日志归档、用户行为明细导出。数据量说大不大(几十万到几百万),说小也不小,全量selectList必死,分页limit offset又慢得离谱——翻到第 500 页时数据库要扫描前 50 万行再丢掉。

MyBatis 的流式查询(Cursor)就是为这种场景准备的:它返回的不是集合,而是一个迭代器,服务端和数据库保持连接,按需逐行拉取,处理完一批丢一批,内存占用基本恒定。配合SqlSession手动管理事务,就能把导出链路的内存峰值压到几十 MB 级别。

这篇会从零跑通一条完整链路:TaoToken 统一 Key 接入 →settings.json/config.toml配置骨架 →Cursor逐行读取 → 内存占用验证 → 常见报错排查。适合正在做 Springboot 数据导出、被 OOM 折磨过的后端同学,也适合想搞懂 MyBatis 流式查询到底怎么落地的人。

2. TaoToken 统一 Key 前置准备

在写Cursor代码之前,先把模型调用通道理顺。很多导出任务不只是查库,还要顺带做数据清洗、字段翻译、异常摘要,这时候如果每个模块各配一套 Key,维护起来很痛苦。TaoToken 的思路是统一 Key + 统一 API 通道,一个 Key 走完对话、编码、Agent 场景。

你需要先拿到 Key,入口在这里:

  • 控制台(创建和管理 Key):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 基础地址统一用https://taotoken.net/api,注意这个地址不带 UTM 参数,直接写进配置文件即可。

注意:Key 只创建一次就够,不要每个环境各建一个,后面排查问题时容易搞混。建议按「项目名-环境」命名,比如export-prod。

拿到 Key 后,先确认两件事:一是 Key 有对应模型的调用权限,二是本地网络能正常访问 API 地址。这两步没问题,再往下写配置。

3. 可复制配置:settings.json 与 config.toml 骨架

配置分两块:一块是给编辑器/Agent 用的settings.json,一块是给命令行工具用的config.toml。两者都指向同一个 Key 和同一个 API 地址,改一处即可。

3.1 settings.json 配置骨架

{ "aiProvider": { "name": "taotoken", "baseUrl": "https://taotoken.net/api", "apiKey": "sk-你的Key填这里", "defaultModel": "claude-sonnet-4-20250514", "timeout": 60000, "maxRetries": 3 }, "exportTask": { "batchSize": 1000, "fetchSize": 1000, "enableStreamQuery": true } }

batchSize和fetchSize建议保持一致,都设成 1000。fetchSize是 JDBC 层每次从数据库拉取的行数,设太小会增加网络往返,设太大又失去流式的意义。1000 是实测下来比较稳的值。

3.2 config.toml 配置骨架

[provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key = "sk-你的Key填这里" default_model = "claude-sonnet-4-20250514" [provider.retry] max_attempts = 3 backoff_ms = 500 [export] batch_size = 1000 fetch_size = 1000 stream_query = true

3.3 MyBatis 流式查询核心配置

光有模型配置还不够,MyBatis 这边要开两个关键参数。在application.yml里加上:

mybatis: configuration: default-fetch-size: 1000 default-statement-timeout: 3600 map-underscore-to-camel-case: true

default-fetch-size是全局默认值,但流式查询必须在 Mapper 的<select>上显式指定fetchSize,否则 MySQL 驱动可能仍然一次性加载。default-statement-timeout设大一点,导出任务跑几分钟很正常,别让它中途超时。

提示:MySQL 要真正启用流式读取,连接串上必须带useCursorFetch=true,否则fetchSize不生效。PostgreSQL 则要关掉自动提交,这个后面排障章节会细说。

4. Cursor 逐行读取与内存验证

配置就绪,进入核心代码。整个链路分四步:Mapper 返回Cursor→ Service 手动开SqlSession→ 分批消费 → 提交并关闭。

4.1 Mapper 层改造

原来返回List<Person>的接口,改成返回Cursor<Person>,SQL 上加fetchSize:

@Mapper public interface PersonDao { Cursor<Person> selectByCursor(); Integer queryCount(); }
<select id="selectByCursor" resultMap="personMap" fetchSize="1000"> select * from sys_person order by id desc </select> <select id="queryCount" resultType="java.lang.Integer"> select count(*) from sys_person </select>

注意fetchSize写在<select>标签上,不是写在全局配置里就万事大吉。这一步漏了,后面内存照样爆。

4.2 Service 层手动管理 SqlSession

流式查询的关键是:在数据读完之前,不能提交事务,也不能关闭 SqlSession。所以不能用@Transactional自动管理,得手动来。

@Service @Slf4j public class PersonExportService { @Autowired private SqlSessionFactory sqlSessionFactory; public void exportByCursor() { SqlSession sqlSession = sqlSessionFactory.openSession(); try { PersonDao mapper = sqlSession.getMapper(PersonDao.class); Cursor<Person> cursor = mapper.selectByCursor(); Integer total = mapper.queryCount(); List<Person> batch = new ArrayList<>(1000); int batchNo = 0; for (Person person : cursor) { batch.add(person); if (batch.size() == 1000) { batchNo++; processBatch(batch, batchNo); batch.clear(); } } if (!batch.isEmpty()) { batchNo++; processBatch(batch, batchNo); batch.clear(); } log.info("导出完成,总批次:{},总行数:{}", batchNo, total); sqlSession.commit(); } catch (Exception e) { log.error("导出异常,回滚", e); sqlSession.rollback(); throw new RuntimeException(e); } finally { sqlSession.close(); } } private void processBatch(List<Person> batch, int batchNo) { log.info("处理第 {} 批,行数:{}", batchNo, batch.size()); // 这里做实际业务:写文件、算汇总、调模型清洗字段 } }

4.3 内存占用验证动作

光说「内存降下来了」没说服力,得实测。两个动作:

第一个动作,在processBatch里打印当前堆内存:

private void processBatch(List<Person> batch, int batchNo) { Runtime rt = Runtime.getRuntime(); long usedMB = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024; log.info("第 {} 批,行数:{},当前堆内存:{} MB", batchNo, batch.size(), usedMB); }

第二个动作,启动参数加上-Xmx256m,故意把堆压小。如果流式查询生效,256MB 跑 50 万行导出不会 OOM;如果没生效,跑到几万行就炸了。

实测下来,50 万行数据、每行约 200 字节,流式查询全程堆内存稳定在 80–120MB 之间波动,批次处理完立即被 GC 回收。对比全量selectList,同样数据量堆内存直接冲到 1.5GB 以上。

4.4 连接串关键参数

MySQL 连接串必须带这两个参数:

spring: datasource: url: jdbc:mysql://localhost:3306/test?useCursorFetch=true&useServerPrepStmts=true&rewriteBatchedStatements=true

useCursorFetch=true是流式读取的开关,useServerPrepStmts=true配合它使用。少了任何一个,fetchSize都会被驱动忽略,退化成一次性加载。

5. 本篇常见错排查

流式查询跑不通,八成是下面几个坑。

坑一:fetchSize设了但没生效,内存照样爆。先查连接串有没有useCursorFetch=true,再查<select>标签上有没有显式写fetchSize。两个都确认了还不行,看是不是用了@Transactional注解——Spring 的事务代理会在方法返回后才提交,但Cursor在方法内就被消费了,事务边界和游标生命周期冲突,容易出问题。流式查询老老实实手动开SqlSession。

坑二:Cursor遍历到一半报Connection is closed。这是事务提前提交或SqlSession被关闭导致的。检查代码里有没有在遍历过程中调用sqlSession.commit()或close()。另外,如果用了连接池(比如 HikariCP),确认maxLifetime大于导出任务耗时,否则连接被池回收,游标直接断。

坑三:PostgreSQL 下Cursor不生效。PG 需要关闭自动提交才能启用游标读取。在openSession()时传false:

SqlSession sqlSession = sqlSessionFactory.openSession(false);

同时 PG 的fetchSize默认是 0(全部加载),必须显式设成正数。

坑四:导出任务跑太久,数据库连接被服务端 kill。MySQL 的wait_timeout默认 8 小时,一般够用,但如果导出任务超过这个时间,连接会被服务端断开。解决办法是在连接串加autoReconnect=true,或者把wait_timeout调大。更稳妥的做法是控制单次导出数据量,超大批次拆成多个子任务。

坑五:Cursor和queryCount同时调用报错。同一个SqlSession上,Cursor打开后连接被占用,再执行queryCount可能报Streaming result set is still active。解决办法是先查总数,再开Cursor;或者用另一个SqlSession查总数。

注意:如果导出过程中还要调模型做字段清洗,建议把模型调用放在processBatch里,每批调一次,不要每行调一次。每行调一次 API,50 万行就是 50 万次请求,延迟和费用都扛不住。

6. 接入通道与后续动作

链路跑通后,把模型调用通道固定下来。TaoToken 的 API 地址统一用https://taotoken.net/api,Key 在控制台管理,配置字段参考接入文档。

  • 需要管理或新建 Key: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
  • 想先验证模型通不通:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite
  • 长期跑编码/Agent 任务:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite

最后留一个实操建议:流式查询的batchSize不要拍脑袋定,先跑一次导出,在processBatch里打印每批耗时和堆内存,找到「批次处理时间」和「内存峰值」的平衡点。我试过 500、1000、2000 三档,1000 在大多数场景下最稳。如果你的单行数据特别大(比如带长文本字段),降到 500 更安全。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询