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=truefetchSize 设为 Integer.MIN_VALUE 开启逐行流式
PostgreSQL 关闭自动提交 autoCommit=falsefetchSize 设正数(如 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,用完一定要关闭,遍历期间别乱改。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐