MyBatis Cursor 深度解析:原理、特性与实战全攻略
MyBatis Cursor 深度解析:原理、特性与实战全攻略
前言
在 MyBatis 开发中,我们习惯了用 List<T> 接收查询结果。但当数据量达到百万级时,这种“一把梭”的方式会瞬间撑爆 JVM 内存,导致 OutOfMemoryError。这时,Cursor 就成了救命稻草。
本文将深入剖析 Cursor 的底层原理、快照特性、性能对比以及踩坑指南,帮你彻底掌握这个流式查询利器。
一、什么是 Cursor?
Cursor 是 MyBatis 3.4.0 版本引入的流式查询接口。它继承自 Iterable<T> 和 Closeable,核心思想是:不一次性加载所有数据到内存,而是用一条取一条,用完一条丢一条。
@Select("select * from users order by id")
Cursor<User> scanAllUsers();
二、正确使用姿势
基本用法
必须用 try-with-resources 包裹,否则连接泄漏是大坑:
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
try (Cursor<User> cursor = mapper.scanAllUsers()) {
for (User user : cursor) {
// 逐条处理,内存始终只有当前一条记录
processUser(user);
}
}
}
Java 8 Stream 流式处理
try (Cursor<User> cursor = mapper.scanAllUsers()) {
List<String> names = cursor.stream()
.filter(u -> u.getAge() > 18)
.map(User::getName)
.collect(Collectors.toList());
}
⚠️ 注意:
collect会把结果再汇集到 List,量大仍需斟酌。
不同数据库的配置
| 数据库 | 关键配置 |
|---|---|
| MySQL | URL 加 ?useCursorFetch=true,fetchSize 设为 Integer.MIN_VALUE 开启逐行流式 |
| PostgreSQL | 关闭自动提交 autoCommit=false,fetchSize 设正数(如 1000) |
| Oracle | 默认游标模式,直接设 fetchSize 即可 |
三、底层原理深度拆解
很多同学误以为 Cursor 把数据库端的查询也变成了“逐条查”。其实不然。我们分阶段来看:
阶段一:数据库端 —— 完整执行
当 select * from users 执行时,数据库照样会扫描全表,在数据库服务器内存或临时磁盘空间中,生成包含所有匹配行的完整结果集。这一步,该做的工作一样没少。
阶段二:数据传输 —— 核心区别
-
普通 List 模式:JDBC 驱动一把梭,把结果集全部拉到 JVM 堆内存,变成
List<User>。100 万条数据,内存瞬间爆炸。 -
Cursor 流式模式:JDBC 驱动在数据库端建立游标,相当于只拿到了一根水管。只有你调用
next()时,才从数据库那端“抽”一条(或一小批)过来。应用内存永远只驻留当前这一条。
一句话总结:数据库全量查询没有变,变的是数据从数据库到应用的传输方式——从洪水漫灌变成了滴灌。
四、Cursor 是快照吗?遍历期间数据变了怎么办?
这是一个高频问题。答案是:你遍历的数据是事务启动时的快照,绝对稳定,不会受其他并发操作影响。
为什么?
以 MySQL InnoDB + REPEATABLE-READ 隔离级别为例:
- 事务开启时会建立一个一致性快照。
Cursor迭代的就是这个快照。- 其他连接即便修改/删除了数据并提交,你的快照通过 Undo Log 依旧保留着旧版本。
并发操作影响一览
| 别人做了什么 | 你的 Cursor 受影响吗? | 原因 |
|---|---|---|
| 修改了你未遍历的某行 | ❌ 无影响 | 快照保留旧值 |
| 删除了你未遍历的某行 | ❌ 无影响 | 快照中该行依然存在 |
| 新插入一行符合条件的数据 | ❌ 无影响 | 快照中没有这行,无幻读 |
| 你自己在遍历中修改 | ⚠️ 行为确定但不推荐 | 相当于遍历时修改集合,容易死锁或报错 |
结论:只要事务一开,你的数据集边界和内容就固化了,外部风吹草动与你无关。
五、Cursor 真的更快吗?
单比“全部跑完的总时间”,Cursor 甚至更慢。 因为逐条(或逐批)拉取有网络往返开销。
但它赢在三个关键指标:
1. 启动时间(首条数据响应)
- List:等 100 万条全传到内存,30 秒后才开始处理第一条。
- Cursor:< 50ms 就返回第一条,用户立即看到反馈。
2. 内存占用
- List:堆内存 4GB 起步,随时崩。
- Cursor:恒定 100MB,稳如老狗。
3. 处理能力上限
- List:数据量不可超出可用内存,10 亿条直接 OOM。
- Cursor:只要时间够,多少条都能跑到底。
最佳实践:折中配置 fetchSize
既想快又不想崩,就别设 Integer.MIN_VALUE 逐行取。设 fetchSize=1000,让 JDBC 驱动每次批量取 1000 条到本地缓存,应用再逐条消费。这样兼顾了批量传输的效率和内存安全。
六、踩坑避雷指南
| 雷区 | 后果 | 正解 |
|---|---|---|
| 未关闭 Cursor | 数据库连接泄漏,连接池耗尽 | 必须 try-with-resources |
| SqlSession 提前关闭 | 遍历时抛 Connection closed |
整个遍历过程必须在 SqlSession 生命周期内 |
| 重复遍历同一个 Cursor | 第二次无数据或报错 | Cursor 只能消费一次,用完即废 |
| 长时间遍历不提交 | 数据库游标和连接长时间占用 | 尽量缩短单条处理时间,避免在循环里做耗时 RPC |
| 小数据量也用 Cursor | 代码复杂度增加,无收益 | 几千条数据直接用 List |
七、完整实战示例
@Service
public class UserExportService {
@Autowired
private SqlSessionFactory sqlSessionFactory;
public void exportLargeUsers(OutputStream outputStream) {
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
try (Cursor<User> cursor = mapper.scanAllUsers();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream))) {
for (User user : cursor) {
writer.write(convertToCsvLine(user));
writer.newLine();
}
writer.flush();
}
} catch (IOException e) {
throw new RuntimeException("导出失败", e);
}
}
private String convertToCsvLine(User user) {
return user.getId() + "," + user.getName() + "," + user.getEmail();
}
}
配合 MySQL 的 JDBC 配置:
spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useCursorFetch=true
Mapper 语句设置 fetchSize:
@Select("select * from users order by id")
@Options(fetchSize = Integer.MIN_VALUE)
Cursor<User> scanAllUsers();
八、总结
| 场景 | 推荐方案 |
|---|---|
| 数据量 < 几千条 | List 一把梭,简单直接 |
| 数据量几十万~几百万 | Cursor + fetchSize=1000 |
| 数据量千万~亿级 | Cursor + 读写分离 + 异步导出 |
| 需立即响应首条数据 | Cursor,比 List 快 3 个数量级 |
**一句话口诀:小数据用 List,大数据用 Cursor,用完一定要关闭,遍历期间别乱改。
更多推荐


所有评论(0)