1. 大结果集查询为什么需要 Cursor
如果你写过select * from 大表然后直接返回List<T>,大概率遇到过两种情况:一是 JVM 堆内存被撑爆,GC 频繁到接口超时;二是数据库驱动一次性把结果集全拉到客户端,网络和内存双重压力。MyBatis 的流式查询就是解决这个问题的:查询成功后不返回集合,而是返回一个org.apache.ibatis.cursor.Cursor迭代器,应用每次从迭代器取一条记录,内存占用从「全量」降到「单条 + 驱动缓冲」。
Cursor继承了java.io.Closeable和java.lang.Iterable,所以它既能forEach遍历,也能try-with-resources关闭。它还提供三个方法:isOpen()判断是否打开、isConsumed()判断是否取完、getCurrentIndex()返回已取条数。听起来很美好,但真正落地时,90% 的人会撞上同一个报错:
java.lang.IllegalStateException: A Cursor is already closed.原因不复杂:流式查询过程中数据库连接必须保持打开,而 Mapper 方法默认执行完就释放连接,Cursor 跟着一起关了。所以核心不是「怎么写 Mapper」,而是「怎么让连接活到数据取完」。这篇就围绕Cursor+SqlSession搭一套可复制的骨架,把Transactional边界和资源关闭时机讲清楚,最后给出分页对比、内存观察、异常回滚三个验证动作。
适合谁看:正在做数据导出、批量对账、大表扫描、ETL 抽取的后端同学;对 MyBatis 有一定使用经验,但流式查询总是报错或不敢上生产的同学。
2. 前置准备:TaoToken 统一 Key 与 API 通道
在动手写代码前,先把辅助工具链的凭证管理理顺。我平时会用 AI 辅助生成 Mapper 骨架、排查报错堆栈、解释驱动行为,如果每个工具都单独配一套 Key,切换环境时很容易乱。TaoToken 的作用就是把这些 AI 能力的 Key 和 API 通道统一管理起来,官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api (这个地址不加 UTM 参数)。
具体操作上,你可以先在控制台创建一把 Key,然后按用途分流:日常问模型行为、解释Cursor报错,用模型对话通道;长期写代码、跑 Agent 辅助重构,用 Coding Plan;需要程序化调用就进 API Keys 页面拿 Key,再对照接入文档配置。这样做的实际好处是:MyBatis 项目里那些application.yml、mybatis-config.xml、测试脚本的配置项,可以集中在一处维护,不用在多个平台之间来回找。
需要提醒的是,TaoToken 在这里扮演的是「AI 辅助配置的统一入口」,不是数据库连接池,也不替代 MyBatis 本身。数据库连接、事务、Cursor 生命周期仍然由你的 Spring 容器和 MyBatis 管理,两者职责不要混。
3. 可复制配置:Mapper 接口与三种连接保持方案
3.1 Mapper 接口定义
先定义一个返回Cursor的 Mapper。注意返回类型写成Cursor<Foo>,MyBatis 就知道这是流式查询,不会走默认的List结果映射。
@Mapper public interface FooMapper { @Select("select * from foo limit #{limit}") Cursor<Foo> scan(@Param("limit") int limit); }Foo就是普通实体类,字段和表列对应即可。这一步没有坑,坑全在调用侧。
3.2 方案一:SqlSessionFactory 手工开连接
最直观的方案,用SqlSessionFactory打开一个SqlSession,它代表一个数据库连接,用try-with-resources保证最后关闭。
@Autowired private SqlSessionFactory sqlSessionFactory; @GetMapping("foo/scan/0/{limit}") public void scanFoo0(@PathVariable("limit") int limit) throws Exception { try (SqlSession sqlSession = sqlSessionFactory.openSession(); Cursor<Foo> cursor = sqlSession.getMapper(FooMapper.class).scan(limit)) { cursor.forEach(foo -> { // 逐条处理,比如写入文件或做聚合 }); } }关键点:Mapper 必须从sqlSession.getMapper()拿,不能注入FooMapper直接用,否则连接不受你控制。这个方案适合非 Spring 事务上下文、或者你想精确控制连接开关的场景。
3.3 方案二:TransactionTemplate 包住查询
如果项目已经在用 Spring 事务管理,用TransactionTemplate更自然,事务执行期间连接保持打开。
@Autowired private TransactionTemplate transactionTemplate; @GetMapping("foo/scan/2/{limit}") public void scanFoo2(@PathVariable("limit") int limit) { transactionTemplate.execute(status -> { try (Cursor<Foo> cursor = fooMapper.scan(limit)) { cursor.forEach(foo -> { // 处理逻辑 }); } catch (IOException e) { throw new RuntimeException(e); } return null; }); }这里的fooMapper可以直接注入,因为事务上下文已经持有连接。注意Cursor的close()会抛IOException,需要处理。
3.4 方案三:@Transactional 注解
最简洁,但坑也最隐蔽。
@GetMapping("foo/scan/3/{limit}") @Transactional public void scanFoo3(@PathVariable("limit") int limit) throws Exception { try (Cursor<Foo> cursor = fooMapper.scan(limit)) { cursor.forEach(foo -> { // 处理逻辑 }); } }@Transactional只在外部调用时生效。如果你在同一个类里this.scanFoo3()自调用,代理不生效,连接照样提前关闭,报错和没加注解一样。这是 Spring AOP 的经典问题,不是 MyBatis 的锅。
三种方案对照:
| 方案 | 连接来源 | 适用场景 | 主要风险 |
|---|---|---|---|
| SqlSessionFactory | 手工 openSession | 非事务上下文、精确控制 | 忘记关闭导致连接泄漏 |
| TransactionTemplate | Spring 事务 | 已有事务管理、编程式 | 异常未抛出导致误提交 |
| @Transactional | Spring 事务 | 简单场景、外部调用 | 自调用失效、长事务 |
3.5 事务边界与资源关闭时机
流式查询的事务边界要尽量小,但必须覆盖整个遍历过程。也就是说,cursor.forEach必须在事务内完成,不能把 Cursor 返回到事务外再遍历。资源关闭顺序是:先关 Cursor,再关 SqlSession 或结束事务。try-with-resources的声明顺序决定了关闭顺序,所以Cursor要写在SqlSession后面(后声明先关闭)。
另外,流式查询期间不要在这个连接上执行其他写操作,驱动通常不允许同一连接上未读完结果集时发起新查询,会报Streaming result set is still active之类的错误。
4. 验证请求与成功结果
配置写完,必须验证三件事:结果正确、内存可控、异常能回滚。
4.1 分页对比验证
先跑一个小limit,比如 1000,把流式结果和普通分页查询结果做对比,确认条数和内容一致。
@GetMapping("foo/verify/{limit}") @Transactional public Map<String, Object> verify(@PathVariable("limit") int limit) { List<Foo> streamed = new ArrayList<>(); try (Cursor<Foo> cursor = fooMapper.scan(limit)) { cursor.forEach(streamed::add); } List<Foo> paged = fooMapper.scanByPage(limit); // 普通 List 查询 Map<String, Object> result = new HashMap<>(); result.put("streamSize", streamed.size()); result.put("pageSize", paged.size()); result.put("equal", streamed.equals(paged)); return result; }请求GET /foo/verify/1000,期望返回streamSize=1000、pageSize=1000、equal=true。如果equal=false,先检查排序字段,流式和分页在无order by时顺序可能不同。
4.2 内存占用观察
把limit调到 100 万,用jconsole或jstat -gc <pid> 1000观察老年代增长。流式查询下,老年代应该基本平稳,只有单条对象和驱动缓冲;如果看到老年代持续上涨直到 OOM,说明结果集被全量物化了,检查是不是返回类型写成了List,或者驱动没开流式模式。
MySQL 驱动需要在 URL 上加参数才真正流式:
jdbc:mysql://localhost:3306/demo?useCursorFetch=true&defaultFetchSize=1000defaultFetchSize控制每次从服务器拉多少条,太小会增加往返次数,太大会增加内存,1000 到 5000 是常见区间。
4.3 异常回滚测试
在forEach里故意抛异常,验证事务回滚和连接释放。
@GetMapping("foo/rollback/{limit}") @Transactional public void rollbackTest(@PathVariable("limit") int limit) { try (Cursor<Foo> cursor = fooMapper.scan(limit)) { cursor.forEach(foo -> { if (foo.getId() == 500) { throw new IllegalStateException("模拟处理失败"); } }); } }请求后检查两点:一是异常正常抛出,二是连接池活跃连接数回到基线(用 Druid 或 HikariCP 的监控页面看)。如果连接数不降,说明 Cursor 或 SqlSession 没关干净。
5. 本篇常见错排查
5.1 A Cursor is already closed
最常见。根因是 Mapper 方法执行完连接就关了。排查顺序:先确认调用侧是否在事务内或手工SqlSession内;再确认@Transactional是否被自调用绕过;最后确认返回类型是不是Cursor而不是List。
5.2 Streaming result set is still active
在流式遍历未结束时,同一连接上又发起了新查询。检查forEach内部有没有调用其他 Mapper 方法。如果有,要么把数据先收集到内存再处理,要么换独立连接。
5.3 连接池耗尽
流式查询持有连接时间长,如果并发高,连接池很快被打满。对策:限制流式接口的并发数,给这类接口单独配一个小连接池,或者用信号量控制同时进行的流式查询数量。
5.4 事务超时
长事务会触发@Transactional(timeout=...)超时,或者数据库侧wait_timeout断开。大结果集遍历可能跑几分钟,建议给流式接口单独设置较长超时,并在业务上做分批提交,避免一个事务扛太久。
5.5 结果顺序不一致
无order by时,流式和分页的返回顺序可能不同,导致对比验证失败。验证脚本里加上稳定的排序字段。
6. 语义一致的 CTA 分流
按你当前卡住的环节选入口,不要只记首页。
排障和接入配置:先去 API Keys 页面拿 Key,再对照接入文档把application.yml里的通道配好,报错堆栈可以直接贴给模型对话通道让它解释驱动行为。
验证模型行为、确认Cursor报错含义:用模型对话通道,把异常信息和你的调用代码一起发过去,让它判断是事务边界问题还是驱动参数问题。
长期写代码、Agent 辅助重构 Mapper 和测试脚本:用 Coding Plan,把流式查询骨架作为上下文,让它帮你补全分页对比和回滚测试。
最后留一个我踩过的坑:try-with-resources里Cursor和SqlSession的声明顺序写反,会导致先关SqlSession再关Cursor,某些驱动下会抛异常。记住后声明的先关闭,Cursor写在后面。