MyBatis 流式查询详解:ResultHandler 与 Cursor
在业务中,如果一次性查询出百万级数据并返回 List
,很容易造成 OOM 或 长时间 GC。
MyBatis 提供了 流式查询(Streaming Query) 能力,让我们可以边读边处理,极大降低内存压力。
1. 什么是流式查询?
普通查询:一次性将全部结果加载到内存,然后再处理。
流式查询:数据库返回一个游标(Cursor),应用端一批一批地从游标读取数据,边读边处理,避免占用大量内存。
适用场景
- 导出大批量数据(CSV、Excel)
- 批量处理(数据同步、数据迁移)
- 实时计算
2. MyBatis 流式查询的两种实现方式
2.1 使用 ResultHandler
ResultHandler 是 MyBatis 提供的经典方式,查询结果不会一次性放到内存,而是每读取一条就调用一次回调方法。
不带参数示例
@Mapper
public interface UserMapper {@Select("SELECT id, name, age FROM user")void scanAllUsers(ResultHandler<User> handler);
}
调用:
@Autowired
private UserMapper userMapper;public void processUsersNoParam() {userMapper.scanAllUsers(ctx -> {User user = ctx.getResultObject();System.out.println(user);});
}
带参数示例
@Mapper
public interface UserMapper {@Select("SELECT id, name, age FROM user WHERE age > #{age}")void scanUsersByAge(@Param("age") int age, ResultHandler<User> handler);
}
调用:
public void processUsersWithParam(int minAge) {userMapper.scanUsersByAge(minAge, ctx -> {User user = ctx.getResultObject();System.out.println(user);});
}
特点
- 边查边处理,不占用过多内存
- 处理逻辑和查询绑定在一起
- 适合流式消费(文件写入、推送消息)
- 如果收集成 List,内存压力和普通查询差不多
2.2 使用 Cursor(推荐 MyBatis 3.4+)
Cursor 提供了更接近 JDBC ResultSet 的方式,支持 Iterable
迭代。
不带参数示例
@Mapper
public interface UserMapper {@Select("SELECT id, name, age FROM user")@Options(fetchSize = Integer.MIN_VALUE) // MySQL 开启流式Cursor<User> scanAllUsers();
}
调用:
@Transactional
@Transactional
public void getUsersAsList() throws IOException {try (Cursor<User> cursor = userMapper.scanAllUsers()) {for (User user : cursor) {System.out.println(user);}}
}
带参数示例
@Mapper
public interface UserMapper {@Select("SELECT id, name, age FROM user WHERE age > #{age}")@Options(fetchSize = Integer.MIN_VALUE)Cursor<User> scanUsersByAge(@Param("age") int age);
}
调用:
@Transactional
@Transactional
public void getUsersByAge(int minAge) throws IOException {try (Cursor<User> cursor = userMapper.scanUsersByAge(minAge)) {for (User user : cursor) {System.out.println(user);}}
}
3. Cursor 踩坑:A Cursor is already closed
很多人在用 Cursor 时会遇到:
A Cursor is already closed.
原因
- Cursor 是延迟加载的,必须在 同一个 SqlSession 存活期间 迭代
- 如果你在 mapper 方法中返回 Cursor,却在外部再去遍历,此时 SqlSession 已经被 MyBatis 关闭,Cursor 自然不可用
错误示例
Cursor<User> cursor = userMapper.scanAllUsers(); // 此时 SQLSession 会在方法返回后关闭
for (User user : cursor) { // 这里会报错...
}
解决办法
- 在同一个方法中迭代,不要把 Cursor 返回到方法外
- 加 @Transactional 保证 SqlSession 在方法执行期间不关闭
- 用 try-with-resources 及时关闭 Cursor
正确示例
@Transactional
public void processCursor() {try (Cursor<User> cursor = userMapper.scanAllUsers()) {for (User user : cursor) {// 处理数据}} catch (IOException e) {throw new RuntimeException(e);}
}
4. 注意事项
- MySQL 必须设置
@Options(fetchSize = Integer.MIN_VALUE)
才能真正流式 - 事务控制:Cursor 必须在事务或 SqlSession 存活期间消费
- 大事务风险:流式处理可能导致事务时间长,要权衡
- 网络延迟:流式每次批量取数,可能比一次性查询多几毫秒,但内存安全
- 收集成 List 慎用:这样会失去流式查询的内存优势
5. 区别
ResultHandler(回调模式):
- 基于观察者模式/回调模式
- MyBatis 主动推送数据给你的处理器
- 你提供一个处理函数,MyBatis 逐条调用
Cursor(迭代器模式):
- 基于迭代器模式
- 你主动从 Cursor 中拉取数据
- 更符合 Java 集合框架的使用习惯
ResultHandler 更适合:
- 简单的逐条处理场景
- 不需要复杂控制流程的情况
- 希望 MyBatis 完全管理资源的场景
Cursor 更适合:
- 需要复杂处理逻辑的场景
- 需要灵活控制处理流程
- 习惯使用 Java 8 Stream API 的开发者
- 需要与现有迭代处理代码集成
选择 ResultHandler 当:
- 处理逻辑简单直接
- 不需要复杂的流程控制
- 希望代码更紧凑
- 不希望手动管理资源
选择 Cursor 当:
- 需要灵活的流程控制
- 处理逻辑复杂,需要分步骤
- 团队熟悉迭代器模式
- 需要与其他基于迭代器的代码集成
- 希望有更好的异常处理控制
6. 总结
-
ResultHandler:更灵活,回调式消费,适合不需要一次性得到全部结果
-
Cursor:可迭代,语法直观,但必须在 SqlSession 存活期间消费,否则就会遇到
A Cursor is already closed
-
带参数查询:ResultHandler 和 Cursor 都支持,只需在 mapper 方法加参数
-
实战建议:
- 大批量导出、批量同步 → Cursor
- 条件过滤、部分收集 → ResultHandler
- 不需要流式直接用普通 List 查询即可