拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MyBatis 流式查询接口实战:Cursor + SqlSession 配置与验证

MyBatis 流式查询接口实战:Cursor + SqlSession 配置与验证 1. 大结果集查询为什么需要 Cursor如果你写过select * from 大表然后直接返回ListT大概率遇到过两种情况一是 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」而是「怎么让连接活到数据取完」。这篇就围绕CursorSqlSession搭一套可复制的骨架把Transactional边界和资源关闭时机讲清楚最后给出分页对比、内存观察、异常回滚三个验证动作。适合谁看正在做数据导出、批量对账、大表扫描、ETL 抽取的后端同学对 MyBatis 有一定使用经验但流式查询总是报错或不敢上生产的同学。2. 前置准备TaoToken 统一 Key 与 API 通道在动手写代码前先把辅助工具链的凭证管理理顺。我平时会用 AI 辅助生成 Mapper 骨架、排查报错堆栈、解释驱动行为如果每个工具都单独配一套 Key切换环境时很容易乱。TaoToken 的作用就是把这些 AI 能力的 Key 和 API 通道统一管理起来官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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。注意返回类型写成CursorFooMyBatis 就知道这是流式查询不会走默认的List结果映射。Mapper public interface FooMapper { Select(select * from foo limit #{limit}) CursorFoo 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(); CursorFoo 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 (CursorFoo 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 (CursorFoo cursor fooMapper.scan(limit)) { cursor.forEach(foo - { // 处理逻辑 }); } }Transactional只在外部调用时生效。如果你在同一个类里this.scanFoo3()自调用代理不生效连接照样提前关闭报错和没加注解一样。这是 Spring AOP 的经典问题不是 MyBatis 的锅。三种方案对照方案连接来源适用场景主要风险SqlSessionFactory手工 openSession非事务上下文、精确控制忘记关闭导致连接泄漏TransactionTemplateSpring 事务已有事务管理、编程式异常未抛出导致误提交TransactionalSpring 事务简单场景、外部调用自调用失效、长事务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 MapString, Object verify(PathVariable(limit) int limit) { ListFoo streamed new ArrayList(); try (CursorFoo cursor fooMapper.scan(limit)) { cursor.forEach(streamed::add); } ListFoo paged fooMapper.scanByPage(limit); // 普通 List 查询 MapString, 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期望返回streamSize1000、pageSize1000、equaltrue。如果equalfalse先检查排序字段流式和分页在无order by时顺序可能不同。4.2 内存占用观察把limit调到 100 万用jconsole或jstat -gc pid 1000观察老年代增长。流式查询下老年代应该基本平稳只有单条对象和驱动缓冲如果看到老年代持续上涨直到 OOM说明结果集被全量物化了检查是不是返回类型写成了List或者驱动没开流式模式。MySQL 驱动需要在 URL 上加参数才真正流式jdbc:mysql://localhost:3306/demo?useCursorFetchtruedefaultFetchSize1000defaultFetchSize控制每次从服务器拉多少条太小会增加往返次数太大会增加内存1000 到 5000 是常见区间。4.3 异常回滚测试在forEach里故意抛异常验证事务回滚和连接释放。GetMapping(foo/rollback/{limit}) Transactional public void rollbackTest(PathVariable(limit) int limit) { try (CursorFoo 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写在后面。
返回列表