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 查詢即可