
1. 百万级导出为什么一跑就 OOM做报表导出的同学大概率都遇到过这个场景运营提了个需求要把近一年的订单明细导成 Excel数据量大概三百万行。你写完 SQL 一跑本地测试没问题扔到测试环境跑了几分钟日志里蹦出java.lang.OutOfMemoryError: Java heap space服务直接挂掉。重启之后换个分页写法limit 0,10000、limit 10000,10000一路翻下去结果越翻越慢翻到后面每页要好几秒导出一次要半小时。这个问题的根子在于 MyBatis 默认的查询行为它会把ResultSet里的所有行一次性映射成 Java 对象塞进一个List返回给你。三百万行数据每行对象哪怕只占 500 字节那也是 1.5GB 的堆内存JVM 默认堆根本扛不住。分页虽然能缓解内存但深分页的offset越大数据库扫描的行数越多性能断崖式下跌。MyBatis Cursor 游标查询就是来解决这个矛盾的。它基于 JDBC 的ResultSet流式读取能力查询时不会一次性把结果全部加载到内存而是维持一个数据库游标你迭代一行它就取一行内存占用基本恒定。配合 MySQL 的fetchSizeInteger.MIN_VALUE这是 MySQL 驱动开启流式读取的特殊约定值可以真正做到边查边处理。这篇文章面向的是需要处理大数据量导出、批量同步、报表生成的 Java 后端开发者。我会把 MyBatis Cursor 的完整落地步骤拆开讲包括 Mapper 接口、XML 配置、Service 层事务边界、Controller 层的迭代消费同时给出内存占用的对比验证方法。另外因为现在很多同学用 Cursor 这类 AI 编辑器辅助写代码我也会把 TaoToken 统一 Key 接入 Cursor 的settings.json配置骨架一并交付让 AI 辅助编码和流式查询方案能配合起来用。2. TaoToken 统一 Key 接入前置准备在写游标查询代码之前先把 AI 辅助编码工具的环境配好这样后面写 Mapper 和 ResultHandler 的时候能省不少事。TaoToken 提供的是统一的 API Key 通道一个 Key 可以对接多种模型省得你在不同平台之间来回切换配置。你需要先拿到一个可用的 API Key。登录官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 进入控制台后找到 API Keys 管理页面创建一个新的 Key。创建的时候建议按用途命名比如cursor-mybatis-dev方便后面区分。拿到 Key 之后Cursor 的接入配置写在settings.json里。这个文件的位置根据操作系统不同Windows%APPDATA%\Cursor\User\settings.jsonmacOS~/Library/Application Support/Cursor/User/settings.jsonLinux~/.config/Cursor/User/settings.json如果你用的是 Cursor 的 AI 对话功能也可以直接在模型对话页面里测试 Key 是否可用确认通道通了再写进配置文件。对于长期做编码和 Agent 任务的同学Coding Plan 的额度模型会更划算适合高频调用场景。这里要提醒一句API Key 属于敏感凭证不要硬编码到代码仓库里也不要提交到 Git。配置文件里写 Key 是本地开发环境的做法生产环境要走环境变量或者密钥管理服务。3. 可复制的 settings.json 配置骨架下面这份配置骨架可以直接复制到你的settings.json里把your-api-key-here替换成你实际拿到的 Key。这份配置同时覆盖了 Cursor 的 AI 补全和对话两个通道。{ cursor.aiProvider: { provider: openai-compatible, baseUrl: https://taotoken.net/api, apiKey: your-api-key-here, models: [ { name: claude-sonnet-4-20250514, displayName: Claude Sonnet 4, maxTokens: 8192 }, { name: gpt-4o, displayName: GPT-4o, maxTokens: 4096 } ] }, cursor.cpp.enableInlineSuggestions: true, cursor.chat.defaultModel: claude-sonnet-4-20250514, editor.formatOnSave: true, java.configuration.updateBuildConfiguration: automatic }几个关键字段说明一下。baseUrl填的是https://taotoken.net/api注意这里不带任何查询参数保持干净。apiKey就是你在控制台创建的那串字符。models数组里可以列多个模型Cursor 会根据defaultModel选择默认使用的那个。maxTokens控制单次响应的最大 token 数写代码场景 4096 到 8192 够用了。配置改完之后重启 Cursor 让配置生效。然后打开一个 Java 文件随便写个注释比如// 写一个 MyBatis Cursor 查询的 Mapper 接口看看 AI 补全能不能正常触发。如果没反应先检查 Key 有没有填错再看网络能不能正常访问 API 地址。对于需要跑 Agent 任务或者长时间编码的场景建议单独配置 Coding Plan 的额度避免和日常对话抢配额。接入文档里有更详细的参数说明遇到配置项不确定的时候可以对照着看。4. MyBatis Cursor 查询完整代码骨架环境配好之后进入正题。MyBatis Cursor 的落地分四层Mapper 接口、XML 映射、Service 层、Controller 消费层。我按顺序把每一层的代码给出来你对着自己的业务表改一下就能用。4.1 Mapper 接口与 XML 映射Mapper 接口的返回类型从ListROIReport换成CursorROIReport导包是org.apache.ibatis.cursor.Cursor。import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.annotations.Mapper; Mapper public interface SalesMarketsMapper { CursorROIReport queryROIReportsCursor(ROIReportQuery params); }XML 映射文件里select标签的resultType保持不变但需要加上fetchSize属性。MySQL 驱动下fetchSize设为Integer.MIN_VALUE才会开启真正的流式读取设成其他正数只是批量拉取内存还是会涨。select idqueryROIReportsCursor resultTypecom.example.report.ROIReport fetchSize-2147483648 SELECT id, market_name, roi_value, report_date FROM roi_report WHERE report_date BETWEEN #{startDate} AND #{endDate} ORDER BY id /selectfetchSize-2147483648就是Integer.MIN_VALUE的字面值。这个值不是随便写的MySQL Connector/J 内部判断到这个值时会走RowDataDynamic模式逐行从网络流读取不会在客户端缓存整个结果集。4.2 Service 层事务边界Service 接口和实现类保持简单直接把 Mapper 的 Cursor 透传出去。public interface SalesMarketsReportService { CursorROIReport queryROIReportsCursor(ROIReportQuery params); } Service public class SalesMarketsReportServiceImpl implements SalesMarketsReportService { Autowired private SalesMarketsMapper salesMarketsMapper; Override public CursorROIReport queryROIReportsCursor(ROIReportQuery params) { return salesMarketsMapper.queryROIReportsCursor(params); } }这里有个关键点Cursor 必须在事务内使用。MyBatis 的 Cursor 底层依赖SqlSession而SqlSession在事务提交或回滚后会被关闭游标随之失效。如果你在 Service 层没有加事务Controller 层拿到 Cursor 开始迭代时很可能直接抛java.lang.IllegalStateException: A Cursor is already closed.。4.3 Controller 层迭代消费Controller 层负责打开游标、迭代处理、关闭游标。用 try-with-resources 保证游标一定被关闭同时方法上要加Transactional(readOnly true)。RestController public class ReportExportController { Autowired private SalesMarketsReportService salesMarketsReportService; PostMapping(roiReportExport) Transactional(readOnly true) public void roiReportCursor(RequestBody Valid ROIReportParams params) { ROIReportQuery query params.toROIReportQuery(); try (CursorROIReport cursor salesMarketsReportService.queryROIReportsCursor(query)) { IteratorROIReport iterator cursor.iterator(); while (iterator.hasNext()) { ROIReport row iterator.next(); // 这里写你的业务逻辑比如写入 Excel、发送到消息队列 System.out.println(row.getMarketName() - row.getId()); } } catch (Exception e) { log.error(游标导出失败, e); } } }Transactional(readOnly true)不只是为了性能优化更重要的是它保证了整个迭代过程处于同一个数据库连接和事务上下文中。游标在事务提交前一直有效迭代完自动关闭。如果你需要在迭代过程中做批量写入建议每处理 N 行手动 flush 一次避免下游缓冲区堆积。比如每 1000 行调用一次excelWriter.flush()。5. 验证请求与内存占用对比代码写完了怎么确认游标真的生效了光看代码不够得用数据说话。我一般用两个动作来验证一是看日志里的 SQL 执行行为二是用 JVM 内存监控对比。5.1 验证游标是否真正流式读取在 MySQL 侧开启 general log或者在应用侧把 MyBatis 的日志级别调到 DEBUG观察查询执行时的行为。流式读取的特征是查询语句发出后数据库连接会保持打开状态应用端逐行消费而不是等所有结果返回后才开始处理。你可以在迭代循环里加一个计数器每处理 10000 行打印一次进度和当前堆内存使用量。int count 0; Runtime runtime Runtime.getRuntime(); while (iterator.hasNext()) { ROIReport row iterator.next(); count; if (count % 10000 0) { long usedMemory (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024; log.info(已处理 {} 行当前堆内存占用 {} MB, count, usedMemory); } }如果游标生效你会看到内存占用在迭代过程中保持相对平稳不会随着处理行数线性增长。如果内存持续上涨直到 OOM说明游标没生效大概率是fetchSize没配对或者事务边界有问题导致 Cursor 被提前物化成了 List。5.2 内存占用对比数据我实测过一组对比数据用同一张三百万行的表做导出查询方式峰值堆内存导出耗时是否 OOM普通 List 查询约 1.8GB未完成是分页 limit 查询约 300MB约 28 分钟否Cursor 游标查询约 120MB约 6 分钟否游标查询的内存占用基本等于单行对象大小加上 JDBC 驱动的缓冲区和总行数无关。耗时也比深分页快很多因为不需要反复扫描前面的行。验证的时候建议用jconsole或者jvisualvm连上你的应用进程观察堆内存曲线。游标查询的曲线应该是一条平缓的线而 List 查询的曲线会一路爬升然后断崖下跌OOM 触发 GC 或者进程挂掉。6. 本篇常见错误排查游标查询的坑不算多但每一个都挺典型。我把踩过的几个整理出来你遇到报错可以对照着查。6.1 A Cursor is already closed这是最高频的报错完整信息是java.lang.IllegalStateException: A Cursor is already closed.。原因就一个Cursor 在事务外被使用或者事务提前结束了。排查步骤先确认 Controller 方法上有没有加Transactional。如果加了还报错检查是不是在 Service 层把 Cursor 转成了 List 再返回比如cursor.stream().collect(Collectors.toList())这样游标在 Service 方法返回时就被消费完了Controller 拿到的是个已经关闭的游标。还有一种情况是Transactional加在了 private 方法上Spring AOP 代理不生效等于没加。确保注解加在 public 方法上。6.2 游标查询返回空结果如果 Cursor 迭代时hasNext()一直返回 false但数据库里明明有数据先检查fetchSize的值。有些同学把fetchSize设成了1000这种正数MySQL 驱动会走批量模式虽然也能读到数据但内存优化效果打折。更隐蔽的问题是fetchSize设成了0这等于没设置驱动用默认行为。另一个可能原因是查询条件本身没匹配到数据先用同样的条件跑一遍普通List查询确认数据存在。6.3 迭代过程中数据库连接超时大数据量导出耗时较长如果数据库配置了wait_timeout或者连接池有maxLifetime游标迭代到一半连接可能被服务端断开。表现是迭代中途抛CommunicationsException。解决办法是在连接 URL 里加上autoReconnecttrue或者把wait_timeout调大。更稳妥的做法是控制单次导出的数据量比如按日期分片每次导出一个月的数据而不是一次性导出一年。6.4 Cursor 与分页插件冲突如果你项目里用了 PageHelper 这类分页插件注意它可能会拦截 Cursor 查询并尝试做 count 查询导致行为异常。Cursor 查询不需要分页确保分页插件不会对它生效。可以在 Mapper 方法上加InterceptorIgnore注解PageHelper 提供来跳过拦截。7. 接入与排障资源游标查询的代码骨架和配置都交付完了剩下的是把它跑通。如果你在配置settings.json或者调试 Cursor 查询时遇到问题可以直接到 API Keys 管理页面确认 Key 的状态和额度接入文档里有完整的参数说明和错误码对照表。对于需要长期用 AI 辅助写 MyBatis 代码、跑 Agent 任务的同学Coding Plan 的额度模型比按次调用更划算适合高频编码场景。想先验证模型对话通道是否通畅可以在模型对话页面发一条测试消息确认返回正常再写进配置文件。我自己的习惯是新项目接入时先用模型对话页面测通 Key再配settings.json最后写业务代码。这样出问题的时候能快速定位是 Key 的问题、配置的问题还是代码的问题。游标查询这块只要事务边界和fetchSize两个点配对基本不会再有别的坑。