在业务中,如果一次性查询出百万级数据并返回 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 查询即可