
1. 从一次线上 OOM 说起Mybatis Cursor 到底解决什么问题线上有个对账任务每天凌晨跑一次从一张两千万行的流水表里把当天需要核对的记录捞出来逐条比对。最早用的是最朴素的写法Mapper 方法直接返回ListFlow本地跑小数据量没问题一上生产就出事堆内存一路涨到 4G 然后java.lang.OutOfMemoryError: Java heap space进程直接挂掉对账任务半途而废。问题根因很直白List返回值的语义是「把这次查询的所有行一次性读进内存再返回」。两千万行、每行几十个字段光对象头加字段就够把堆撑爆更别说 Mybatis 在映射过程中还会产生大量中间对象。你调大堆内存只是把爆炸时间往后推治标不治本。Mybatis 给出的答案是CursorT。它的官方注释写得很清楚适合处理通常放不进内存的海量数据查询。它本质是对 JDBCResultSet的一层惰性封装你迭代到哪一行驱动才从数据库拉哪一行准确说是按 fetchSize 分批拉内存里始终只保留当前批次的数据而不是全量结果集。这篇要落地的场景是在 TaoToken 统一 Key/API 通道下把 Mybatis Cursor 流式查询完整跑通从settings.json骨架到 Cursor 配置再到启动流式查询、观察内存占用、对比结果集分批消费的验证动作。适合正在被大数据量查询 OOM 折磨、想找一个可复制方案的后端同学。下面所有配置和命令都可以直接抄。2. 前置准备TaoToken 统一 Key 通道与 settings.json 骨架在动手改 Mybatis 之前先把模型/工具侧的通道理顺。TaoToken 在这里扮演的是统一入口的角色你不需要为每个模型或工具单独维护一套 Key 和地址而是用同一个 Key 走同一个 API 通道配置集中管理。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。为什么写 Mybatis 的文章要先讲这个因为现在很多团队在排查 OOM、生成压测脚本、让 AI 辅助分析慢 SQL 时会用到编码类工具。这些工具如果各自散落配置Key 一多就容易乱。统一到 TaoToken 通道后settings.json里只维护一份换工具不用重配。先拿 Key进入控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 页面创建一个 Key复制出来。对应的文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 接入细节以文档为准。一个可用的settings.json骨架长这样字段含义我写在注释里JSON 不支持注释实际使用时删掉{ apiKey: sk-你的TaoTokenKey, baseUrl: https://taotoken.net/api, model: claude-sonnet-4-5, timeoutMs: 60000, maxRetries: 2 }这里baseUrl用 API 地址不要带查询参数apiKey就是控制台里创建的那串。如果你用的是 Claude Code 这类编码工具接入方式参考 https://taotoken.net/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_contentClaudeCodeAnthropicutm_campaignrewrite 把上面的 Key 和地址填进去即可。需要长期跑编码任务或 Agent 的可以看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 按用量规划更省心。注意settings.json里的 Key 不要提交到 Git 仓库用环境变量或本地忽略文件管理。这一点和数据库密码同等重要。通道理顺之后回到 Mybatis 本身。Cursor 的落地不依赖 TaoToken 的运行时TaoToken 负责的是你开发调试阶段的工具链统一两者是配合关系不是替代关系。3. 可复制配置Mapper 返回 Cursor 与 SpringBoot 事务约束先看最核心的一步把 Mapper 方法返回值从List改成Cursor。import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; Mapper public interface FlowMapper { Select(SELECT id, order_no, amount, status, created_at FROM flow WHERE status #{status}) CursorFlow streamByStatus(Param(status) int status); }就这么简单返回值类型换成CursorFlowMybatis 在解析方法签名时会识别returnsCursor true进而走executeForCursor分支最终由DefaultCursor封装ResultSet。它不会像List那样在handler.query()里把全部数据读出来而是把Statement和ResultSet一直持有等你迭代时才逐批取。但这里有个大坑也是最多人踩的在 SpringBoot 里直接用 Cursor 会报错提示SqlSession已关闭或Cursor已关闭。原因是 SpringBoot 下 Mybatis 的SqlSession生命周期默认只覆盖单次 Mapper 方法调用方法一返回SqlSession就被关闭而关闭SqlSession时会连带关闭它绑定的Cursor。你拿到手的 Cursor 已经是个「死」的。解决办法二选一第一种在调用 Cursor 的方法上加Transactional把事务边界拉长到覆盖整个迭代过程。这是最常用的方式Service public class FlowService { Autowired private FlowMapper flowMapper; Transactional public void reconcile(int status) { try (CursorFlow cursor flowMapper.streamByStatus(status)) { for (Flow flow : cursor) { // 逐行处理内存里只有当前行 doReconcile(flow); } } catch (IOException e) { throw new RuntimeException(cursor close failed, e); } } }第二种手动管理SqlSession自己控制 open/close 时机。这种方式更灵活但代码更啰嗦一般只在特殊场景用。还有一个容易被忽略的配置fetchSize。Cursor 的惰性是按批拉取的批大小由 JDBC 驱动的fetchSize决定。MySQL 驱动默认会把整个结果集拉到客户端这也是为什么很多人以为用了 Cursor 就不会 OOM结果还是炸必须显式设置fetchSize为Integer.MIN_VALUE才能开启真正的流式读取mybatis: configuration: default-fetch-size: -2147483648或者在 Mapper 方法上用OptionsOptions(fetchSize Integer.MIN_VALUE) Select(SELECT id, order_no, amount, status, created_at FROM flow WHERE status #{status}) CursorFlow streamByStatus(Param(status) int status);Integer.MIN_VALUE是 MySQL 驱动的约定值表示「逐行流式读取不要缓存整个结果集」。PostgreSQL 则用正数fetchSize比如 1000配合自动提交关闭来生效。数据库不同取值不同这点要按你实际用的驱动来。另外如果resultMap里用了集合嵌套collectionCursor 查询必须加resultOrderedtrue并按 id 列排序否则 Mybatis 无法正确分组会报错或结果错乱。4. 验证请求启动流式查询并观察内存占用配置写完怎么确认它真的在流式读取、真的没把内存撑爆光看代码不够得实测。第一步写一个验证用的接口或测试方法故意用大数据量查询同时打印内存Transactional public void verifyStream(int status) { Runtime rt Runtime.getRuntime(); long before usedMemory(rt); System.out.println(迭代前已用内存: before / 1024 / 1024 MB); int count 0; try (CursorFlow cursor flowMapper.streamByStatus(status)) { for (Flow flow : cursor) { count; if (count % 100000 0) { long now usedMemory(rt); System.out.println(已处理 count 行, 当前内存: now / 1024 / 1024 MB); } } } catch (IOException e) { throw new RuntimeException(e); } System.out.println(总行数: count , 迭代后内存: usedMemory(rt) / 1024 / 1024 MB); } private long usedMemory(Runtime rt) { return rt.totalMemory() - rt.freeMemory(); }第二步跑起来观察输出。如果配置正确你会看到内存曲线基本平稳处理到一百万行时内存和刚开始差不多只有小幅波动GC 正常回收。如果fetchSize没设对你会看到内存随处理行数线性上涨处理到几十万行就开始逼近堆上限。第三步做对比实验。把同一个查询分别用List和Cursor跑一遍用同样的数据量观察内存峰值。List版本大概率在几十万行时就 OOM 或触发 Full GC 频繁停顿Cursor版本则平稳跑完。这个对比能让你直观理解「惰性迭代」和「全量加载」的差别。第四步验证分批消费。Cursor 本身是迭代器你可以配合Iterator手动控制节奏比如每处理 1000 行提交一次下游写入避免下游也积压try (CursorFlow cursor flowMapper.streamByStatus(status)) { IteratorFlow it cursor.iterator(); ListFlow batch new ArrayList(1000); while (it.hasNext()) { batch.add(it.next()); if (batch.size() 1000) { flushToDownstream(batch); batch.clear(); } } if (!batch.isEmpty()) { flushToDownstream(batch); } }这样整条链路的内存占用都是可控的数据库侧按 fetchSize 分批拉应用侧按 batch 分批消费两端都不会堆积。如果你在验证过程中需要让 AI 帮你分析内存 dump 或生成压测脚本可以用模型对话入口 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 把日志贴进去问走的就是前面配好的统一通道。5. 本篇常见错排查Cursor 报错与 OOM 反复的几个原因报错一Cursor is already closed或SqlSession was not registered for synchronization这是最典型的。原因就是前面说的SqlSession生命周期问题。检查你的调用方法有没有加Transactional或者有没有手动管理SqlSession。注意Transactional要加在调用 Mapper 并迭代 Cursor 的那个方法上加在 Mapper 接口上没用。报错二Cursor results cannot be mapped to multiple resultMapsCursor 查询只能映射到单个resultMap。如果你的查询涉及多表 join 且配置了多个 resultMap需要合并成一个或者改用其他方式。报错三内存还是涨Cursor 好像没生效九成是fetchSize没设。MySQL 驱动默认fetchSize0会把整个结果集缓存到客户端内存这时候你用 Cursor 也只是把「一次性 List」换成了「一次性 ResultSet」内存照样爆。确认default-fetch-size或Options(fetchSize Integer.MIN_VALUE)生效了。可以在 SQL 日志里看驱动行为或者直接观察内存曲线。报错四迭代过程中抛Streaming result set ... is still active这通常是因为在迭代 Cursor 的同时在同一个连接上又发起了另一个查询。流式读取期间连接被占用不能再执行其他语句。解决办法是把下游操作放到另一个连接/事务里或者先把当前批数据收集完再处理。报错五resultOrdered相关错误用了嵌套resultMap但没加resultOrderedtrue或者没按 id 排序。Cursor 依赖有序结果来正确分组顺序乱了映射就错。加上resultOrderedtrue并在 SQL 里ORDER BY id。性能上的坑Cursor 迭代期间持有数据库连接如果处理单行很慢比如每行都调远程接口连接会被长时间占用可能拖垮连接池。建议控制单次迭代时长或者把「读取」和「处理」解耦读出来的批次丢到队列里异步处理。6. 把通道和查询都收拢到一处回头看整条链路数据库侧靠fetchSize开启真正的流式读取应用侧靠Cursor惰性迭代加分批消费控制内存SpringBoot 侧靠Transactional延长SqlSession生命周期。三件事缺一不可少任何一个都会让 OOM 卷土重来。工具链侧把 Key 和地址统一到 TaoToken 通道settings.json里维护一份配置编码、分析、生成脚本都走同一个入口省去到处找 Key 的麻烦。需要新建或轮换 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 相关配置别只看单元测试过没过一定要用接近生产的数据量跑一遍并打印内存曲线。小数据量下List和Cursor表现没差别只有数据量上来了fetchSize有没有生效、事务边界对不对才会暴露出来。