
1. 百万级导出把 JVM 撑爆的那次经历先说结论MyBatis Cursor 是 MyBatis 3.4.0 引入的流式查询接口它继承IterableT和Closeable核心能力是让查询结果不再一次性灌进 JVM 堆内存而是由 JDBC 驱动在数据库端维持一个游标应用侧调用next()时才把当前行或一小批拉过来。它适合谁适合做大数据量导出、全表遍历、离线对账、数据迁移这类结果集可能有几十万到上亿行的场景。如果你平时查的都是几千条以内的列表页那 List 一把梭就够了硬上 Cursor 只会徒增复杂度。我试过用普通ListUser去接一张 300 万行的用户表做 CSV 导出本地跑的时候堆内存直接飙到 4GB 以上GC 日志一片红最后OutOfMemoryError: Java heap space收场。后来换成 Cursor 逐行消费内存曲线几乎是平的稳定在 100MB 上下。这篇文章就把这套东西从原理到可复制的配置骨架讲透包括 Cursor 返回类型怎么写、fetchSize 怎么设、事务边界卡在哪、怎么验证它真的省内存以及我踩过的那些坑。需要先明确一个容易误解的点Cursor 并没有让数据库少干活。select * from users这条 SQL 在数据库端照样会扫描全表、生成完整结果集该走的索引、该做的排序一样不少。变的只是数据从数据库传输到应用的方式——从洪水漫灌变成滴灌。理解这一点后面很多行为就顺了。2. 前置准备环境、依赖与 TaoToken 接入2.1 版本与依赖要求Cursor 对版本有硬性要求低于 3.4.0 的 MyBatis 根本没有这个接口。我建议直接用 3.5.x 系列稳定性和驱动兼容性都更好。dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.13/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependencySpring Boot 项目里如果用的是mybatis-spring-boot-starter注意它内部锁定的 MyBatis 版本必要时手动覆盖。2.2 用 TaoToken 做联调与排障写这类流式查询最烦的是报错信息不直观——比如Connection closed、Cursor is closed这种光看堆栈很难定位是事务边界问题还是驱动配置问题。我习惯把关键 SQL、异常堆栈丢给模型对话去分析让它帮我判断是 fetchSize 没生效还是 SqlSession 提前关了。TaoToken 的接入地址是https://taotoken.net/api控制台在https://taotoken.net/consoleAPI Key 在https://taotoken.net/api-keys生成。如果你要长期做编码和 Agent 类工作可以看下 Coding Planhttps://taotoken.net/coding-plan。文档在https://taotoken.net/docClaude Code 相关的接入说明在https://taotoken.net/ClaudeCodeAnthropic。官网入口是https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。注意TaoToken 在这里的角色是辅助你排查 SQL 与异常、生成配置骨架不是替代你的数据库或编辑器。生产库的连接信息不要贴给任何外部服务。3. 可复制的 Cursor 配置骨架3.1 Mapper 接口与注解写法最简形态就是在 Mapper 方法上把返回类型从ListUser换成CursorUser并用Options指定 fetchSize。public interface UserMapper { Select(select id, name, email, age from users order by id) Options(fetchSize Integer.MIN_VALUE) CursorUser scanAllUsers(); Select(select id, name, email, age from users where age #{minAge} order by id) Options(fetchSize 1000) CursorUser scanByAge(Param(minAge) int minAge); }fetchSize Integer.MIN_VALUE是 MySQL 驱动的一个特殊约定表示开启逐行流式读取。但逐行取意味着网络往返次数极多吞吐会明显下降。工程上我更推荐fetchSize 1000让驱动每次批量拉 1000 行到本地缓存应用再逐条消费兼顾内存和效率。3.2 XML 映射写法如果你用的是 XML配置等价select idscanAllUsers resultTypecom.example.domain.User fetchSize1000 select id, name, email, age from users order by id /select3.3 事务边界这是最容易翻车的地方Cursor 必须在 SqlSession 的生命周期内消费完。一旦 SqlSession 关闭底层 ResultSet 和 Connection 就没了游标自然失效。所以正确姿势是用 try-with-resources 把 SqlSession 和 Cursor 都包住且遍历逻辑写在最内层。try (SqlSession sqlSession sqlSessionFactory.openSession()) { UserMapper mapper sqlSession.getMapper(UserMapper.class); try (CursorUser cursor mapper.scanAllUsers()) { for (User user : cursor) { processUser(user); } } }这里有个细节openSession()默认autoCommitfalse事务不会自动提交。对于只读的流式查询这没问题但要注意别在遍历过程中做写操作否则容易和游标持有的连接产生锁竞争。3.4 数据库侧的关键配置不同数据库对游标流式的支持方式不一样配置错了 fetchSize 就是摆设。数据库关键配置说明MySQLURL 加useCursorFetchtruefetchSize 设正数或Integer.MIN_VALUE不加这个参数驱动会忽略 fetchSize仍然全量拉取PostgreSQLautoCommitfalsefetchSize 设正数如 1000PG 的游标必须在事务内才生效Oracle直接设 fetchSize 即可默认就是游标模式MySQL 的 JDBC URL 示例spring.datasource.urljdbc:mysql://localhost:3306/mydb?useCursorFetchtrueuseSSLfalseserverTimezoneAsia/Shanghai注意useCursorFetchtrue是 MySQL 侧流式的前提很多人只设了 fetchSize 却没加这个参数结果内存照样爆还以为是 Cursor 没用。4. 验证请求与成功结果4.1 内存占用对比验证光说省内存不够得能测出来。我一般用Runtime打印堆内存快照在遍历前后各打一次。public void exportWithCursor(OutputStream out) throws IOException { Runtime rt Runtime.getRuntime(); System.out.println(before: usedMb(rt) MB); try (SqlSession sqlSession sqlSessionFactory.openSession()) { UserMapper mapper sqlSession.getMapper(UserMapper.class); try (CursorUser cursor mapper.scanAllUsers(); BufferedWriter writer new BufferedWriter(new OutputStreamWriter(out))) { int count 0; for (User user : cursor) { writer.write(toCsvLine(user)); writer.newLine(); count; if (count % 100000 0) { System.out.println(processed count , used usedMb(rt) MB); } } } } System.out.println(after: usedMb(rt) MB); } private long usedMb(Runtime rt) { return (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024; }实测下来300 万行数据用 List 接堆内存峰值 4GB 以上换成 Cursor fetchSize1000全程稳定在 100MB 左右处理到第 100 万、200 万行时内存几乎不涨。这就是流式的价值——内存占用和数据总量解耦。4.2 首条数据响应时间另一个直观指标是首条数据返回时间。List 模式要等全部结果集传输完才能开始处理百万级数据可能要几十秒Cursor 模式第一条通常在几十毫秒内就返回了用户能立刻看到反馈。做导出进度条的时候这个差异特别明显。4.3 用 Stream 做流式处理Cursor 实现了Iterable可以直接转成 Streamtry (CursorUser cursor mapper.scanAllUsers()) { cursor.stream() .filter(u - u.getAge() 18) .map(User::getName) .forEach(this::writeName); }但要小心如果你在 Stream 末尾调collect(Collectors.toList())等于又把所有结果汇集回内存流式的意义就没了。量大时坚持用forEach或自定义的Collector做增量写出。5. 本篇常见错排查5.1 Connection closed / Cursor is closed最常见的原因是 SqlSession 提前关闭或者 Cursor 被带出了 try-with-resources 作用域。比如把 Cursor 作为方法返回值返回调用方拿到时连接已经关了。正解是遍历逻辑必须和 SqlSession 在同一层作用域内完成。5.2 内存还是爆了先检查 MySQL URL 有没有加useCursorFetchtrue。其次确认 fetchSize 真的传下去了——XML 里写fetchSize属性注解里写Options(fetchSize...)两处别漏。还有一种情况是你在遍历里把每行都塞进了一个外部 List 做暂存那当然还是会爆。5.3 重复遍历同一个 Cursor 拿不到数据Cursor 是一次性消费的遍历完就废了第二次遍历要么没数据要么直接报错。需要多次遍历就重新查一次或者把结果落到临时表。5.4 遍历期间数据被改了怎么办这是高频疑问。答案是你遍历的是事务启动时的一致性快照外部并发修改、删除、插入都不影响你当前这次遍历。以 MySQL InnoDB 的 REPEATABLE-READ 为例事务开启时建立快照其他连接改了数据并提交你的快照通过 Undo Log 依然保留旧版本。但你自己在遍历过程中去改数据行为就不确定了容易死锁强烈不推荐。5.5 长时间遍历占着连接不放流式查询期间数据库连接和游标一直被占用如果单条处理逻辑里有耗时 RPC 调用连接池很快会被耗尽。建议把耗时操作移出遍历循环或者先落盘再异步处理。5.6 小数据量也上 Cursor几千条数据用 List 就够了Cursor 反而增加代码复杂度和连接占用时间。判断标准很简单结果集可能超过可用内存的十分之一才考虑 Cursor。6. 继续深入与工具选择把上面的骨架跑通后你会发现 Cursor 的难点不在 API 本身而在事务边界和驱动配置这些周边。遇到fetchSize不生效、游标提前关闭这类问题时把 SQL、URL 配置和异常堆栈整理清楚再去查效率会高很多。模型对话入口在https://taotoken.net/api适合做这种针对性排障如果你要长期写这类数据管道和 Agent 任务Coding Plan 会更顺手https://taotoken.net/coding-plan。API Key 记得在https://taotoken.net/api-keys管理接入细节看https://taotoken.net/doc。最后留一句我自己的口诀小数据用 List大数据用 Cursor用完必须关遍历期间别乱改MySQL 别忘了useCursorFetchtrue。