news 2026/9/28 4:32:44

MyBatis Cursor 深度解析:流式查询原理与实战配置全攻略

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis Cursor 深度解析:流式查询原理与实战配置全攻略

1. 百万级导出把 JVM 撑爆的那次经历

先说结论:MyBatis Cursor 是 MyBatis 3.4.0 引入的流式查询接口,它继承Iterable<T>和Closeable,核心能力是让查询结果不再一次性灌进 JVM 堆内存,而是由 JDBC 驱动在数据库端维持一个游标,应用侧调用next()时才把当前行(或一小批)拉过来。它适合谁?适合做大数据量导出、全表遍历、离线对账、数据迁移这类"结果集可能有几十万到上亿行"的场景。如果你平时查的都是几千条以内的列表页,那 List 一把梭就够了,硬上 Cursor 只会徒增复杂度。

我试过用普通List<User>去接一张 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> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>

Spring 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/console,API Key 在https://taotoken.net/api-keys生成。如果你要长期做编码和 Agent 类工作,可以看下 Coding Plan:https://taotoken.net/coding-plan。文档在https://taotoken.net/doc,Claude Code 相关的接入说明在https://taotoken.net/ClaudeCodeAnthropic。官网入口是https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。

注意:TaoToken 在这里的角色是辅助你排查 SQL 与异常、生成配置骨架,不是替代你的数据库或编辑器。生产库的连接信息不要贴给任何外部服务。

3. 可复制的 Cursor 配置骨架

3.1 Mapper 接口与注解写法

最简形态就是在 Mapper 方法上把返回类型从List<User>换成Cursor<User>,并用@Options指定 fetchSize。

public interface UserMapper { @Select("select id, name, email, age from users order by id") @Options(fetchSize = Integer.MIN_VALUE) Cursor<User> scanAllUsers(); @Select("select id, name, email, age from users where age > #{minAge} order by id") @Options(fetchSize = 1000) Cursor<User> scanByAge(@Param("minAge") int minAge); }

fetchSize = Integer.MIN_VALUE是 MySQL 驱动的一个特殊约定,表示开启逐行流式读取。但逐行取意味着网络往返次数极多,吞吐会明显下降。工程上我更推荐fetchSize = 1000,让驱动每次批量拉 1000 行到本地缓存,应用再逐条消费,兼顾内存和效率。

3.2 XML 映射写法

如果你用的是 XML,配置等价:

<select id="scanAllUsers" resultType="com.example.domain.User" fetchSize="1000"> select id, name, email, age from users order by id </select>

3.3 事务边界:这是最容易翻车的地方

Cursor 必须在 SqlSession 的生命周期内消费完。一旦 SqlSession 关闭,底层 ResultSet 和 Connection 就没了,游标自然失效。所以正确姿势是用 try-with-resources 把 SqlSession 和 Cursor 都包住,且遍历逻辑写在最内层。

try (SqlSession sqlSession = sqlSessionFactory.openSession()) { UserMapper mapper = sqlSession.getMapper(UserMapper.class); try (Cursor<User> cursor = mapper.scanAllUsers()) { for (User user : cursor) { processUser(user); } } }

这里有个细节:openSession()默认autoCommit=false,事务不会自动提交。对于只读的流式查询,这没问题,但要注意别在遍历过程中做写操作,否则容易和游标持有的连接产生锁竞争。

3.4 数据库侧的关键配置

不同数据库对游标流式的支持方式不一样,配置错了 fetchSize 就是摆设。

数据库关键配置说明
MySQLURL 加useCursorFetch=true,fetchSize 设正数或Integer.MIN_VALUE不加这个参数,驱动会忽略 fetchSize,仍然全量拉取
PostgreSQLautoCommit=false,fetchSize 设正数(如 1000)PG 的游标必须在事务内才生效
Oracle直接设 fetchSize 即可默认就是游标模式

MySQL 的 JDBC URL 示例:

spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useCursorFetch=true&useSSL=false&serverTimezone=Asia/Shanghai

注意:useCursorFetch=true是 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 (Cursor<User> 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 + fetchSize=1000,全程稳定在 100MB 左右,处理到第 100 万、200 万行时内存几乎不涨。这就是流式的价值——内存占用和数据总量解耦。

4.2 首条数据响应时间

另一个直观指标是首条数据返回时间。List 模式要等全部结果集传输完才能开始处理,百万级数据可能要几十秒;Cursor 模式第一条通常在几十毫秒内就返回了,用户能立刻看到反馈。做导出进度条的时候这个差异特别明显。

4.3 用 Stream 做流式处理

Cursor 实现了Iterable,可以直接转成 Stream:

try (Cursor<User> 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 有没有加useCursorFetch=true。其次确认 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 别忘了useCursorFetch=true。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 4:31:52

如何卸载 OpenClaw:从配置文件到残留清理的完整卸载指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 4:29:34

Cursor 与 CursorAdapter 的观察者模式:从源码到可复用配置骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华