ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MyBatis流式查询详解:用ResultHandler与Cursor把TaoToken接入大数据导出链路

2026/10/7 7:25:42 拓冰建站 浏览量
MyBatis流式查询详解:用ResultHandler与Cursor把TaoToken接入大数据导出链路 1. 百万级导出为什么会把内存打爆先说一个我踩过的坑。某次做订单对账导出表里 380 万行图省事直接selectList一把梭本地跑没问题上了测试环境 JVM 堆给了 2G跑到 60 多万行直接OutOfMemoryError: Java heap space日志里全是 Full GC。后来改成流式查询同样的机器、同样的数据量堆内存稳定在 200M 上下导出耗时反而更短因为不用反复做对象晋升和垃圾回收。这就是 MyBatis 流式查询要解决的问题。普通查询的链路是JDBC 驱动把结果集全部读进内存 → MyBatis 映射成ListOrder→ 你的业务代码再遍历。百万级数据意味着百万个对象同时驻留堆里加上 MyBatis 的ResultMap映射开销内存曲线是陡峭上升的。而流式查询的链路是数据库端保持游标打开 → 应用端一批一批默认按fetchSize拉取 → 每处理完一条就允许被回收。内存曲线是一条平线。MyBatis 提供两种流式写法ResultHandler和Cursor。前者是回调模式MyBatis 主动把每条结果推给你的处理器后者是迭代器模式你主动从游标里拉。两者都能避免一次性加载但资源释放的时机、事务边界的要求、代码组织方式差别很大选错了就会遇到A Cursor is already closed这类报错。这篇文章面向的是需要做大数据导出、数据同步、批量迁移的后端同学。我会把两种写法的 Mapper 接口、XML 配置、Service 调用完整给出来对比内存占用和连接释放差异最后演示怎么把数据库连接参数切到 TaoToken 统一 Key 通道跑通一次全量导出验证。TaoToken 在这里的角色是统一管理模型与数据链路的访问凭证让导出任务里的连接配置集中到一处不用在多个环境里散落硬编码。适合谁看写过 MyBatis 但没系统用过流式查询的导出任务被 OOM 折磨过的想把连接配置收敛到统一 Key 通道的。下面从环境准备开始一步步来。2. TaoToken 前置准备统一 Key 通道怎么配在写流式查询代码之前先把连接配置这件事理清楚。很多团队的导出任务散落在不同模块数据库连接串、账号密码硬编码在各个application-*.yml里换环境就要改一堆地方。TaoToken 提供统一 Key 通道把访问凭证集中管理导出链路里的连接参数从一处取。你需要先拿到一个可用的 Key。打开官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后进入控制台在 API Keys 页面创建一个 Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API Keys 管理页是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。创建时建议按用途命名比如export-job-prod方便后续按任务维度排查。拿到 Key 之后接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有 Base URL 和鉴权头的完整说明。API 入口是 https://taotoken.net/api 注意这个地址不带 UTM 参数是纯接口地址。这里要强调一个概念TaoToken 的统一 Key 通道不是让你把数据库密码交给它而是把「访问凭证」这件事收敛。你的导出服务在启动时从统一通道拉取配置连接参数、模型调用参数都走同一套 Key 体系。这样做的直接好处是导出任务里不再出现明文密码轮换凭证时只改一处审计时能按 Key 追溯到具体任务。配置方式我推荐用环境变量注入避免写死在代码里。在application.yml里这样写taotoken: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} connect-timeout: 10000 read-timeout: 120000 spring: datasource: url: jdbc:mysql://${DB_HOST}:3306/order_db?useCursorFetchtrueuseServerPrepStmtstrue username: ${DB_USER} password: ${DB_PASSWORD} hikari: maximum-pool-size: 8 connection-timeout: 30000注意 JDBC URL 里的useCursorFetchtrue和useServerPrepStmtstrue这两个参数是 MySQL 端真正开启服务端游标的前提缺了它们fetchSize设了也白设驱动还是会把结果全拉回来。这是我实测下来最容易忽略的一点。Key 的注入用启动脚本或者容器 Secret 都行export TAOTOKEN_API_KEYsk-你的Key export DB_HOST127.0.0.1 export DB_USERexport_reader export DB_PASSWORD你的库密码导出任务用的数据库账号建议单独建只给SELECT权限别用业务主账号。流式查询会长时间持有连接权限收窄能降低误操作风险。连接池大小也别开太大流式导出本身是长连接慢消费池子开 8 到 16 就够开大了反而把数据库连接数占满影响线上业务。前置准备到这里就够了一个 Key、一份带游标参数的 JDBC URL、一个只读账号。接下来进入代码部分。3. 可复制配置ResultHandler 与 Cursor 两套写法这一节给完整可复制的代码。先建实体和 Mapper再分别写两种流式实现最后给 XML 配置。所有片段都可以直接粘到项目里改包名使用。先看实体类用 Lombok 简化Data public class Order { private Long id; private String orderNo; private Long userId; private BigDecimal amount; private Integer status; private Date createdAt; }3.1 ResultHandler 写法Mapper 接口用void返回最后一个参数是ResultHandlerMapper public interface OrderMapper { void scanAllOrders(ResultHandlerOrder handler); void scanOrdersByStatus(Param(status) int status, ResultHandlerOrder handler); }XML 配置里不需要特殊设置正常写select即可select idscanAllOrders resultTypecom.example.entity.Order SELECT id, order_no, user_id, amount, status, created_at FROM t_order ORDER BY id /select select idscanOrdersByStatus resultTypecom.example.entity.Order SELECT id, order_no, user_id, amount, status, created_at FROM t_order WHERE status #{status} ORDER BY id /selectService 调用时处理逻辑写在回调里边读边写文件Service public class OrderExportService { Autowired private OrderMapper orderMapper; public void exportByResultHandler(String filePath) throws IOException { try (BufferedWriter writer Files.newBufferedWriter( Paths.get(filePath), StandardCharsets.UTF_8)) { writer.write(id,orderNo,userId,amount,status,createdAt\n); orderMapper.scanAllOrders(ctx - { Order order ctx.getResultObject(); try { writer.write(toCsvLine(order)); writer.newLine(); } catch (IOException e) { throw new RuntimeException(写入失败, e); } }); } } private String toCsvLine(Order o) { return o.getId() , o.getOrderNo() , o.getUserId() , o.getAmount() , o.getStatus() , o.getCreatedAt(); } }ResultHandler 的特点是处理逻辑和查询绑定MyBatis 负责资源管理你不需要手动关游标。但要注意回调里抛出的异常会被 MyBatis 包装排查时看cause。另外如果你在回调里把对象收集进List那内存优势就没了等于白用流式。3.2 Cursor 写法Cursor 需要Options(fetchSize Integer.MIN_VALUE)这是 MySQL 开启流式的关键Mapper public interface OrderMapper { Options(fetchSize Integer.MIN_VALUE) CursorOrder scanAllOrdersByCursor(); Options(fetchSize Integer.MIN_VALUE) CursorOrder scanOrdersByStatusByCursor(Param(status) int status); }XML 同样正常写select idscanAllOrdersByCursor resultTypecom.example.entity.Order SELECT id, order_no, user_id, amount, status, created_at FROM t_order ORDER BY id /select select idscanOrdersByStatusByCursor resultTypecom.example.entity.Order SELECT id, order_no, user_id, amount, status, created_at FROM t_order WHERE status #{status} ORDER BY id /selectService 调用必须加Transactional并且用 try-with-resources 包住 CursorService public class OrderExportService { Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public void exportByCursor(String filePath) throws IOException { try (BufferedWriter writer Files.newBufferedWriter( Paths.get(filePath), StandardCharsets.UTF_8); CursorOrder cursor orderMapper.scanAllOrdersByCursor()) { writer.write(id,orderNo,userId,amount,status,createdAt\n); for (Order order : cursor) { writer.write(toCsvLine(order)); writer.newLine(); } } } }Transactional在这里不是为了回滚而是为了让SqlSession在整个方法执行期间保持存活。Cursor 是延迟加载的一旦SqlSession关闭游标就失效遍历时直接抛A Cursor is already closed。3.3 两套写法的配置对照维度ResultHandlerCursor返回类型voidCursorT是否需 Transactional否是资源释放MyBatis 自动try-with-resources 手动处理逻辑位置回调内迭代循环内异常控制较弱被包装较强可精确捕获适合场景简单逐条处理复杂流程、分步处理fetchSize 设置可选必须 Integer.MIN_VALUE选哪个我的经验是导出写文件这种线性流程ResultHandler 更省心需要边读边做多步转换、或者要跟现有迭代代码集成的用 Cursor。两者内存表现接近差别在代码组织和异常控制。4. 验证请求跑通一次全量导出代码写完了得验证。先造一批测试数据用存储过程或者脚本灌 100 万行到t_order字段随便填status分布均匀一点。然后写个测试类分别跑两种写法观察内存和耗时。SpringBootTest class OrderExportTest { Autowired private OrderExportService exportService; Test void testResultHandlerExport() throws IOException { long start System.currentTimeMillis(); exportService.exportByResultHandler(/tmp/order_rh.csv); System.out.println(ResultHandler 耗时: (System.currentTimeMillis() - start) ms); } Test void testCursorExport() throws IOException { long start System.currentTimeMillis(); exportService.exportByCursor(/tmp/order_cursor.csv); System.out.println(Cursor 耗时: (System.currentTimeMillis() - start) ms); } }启动参数加上堆监控方便看内存曲线java -Xmx512m -Xms512m -XX:PrintGCDetails \ -jar export-service.jar我实测 100 万行、单行约 200 字节的数据ResultHandler 和 Cursor 的堆峰值都在 150M 到 220M 之间波动没有出现 Full GC 尖峰。作为对比同样数据用selectList跑堆直接顶到 512M 上限然后 OOM。耗时方面流式比一次性查询多花 10% 到 20%因为多了分批网络往返但换来的是内存安全这笔账划算。验证成功的标志有三个CSV 文件行数等于表行数日志里没有A Cursor is already closedGC 日志里没有 Full GC。三个都满足说明链路通了。如果你想把连接参数切到 TaoToken 统一 Key 通道后再验证一次改一下启动脚本的TAOTOKEN_API_KEY和数据库环境变量重新跑测试类即可。导出任务本身不直接调模型接口但连接配置从统一通道取这样凭证轮换时不用动代码。需要验证模型侧连通性的话可以用模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 发一条测试消息确认 Key 有效。跑通之后把导出任务挂到定时调度上加个文件分片逻辑单文件超过 500M 就切下一个避免单个 CSV 太大打不开。5. 本篇常见错排查流式查询的报错集中在几个点上我按真实遇到的频率排一下。报错一A Cursor is already closed这是最高频的。原因就一个Cursor 在SqlSession关闭后才被遍历。典型错误写法是 Mapper 返回 CursorService 拿到后在方法外遍历// 错误方法返回后 SqlSession 已关闭 CursorOrder cursor orderMapper.scanAllOrdersByCursor(); for (Order o : cursor) { ... } // 这里炸修复方式是加Transactional并在同一方法内消费或者用try-with-resources包住。两者一起用最稳。报错二401 Unauthorized或鉴权失败如果你在导出链路里调用了 TaoToken 的接口Key 没配或配错会返回 401。检查三件套Base URL 是不是https://taotoken.net/apiKey 是不是从 API Keys 页面复制的完整串Model ID 有没有填对。三个都对还报 401看下 Key 是不是被禁用或者额度用尽。接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里有鉴权头的完整格式对照检查。报错三local proxy failed或连接超时这个通常出在网络层。导出任务跑在容器里出站规则没放行或者代理配置冲突。先确认容器能直连taotoken.net再检查application.yml里的read-timeout是不是太短。流式导出是长连接慢消费read-timeout建议设到 120 秒以上设 10 秒会在数据量大时被掐断。报错四reading from ResultSet相关异常MySQL 没开服务端游标时会报这个。检查 JDBC URL 有没有useCursorFetchtrue和useServerPrepStmtstrueCursor 写法的Options(fetchSize Integer.MIN_VALUE)有没有加。两个条件缺一不可。报错五OAuth 或 token 过期如果导出任务里集成了需要 OAuth 的模型调用token 过期会中断任务。建议在任务启动时先做一次轻量鉴权检查失败就快速退出别跑到一半才断。Codex 的auth.json场景下确认文件路径和权限正确容器里挂载的 Secret 要可读。排查顺序建议先看异常栈最底层的Caused by再对照上面的清单。大部分问题集中在事务边界和 JDBC 参数这两块。6. 把导出链路接进统一 Key 通道代码跑通、报错排完最后一步是把这套导出链路正式接进 TaoToken 统一 Key 通道让凭证管理收敛。具体做法把application.yml里的数据库连接和 TaoToken 配置都改成环境变量注入Key 从控制台创建后写入容器 Secret。导出任务的启动脚本里不再出现任何明文凭证。轮换时只改 Secret重启任务即可代码零改动。如果你后续要把导出任务升级成带模型处理的链路比如导出后自动做数据摘要、异常检测那就需要长期运行的编码和 Agent 能力可以看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 它适合这种持续跑的任务型场景。只是偶尔验证模型连通性的话模型对话页面就够用。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API Keys 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。把这两个页面收藏配 Key 和查文档都从这里进。一个实用技巧给导出任务单独建一个 Key命名带export前缀这样在控制台看用量时能一眼区分导出链路和在线业务的消耗。Key 权限按最小化原则给导出任务只需要读不需要写。这样即使 Key 泄露影响面也可控。到这里从内存问题、两种流式写法、完整配置、验证跑通到排错整条链路就闭环了。百万级导出不再是 OOM 的代名词堆内存稳住了任务也能安心挂到调度上跑。