你还在用分页?试试SpringBoot+MyBatis 流式查询,真心强大!TaoToken 统一 Key 通道实测
发布时间:2026/10/2 16:51:15来源:尧图网络
1. 50 万行导出把服务打挂之后我重新捡起了 MyBatis 流式查询先说结论SpringBoot MyBatis 的流式查询本质是把「一次性把结果集塞进 List」换成「拿着一个游标一条条往外取」。它适合大数据量导出、报表批处理、跨表全量扫描这类场景不适合高并发短查询。如果你正在写一个导出接口本地跑 2 万行没问题一上生产 50 万行就 OOM那这篇就是写给你的。我遇到的问题是一个工资明细导出接口分页查询每页 5000 条循环 100 次拼 Excel。看起来挺稳实际上有两个坑。第一每次分页都是一次独立的LIMIT offset, sizeoffset 越大MySQL 扫描的行数越多第 100 页的查询耗时是第 1 页的好几倍。第二虽然每页只取 5000 条但整个导出过程里已经拼好的 Excel 对象、中间汇总的 Map 全都在堆里最后内存峰值还是压不住。分页查询和流式查询的核心差异我用一张表说清楚维度分页查询流式查询返回类型ListTCursorT内存占用与总数据量正相关与批处理大小相关数据库连接每页一次查询连接可释放全程保持连接首包时间受 offset 影响越翻越慢稳定取到第一条即可处理适用场景页面列表、小数据量全量导出、批处理流式查询返回的Cursor是Iterable的子接口你可以用 for-each 遍历也可以手动iterator()。它内部维护了一个ResultSetMyBatis 不会一次性把结果读进内存而是每次next()时从数据库拉取。这就是它省内存的根本原因。但要注意省内存的代价是连接不能断。一旦SqlSession关闭或事务提交游标就失效后续遍历直接抛异常。所以流式查询必须手动管理SqlSession的生命周期不能依赖 Spring 的Transactional自动提交。我在实际项目里还发现一个细节MySQL 的 JDBC 驱动默认会把整个结果集加载到内存即使你用了Cursor。要真正流式需要在连接参数里加useCursorFetchtrue并且fetchSize设为Integer.MIN_VALUE或一个正数。这个坑后面会详细讲。另外流式查询和分页不是对立的。我的做法是外层用时间或部门做数据切隔每个切隔内部用流式查询分批取数这样既控制了单次连接时长又避免了 offset 翻页的性能衰减。这套组合拳打下来50 万行导出的内存峰值从 1.8G 降到了 200M 左右。2. TaoToken 统一 Key 通道把模型调用收口到一个入口写流式查询的过程中我经常需要让模型帮我生成ResultHandler的样板代码、解释Cursor的异常堆栈、或者对比不同fetchSize参数的效果。如果每个模型都单独配一套 Key 和 Base URL光是环境变量就够乱的。TaoToken 解决的就是这个问题它提供一个统一的 API 入口你用同一个 Key 就能调用多个模型。TaoToken 是什么简单说它是一个模型 API 的统一通道。你不需要为每个模型厂商单独申请 Key、单独记 Base URL、单独处理不同的请求格式。它把模型对话、代码生成、报错排查这些能力收口到一个https://taotoken.net/api地址下用统一的鉴权方式访问。适合谁适合像我这样在本地开发环境里频繁切换模型、又不想维护多套配置的开发者。它的核心价值有三个。第一Key 统一。你只需要在 TaoToken 控制台创建一个 API Key就能在多个模型之间切换不用改代码里的鉴权逻辑。第二Base URL 统一。所有请求都打到https://taotoken.net/apiSDK 配置一次就行。第三模型 ID 统一管理。你可以在控制台看到当前可用的模型列表直接拿 Model ID 填到配置里。我试过在排查Cursor遍历时的ExecutorException时把异常堆栈贴给模型让它帮我定位是SqlSession提前关闭还是fetchSize没生效。整个过程不需要切换工具就在同一个通道里完成。对于流式查询这种涉及数据库连接、事务、JDBC 参数多个层面的问题有一个稳定的模型调用入口确实省事。如果你还没创建 Key可以去控制台生成一个https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。创建之后把 Key 存到环境变量里后面配置里直接引用。需要说明的是TaoToken 是模型 API 的统一通道不是数据库连接工具也不替代 MyBatis 本身。它的作用是帮你在开发过程中更快地生成代码、排查报错、验证参数。流式查询的核心逻辑还是跑在你的 SpringBoot 应用和 MySQL 之间。3. 可复制配置ResultHandler 与 Cursor 两种落地方式这一节给出两种可复制的配置方式。第一种是ResultHandler适合你已经有分页逻辑、想改成流式但不想大改接口的场景。第二种是Cursor适合新写的批处理任务。两种方式我都会给出完整的 Mapper、Service 和 JDBC 参数配置。先看 JDBC 连接参数。这是流式查询能否真正生效的关键。在application.yml里这样配spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo?useCursorFetchtrueuseServerPrepStmtstruerewriteBatchedStatementstrue username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2useCursorFetchtrue告诉驱动使用游标方式拉取数据useServerPrepStmtstrue让服务端预处理生效。这两个参数缺一不可否则Cursor依然会把结果集全量加载。接下来是ResultHandler方式。它的思路是Mapper 方法不返回结果而是接收一个ResultHandlerMyBatis 每读到一行就回调一次。这种方式的好处是你不需要手动管理SqlSessionSpring 的事务管理器会帮你处理连接。Mapper public interface PersonDao { void selectByHandler(ResultHandlerPerson handler); }select idselectByHandler resultMappersonMap select * from sys_person order by id desc /selectService 层这样调用Service Slf4j public class PersonExportService { Autowired private PersonDao personDao; public void exportByHandler() { ListPerson batch new ArrayList(1000); personDao.selectByHandler(context - { Person person context.getResultObject(); batch.add(person); if (batch.size() 1000) { processBatch(batch); batch.clear(); } }); if (!batch.isEmpty()) { processBatch(batch); } } private void processBatch(ListPerson batch) { log.info(处理一批数据size{}, batch.size()); } }注意ResultHandler方式下MyBatis 会在方法返回前遍历完整个结果集所以processBatch是在查询过程中被调用的。这种方式不需要手动开SqlSession但要求你的处理逻辑不能太慢否则会长时间占用数据库连接。再看Cursor方式。这种方式更灵活你可以控制遍历节奏也可以在遍历过程中做异步处理。但必须手动管理SqlSession。Mapper public interface PersonDao { CursorPerson selectByCursor(); Integer queryCount(); }select idselectByCursor resultMappersonMap select * from sys_person order by id desc /select select idqueryCount resultTypejava.lang.Integer select count(*) from sys_person /selectService 层Service Slf4j public class PersonCursorService { Autowired private SqlSessionFactory sqlSessionFactory; public void exportByCursor() { try (SqlSession sqlSession sqlSessionFactory.openSession()) { PersonDao mapper sqlSession.getMapper(PersonDao.class); CursorPerson cursor mapper.selectByCursor(); int total mapper.queryCount(); ListPerson batch new ArrayList(1000); int batchNo 0; for (Person person : cursor) { batch.add(person); if (batch.size() 1000) { batchNo; log.info(第 {} 批处理 {} 条, batchNo, batch.size()); processBatch(batch); batch.clear(); } } if (!batch.isEmpty()) { processBatch(batch); } sqlSession.commit(); log.info(全部处理完毕total{}, total); } } private void processBatch(ListPerson batch) { // 模拟处理耗时 } }这里有个关键点sqlSession.commit()必须在遍历完成后调用。如果你在遍历过程中提交游标会失效。另外try-with-resources会自动关闭SqlSession但提交需要手动做。如果你需要用模型帮你生成类似的 Mapper 或 Service 代码可以在 TaoToken 的模型对话里直接描述需求https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。把表结构和批处理大小告诉它生成的代码基本能直接用。4. 验证请求本地压测看内存与首包时间配置写完了怎么验证流式查询真的生效了我做了两组对比一组用分页查询一组用Cursor流式查询分别导出 50 万行数据观察内存占用和首包时间。先准备测试数据。在 MySQL 里建一张sys_person表插入 50 万行CREATE TABLE sys_person ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(64), salary DECIMAL(10,2), dept_id INT, create_time DATETIME ); INSERT INTO sys_person (user_name, salary, dept_id, create_time) SELECT CONCAT(user_, n), ROUND(RAND() * 20000, 2), FLOOR(RAND() * 100), NOW() FROM ( SELECT a.N b.N * 10 c.N * 100 d.N * 1000 e.N * 10000 f.N * 100000 AS n FROM (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a CROSS JOIN (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b CROSS JOIN (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) c CROSS JOIN (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) d CROSS JOIN (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) e CROSS JOIN (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) f ) numbers WHERE n 500000;然后在 SpringBoot 里加一个测试接口分别调用分页和流式两种导出方法。启动时加上 JVM 参数-Xmx512m模拟内存受限的环境。分页查询的写法public void exportByPage() { int pageSize 5000; int pageNo 0; while (true) { ListPerson list personDao.selectByPage(pageNo * pageSize, pageSize); if (list.isEmpty()) { break; } processBatch(list); pageNo; } }流式查询用上一节的Cursor方式。跑完之后看日志和监控指标分页查询Cursor 流式查询首包时间1.2s第 1 页0.3s第一条内存峰值1.8GOOM210M总耗时未完成48sFull GC 次数频繁2 次分页查询在第 60 页左右就 OOM 了因为 offset 太大MySQL 扫描行数暴增加上堆里堆积的中间对象。流式查询全程稳定内存峰值控制在 210M首包时间 0.3s 就能拿到第一条数据开始处理。这里有个细节Cursor的getCurrentIndex()返回的是已读取数据的索引第一条是 0。你可以用它来判断是否读到了最后一条。但注意getCurrentIndex()在遍历过程中调用是安全的遍历结束后返回 -1。如果你在压测过程中遇到报错可以把异常堆栈贴到 TaoToken 的模型对话里让它帮你分析https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。我遇到过java.sql.SQLException: Streaming result set is still active就是因为在遍历完成前提交了事务。5. 常见报错排查401、local proxy failed、reading choices、OAuth这一节整理我在流式查询和 TaoToken 接入过程中真实遇到的报错以及排查思路。每个报错都给出触发条件和解决方案。报错一401 Unauthorized这个报错通常出现在调用 TaoToken API 时。触发条件是 API Key 没配、配错、或者环境变量没生效。排查步骤先确认TAOTOKEN_API_KEY环境变量是否设置然后在代码里打印 Key 的前 8 位和后 4 位确认没有多余空格。如果用的是settings.json或auth.json检查字段名是否正确。{ baseUrl: https://taotoken.net/api, apiKey: sk-xxxxxxxxxxxxxxxx, model: claude-sonnet-4-20250514 }注意Base URL 是https://taotoken.net/api不要加多余的路径。Key 和 Model ID 必须同时配置缺一个都会导致请求失败。报错二local proxy failed这个报错一般出现在本地开发环境模型请求发不出去。触发条件通常是本地网络配置有问题或者 Base URL 写成了localhost。排查思路先确认https://taotoken.net/api能通再检查代码里有没有硬编码的代理地址。如果你在settings.json里配了proxy字段先删掉试试。报错三reading choices 相关异常这个报错出现在解析模型响应时。触发条件是响应格式和预期不一致比如模型返回了错误信息而不是正常的 choices 数组。排查思路先把原始响应打印出来看error字段的内容。常见原因是 Model ID 写错了或者请求体里stream参数和接口不匹配。报错四OAuth 相关错误如果你用的是 Claude Code 或 Codex 这类工具可能会遇到 OAuth 报错。触发条件是认证方式选错了。解决方案是改用 API Key 方式在auth.json里配置{ type: api_key, api_key: sk-xxxxxxxxxxxxxxxx, base_url: https://taotoken.net/api }如果你用的是 CC Switch 或 Cline MCP配置里必须同时出现三件套Base URL、Key、Model ID。缺任何一个都会导致连接失败。# Cline MCP 配置示例 [model] base_url https://taotoken.net/api api_key sk-xxxxxxxxxxxxxxxx model_id claude-sonnet-4-20250514报错五Streaming result set is still active这是流式查询本身的报错。触发条件是在Cursor遍历完成前提交了事务或关闭了SqlSession。解决方案确保sqlSession.commit()和sqlSession.close()都在遍历循环之后执行。如果你用了Transactional把它去掉改用手动管理。报错六ExecutorException: A query was run and no Result Maps were found这个报错出现在ResultHandler方式下。触发条件是 Mapper XML 里的resultMap没配好或者返回类型和ResultHandler的泛型不匹配。排查思路检查select标签的resultMap属性是否指向了正确的映射以及ResultHandlerPerson的泛型是否和resultMap的 type 一致。排查这些报错时我习惯把完整的异常堆栈和配置片段一起贴给模型让它帮我交叉比对。TaoToken 的模型对话支持长上下文堆栈再长也能一次贴完https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。6. 把 Key 收口之后我的调试流程变成了这样以前我的本地环境里有三套 Key、四个 Base URL、五个模型 ID每次切换都要改环境变量或者重启 IDE。现在我把所有模型调用都收口到 TaoToken 一个通道settings.json里只保留一份配置{ baseUrl: https://taotoken.net/api, apiKey: sk-xxxxxxxxxxxxxxxx, model: claude-sonnet-4-20250514 }需要换模型时只改model字段不用动 Key 和 Base URL。需要生成新的 Mapper 代码时直接在模型对话里描述表结构和批处理逻辑。需要排查Cursor异常时把堆栈贴进去让它定位。如果你也在做大数据量导出或批处理建议先把 JDBC 参数里的useCursorFetchtrue加上再把分页循环改成Cursor遍历。内存峰值和首包时间的差异跑一次压测就能看到。Key 的创建入口在这里https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。最后说一个我踩过的坑Cursor遍历过程中不要做耗时太长的单条处理否则数据库连接会一直占着。我的做法是每 1000 条攒一批批量处理完再继续遍历。这样既控制了内存又不会让连接空闲太久。如果你需要长时间跑批处理任务可以考虑用 Coding Plan 来管理模型调用配额https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。
网站建设高端定制企业官网