1. 为什么大结果集查询会把内存打爆
先说结论:MySQL 环境下用 MyBatis 做普通查询,默认会把整个结果集一次性读进 JVM 内存,几百万行数据直接 OOM。Cursor(游标)就是解决这个问题的——它让 JDBC 逐批从服务端拉数据,而不是一口气全拿回来。
我在一个订单对账项目里踩过这个坑:单表 800 万行,用List<Order> selectAll()查出来做遍历,堆内存 4G 直接撑爆,GC 日志全是 Full GC。后来改成 Cursor 流式读取,内存曲线立刻平了,稳定在几百 MB。
Cursor 适合谁?适合需要遍历大结果集做批处理、导出、对账、数据迁移的场景。不适合的场景是:你只需要查几条数据,或者需要把结果集反复随机访问——那种情况用普通 List 更省事。
这里有个关键前提:MySQL 的流式查询不是随便设个fetchSize就生效的。它有一套特定的触发条件,配错了就是"伪 Cursor",数据照样全量加载。下面我把配置骨架、验证方法、常见坑一次讲清楚。
2. TaoToken 前置:把模型对话和编码辅助接进来
在动手改 MyBatis 配置之前,我习惯先把调试辅助工具准备好。排查 Cursor 问题时经常需要问模型"这段 JDBC 源码为什么这样判断",或者让模型帮我 review Mapper 写法。TaoToken 在这里的作用是提供一个统一的模型调用入口,省去到处找 Key 的麻烦。
官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
如果你只是想快速验证某个模型对 MyBatis 源码的理解,直接用模型对话页最方便: https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
如果你在做长期的编码工作,比如反复调试 Cursor 配置、让 Agent 帮你改 Mapper,那 Coding Plan 更合适: https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
需要自己管理调用凭证的话,去控制台和 API Keys 页面: https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
API 地址统一是https://taotoken.net/api,接入文档在这里: https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
用 Claude Code 做编码辅助的,Anthropic 兼容入口: https://taotoken.net/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
这些工具不是必须的,但排查 Cursor 这种涉及 JDBC 底层源码的问题时,有个能随时问的模型确实省时间。
3. 可复制的 MyBatis Cursor 配置骨架
3.1 环境与依赖版本
先确认版本,Cursor 的行为在不同版本间有差异:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 8+ | Cursor 接口在 JDK8 可用 |
| MyBatis | 3.5.2+ | 低版本 Cursor 支持不完整 |
| mysql-connector-java | 8.0.22 | 8.x 系列流式判断逻辑一致 |
| MySQL Server | 5.7 / 8.0 | 都支持流式结果集 |
Maven 依赖:
<dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.22</version> </dependency>3.2 Mapper XML 写法
核心是fetchSize="-2147483648",也就是Integer.MIN_VALUE。这个值不是随便写的,后面会解释原因。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.demo.mapper.MenuMapper"> <select id="cursor" resultType="com.demo.entity.Menu" fetchSize="-2147483648"> select menu_id as menuId, menu_name as menuName from t_menu where menu_id < #{menuId} </select> </mapper>注意fetchSize写在<select>标签上,不是写在全局 settings 里。全局 settings 里没有这个配置项,别找错地方。
3.3 Mapper 接口
返回类型必须是org.apache.ibatis.cursor.Cursor,不能是List:
package com.demo.mapper; import com.demo.entity.Menu; import org.apache.ibatis.cursor.Cursor; public interface MenuMapper { Cursor<Menu> cursor(Integer menuId); }3.4 Service 层:SqlSession 类型是关键
这一步最容易出错。必须用ExecutorType.REUSE打开 SqlSession,用 BATCH 或 SIMPLE 都会导致结果集被提前关闭。
package com.demo.service; import com.demo.entity.Menu; import com.demo.mapper.MenuMapper; import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.session.ExecutorType; import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSessionFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.Iterator; @Service public class MenuService { @Autowired private SqlSessionFactory sessionFactory; public void streamMenu() { SqlSession sqlSession = sessionFactory.openSession(ExecutorType.REUSE); try { MenuMapper mapper = sqlSession.getMapper(MenuMapper.class); Cursor<Menu> cursor = mapper.cursor(1000000); Iterator<Menu> iterator = cursor.iterator(); while (iterator.hasNext()) { Menu menu = iterator.next(); System.out.println(menu.getMenuId() + " - " + menu.getMenuName()); } } finally { sqlSession.close(); } } }如果你用 Spring 的@Transactional,注意事务边界。Cursor 必须在同一个 SqlSession 生命周期内消费完,跨事务会报 "Cursor is closed"。
4. 验证请求与成功结果
4.1 为什么必须是 Integer.MIN_VALUE
MySQL Connector/J 判断是否启用流式结果集的逻辑在com.mysql.cj.jdbc.StatementImpl#createStreamingResultSet:
protected boolean createStreamingResultSet() { return ((this.query.getResultType() == Type.FORWARD_ONLY) && (this.resultSetConcurrency == java.sql.ResultSet.CONCUR_READ_ONLY) && (this.query.getResultFetchSize() == Integer.MIN_VALUE)); }三个条件同时满足才走流式:结果集类型是FORWARD_ONLY、并发模式是CONCUR_READ_ONLY、fetchSize 等于Integer.MIN_VALUE。所以-2147483648不是魔法数字,是 MySQL 驱动约定的开关值。
4.2 验证游标是否真的生效
光看代码跑通不够,要确认数据是分批拉的。两个验证方法:
方法一:看内存曲线。用jconsole或jvisualvm挂到进程上,跑一个百万行查询。如果内存平稳,说明流式生效;如果内存阶梯式上涨到峰值,说明是伪 Cursor。
方法二:加日志看拉取节奏。在遍历循环里每 10000 条打一次时间戳:
int count = 0; while (iterator.hasNext()) { Menu menu = iterator.next(); if (++count % 10000 == 0) { System.out.println("已处理 " + count + " 条, 时间: " + System.currentTimeMillis()); } }真 Cursor 的时间戳是均匀递增的,伪 Cursor 会在开头卡很久(全量加载),然后瞬间打印完。
4.3 成功结果对照
跑通后你应该看到:
- 控制台逐条打印菜单数据,不是一次性刷屏
- 内存占用稳定,不随数据量线性增长
- 处理 100 万行耗时比普通查询略长(因为网络往返多了),但内存安全
5. 本篇常见错误排查
5.1 报 "Cursor is closed"
原因:SqlSession 被提前关闭,或者用了ExecutorType.BATCH/SIMPLE。
BatchExecutor#doQueryCursor里查询完就关了结果集,SimpleExecutor同理。只有ReuseExecutor不关闭,能持续从服务端取数据。改成ExecutorType.REUSE即可。
5.2 内存还是爆了
检查三处:fetchSize是否写成-2147483648(写成1000无效);resultType是否误写成List;是否在遍历前把 Cursor 转成了 List。
5.3 遍历时报连接超时
流式查询会长时间占用连接,MySQL 的wait_timeout默认 8 小时,一般够用。但如果处理逻辑很慢,建议调大net_write_timeout和net_read_timeout。
5.4 Spring 事务里 Cursor 失效
@Transactional默认用 SIMPLE executor。要么手动开 REUSE 的 SqlSession,要么在事务方法里用SqlSessionTemplate指定 executorType。
注意:Cursor 消费完必须关闭 SqlSession,否则连接泄漏。用 try-finally 包住。
6. 接入与排障资源
Cursor 配置本身不复杂,难的是排查"为什么没生效"。遇到 JDBC 源码层面的疑问,或者需要模型帮你 review Mapper 和 Service 写法,可以用这些入口:
API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
接入文档(含各语言 SDK 示例):https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
快速验证模型对源码的理解,用模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
长期做编码调试和 Agent 辅助,用 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
API 基础地址:https://taotoken.net/api
最后补一个实战技巧:生产环境用 Cursor 时,给遍历循环加个批量提交的节奏控制,比如每 5000 条做一次业务侧落库或缓存写入,避免长时间持有连接。另外 Cursor 不支持RowBounds分页,要分页就自己在 SQL 里加limit,别指望 RowBounds 和 Cursor 混用。