MyBatis流式查询(Cursor)实战:解决大数据量查询OOM问题

📅 2026/7/4 4:30:13 👁️ 阅读次数 📝 编程学习
MyBatis流式查询(Cursor)实战:解决大数据量查询OOM问题

你有没有遇到过这样的场景:一个看似简单的查询,数据量稍微大一点,应用就突然卡死,然后监控告警疯狂提示“内存溢出(OOM)”。你检查代码,发现就是一行普通的List<User> users = userMapper.selectList(queryWrapper);,逻辑清晰,毫无破绽。但正是这“一行代码”,在数据量达到几十万甚至上百万时,会瞬间将 JVM 堆内存撑爆,导致服务不可用。

这不是危言耸听,而是后端开发中一个非常典型且隐蔽的“性能陷阱”。问题的根源在于,传统的数据库查询方式,会一次性将所有结果集加载到应用内存中。当数据量远超内存容量时,OOM 就成了必然结局。今天,我们就来彻底解决这个问题,反手祭出MyBatis 流式查询(Cursor)这把利器,让你在处理海量数据时,既能保证功能,又能稳住内存。

本文将不仅仅告诉你“怎么用”,更会深入剖析“为什么需要”、“背后的原理是什么”以及“有哪些坑必须避开”。我们会从一次真实的 OOM 事故复盘开始,逐步拆解流式查询的核心机制,并提供从 Spring Boot 集成到生产环境最佳实践的完整指南。无论你是正在被大数据量查询困扰,还是想提前储备高并发场景下的优化方案,这篇文章都将为你提供可直接落地的解决方案。

1. 为什么一行普通的查询代码会成为“内存杀手”?

在深入技术细节之前,我们首先要建立一个清晰的认知:OOM 往往不是由复杂的业务逻辑引起的,而是由对数据规模的无意识假设导致的。

想象一下这个场景:你需要导出一份所有用户的注册信息报表。代码可能长这样:

// UserMapper.java List<User> selectAllUsers(); // Service 层 public List<User> exportAllUsers() { return userMapper.selectAllUsers(); // 危险操作! }

或者使用 MyBatis-Plus:

// Service 层 public List<User> exportAllUsers() { QueryWrapper<User> wrapper = new QueryWrapper<>(); return userMapper.selectList(wrapper); // 同样危险! }

这行代码背后发生了什么?

  1. MyBatis 执行 SQLSELECT * FROM user
  2. 数据库服务器准备好所有匹配的数据(比如 100 万行)。
  3. JDBC 驱动尝试将这 100 万行数据,通过一次网络传输,全部拉取到应用程序的 JVM 内存中。
  4. MyBatis 将这 100 万行数据,每一行都实例化成一个User对象。
  5. 最终,一个包含 100 万个User对象的List被返回。

内存估算:假设一个User对象有 10 个字段,平均每个字段占用 50 字节,那么一个对象约 500 字节。100 万个对象就是500 MB。这还不算List容器本身的开销、数据库ResultSet的临时缓存等。对于默认堆内存可能只有 1GB 或更少的应用来说,这 500MB 的瞬间压力极易触发 Full GC,甚至直接导致 OOM。

更糟糕的是连锁反应:

  • 数据库连接被长时间占用:在数据传输完成前,这个连接无法被释放回连接池。
  • 网络带宽被打满:一次性传输海量数据。
  • 应用无响应:GC 线程疯狂工作,业务线程停顿,导致接口超时、服务雪崩。

所以,这“一行代码”的问题,本质是数据处理模式与数据规模不匹配。我们需要将“一次性装载”模式,转变为“按需流动”模式,这就是流式查询(Streaming Query)的核心思想。

2. 流式查询(Cursor) vs 传统查询:核心原理与差异

流式查询,顾名思义,就是让数据像水流一样,一部分一部分地处理,而不是一次性筑坝蓄满整个水库。

2.1 传统查询的工作原理(Fetch All)

graph TD A[应用程序执行查询] --> B[发送SQL到数据库]; B --> C[数据库执行查询<br>并生成完整结果集]; C --> D[JDBC驱动一次性<br>拉取所有结果到内存]; D --> E[MyBatis映射所有结果<br>为对象List]; E --> F[返回巨大的List对象];

特点:简单直观,适用于数据量小的场景。所有操作在内存中完成,速度快,但内存压力与数据量成正比。

2.2 流式查询(Cursor)的工作原理(Fetch Incrementally)

graph TD A[应用程序执行流式查询] --> B[发送SQL到数据库]; B --> C[数据库执行查询<br>并准备结果集游标]; C --> D[建立流式通道]; D --> E{是否调用 cursor.next?}; E -- 是 --> F[JDBC驱动从数据库<br>传输单行/少量数据]; F --> G[MyBatis映射单行数据为对象]; G --> H[应用程序处理当前对象]; H --> E; E -- 否/结束 --> I[关闭游标与连接];

特点

  • 内存友好:同一时间,只有少量数据(一行或一个批次)驻留在 JVM 内存中。
  • 连接占用数据库连接在遍历期间必须保持打开状态,这是实现流式传输的前提,也是一个需要重点管理的风险点。
  • 延迟处理:数据是“用的时候才取”,适合结合业务逻辑逐条处理,如数据转换、写入文件、发送消息等。

2.3 关键对比表格

特性传统查询 (List<T>)流式查询 (Cursor<T>)
内存占用高,与结果集大小正相关低,仅缓存少量行
数据库连接查询执行完毕立即释放必须保持打开直到遍历完成
适用场景数据量小(千/万级),需要随机访问数据量大(十万/百万级),顺序处理
网络传输一次性大批量多次小批量,可缓解瞬时压力
代码复杂度相对较高,需注意资源关闭
能否分页可结合LIMIT实现本身就是一种“逻辑分页”,但不能LIMIT OFFSET(会破坏流式)

核心结论:流式查询不是用来替代传统查询的,它是应对大数据量顺序处理这一特定场景的专用工具。滥用流式查询(例如处理小数据)会带来不必要的连接占用和复杂度。

3. 环境准备与前置条件

在开始编写代码之前,请确保你的开发环境满足以下要求。我们将以最常用的 Spring Boot + MyBatis 组合为例。

  1. JDK 版本:1.8 或更高版本(推荐 JDK 11+)。流式查询依赖的 JDBC 特性在主流版本中均已支持。
  2. 构建工具:Maven 或 Gradle。
  3. 数据库MySQL是本文的主要示例。请注意,不同数据库(如 PostgreSQL、Oracle)对游标和流式结果集的支持方式和驱动配置可能不同,请查阅对应数据库的 JDBC 驱动文档。
  4. 核心依赖
    • Spring Boot: 2.x 或 3.x
    • MyBatis Spring Boot Starter: 与 Spring Boot 版本对应
    • MySQL Connector/J: 驱动版本建议 8.0+

以下是 Mavenpom.xml的关键依赖配置:

<!-- pom.xml --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <!-- 或 3.1.x --> </parent> <dependencies> <!-- Spring Boot Web (根据项目需要) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 整合 Spring Boot --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> <!-- 对应 Spring Boot 2.7.x --> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> <version>8.0.33</version> </dependency> <!-- Lombok (可选,简化代码) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

重要提醒:使用 MySQL 进行流式查询,必须在 JDBC 连接 URL 中配置一个关键参数,否则驱动默认行为仍是 Fetch All。

# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/your_database?useSSL=false&serverTimezone=UTC&useCursorFetch=true # 关键参数 username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver

useCursorFetch=true:这个参数告诉 MySQL JDBC 驱动使用服务端游标(Server-side Cursor)来逐行获取结果,这是实现高效流式查询的基础。没有它,即使使用Cursor接口,驱动也可能在底层进行预取。

4. 核心流程拆解:从 Mapper 定义到资源关闭

实现一个健壮的流式查询,需要遵循一个清晰的流程。下图概括了从定义到安全关闭的完整生命周期:

flowchart TD A[定义返回Cursor的Mapper方法] --> B[在Service中获取Cursor对象]; B --> C[使用try-with-resources包裹]; C --> D[遍历Cursor处理数据]; D --> E{遍历完成或发生异常?}; E -- 完成 --> F[try块结束自动关闭Cursor与连接]; E -- 发生异常 --> G[异常抛出<br>但资源仍被try-with-resources自动关闭]; F --> H[结束]; G --> H;

下面,我们分步详解每个环节。

4.1 第一步:定义 Mapper 接口方法

MyBatis 的流式查询通过org.apache.ibatis.cursor.Cursor<T>接口作为返回值。这与返回List<T>有本质区别。

// UserMapper.java import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.annotations.Mapper; @Mapper public interface UserMapper { /** * 流式查询所有用户 * @return 用户游标 */ Cursor<User> selectAllUsersByCursor(); /** * 带条件的流式查询 * @param status 用户状态 * @return 用户游标 */ Cursor<User> selectUsersByStatus(@Param("status") Integer status); }

关键点

  • 方法返回值必须是Cursor<T>
  • 方法本身可以接受参数,用于构造带条件的查询。

4.2 第二步:编写对应的 Mapper XML

在 XML 映射文件中,SQL 的写法与普通查询完全一样。MyBatis 会根据方法返回值类型自动选择执行模式。

<!-- UserMapper.xml --> <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <select id="selectAllUsersByCursor" resultType="com.example.demo.entity.User"> SELECT id, name, email, create_time FROM user <!-- 这里可以排序,但避免使用会导致全表扫描的复杂条件 --> ORDER BY id ASC </select> <select id="selectUsersByStatus" resultType="com.example.demo.entity.User"> SELECT id, name, email, create_time FROM user WHERE status = #{status} ORDER BY id ASC </select> </mapper>

SQL 编写建议

  • 务必使用ORDER BY:流式处理通常是顺序的,一个明确的排序能保证结果顺序一致,也利于某些数据库优化。
  • 避免SELECT *:只查询需要的字段,减少网络传输和对象封装开销。
  • WHERE 条件要高效:尽量使用索引覆盖的条件,避免流式查询变成流式全表扫描,那样数据库压力依然很大。

4.3 第三步:在 Service 层使用 Try-With-Resources 遍历

这是最核心、最容易出错的一步Cursor实现了Closeable接口,必须确保在任何情况下(正常结束或异常)都能被关闭,以释放底层数据库连接。

错误示范(会导致连接泄漏):

public void processUsersWrong() { Cursor<User> cursor = userMapper.selectAllUsersByCursor(); for (User user : cursor) { // 如果在这里发生异常,cursor不会关闭! // 处理用户 System.out.println(user.getName()); } // 忘记调用 cursor.close(); }

正确示范(使用 try-with-resources):

import org.apache.ibatis.cursor.Cursor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper = userMapper; } /** * 使用流式查询处理大量用户数据 */ @Transactional // 事务注解很重要,见下文分析 public void processLargeUserData() { // 关键:使用 try-with-resources 语句,确保 Cursor 自动关闭 try (Cursor<User> cursor = userMapper.selectAllUsersByCursor()) { for (User user : cursor) { // 在这里处理每一条数据 // 例如:写入文件、发送消息、计算统计等 doSomeBusiness(user); } } // 无论是否发生异常,try块结束后都会自动调用 cursor.close() // 游标关闭后,数据库连接才会被释放回连接池 } private void doSomeBusiness(User user) { // 模拟业务处理 System.out.println("Processing user: " + user.getId() + " - " + user.getName()); // 这里可以是复杂的业务逻辑 } }

代码解读

  1. try (Cursor<User> cursor = ...):这是 Java 7 引入的 try-with-resources 语法,用于自动管理资源(实现了AutoCloseable接口)。在try块结束时(无论正常还是异常),cursor.close()会被自动调用。
  2. for (User user : cursor):使用增强 for 循环遍历Cursor,语法简洁。底层是调用cursor.hasNext()cursor.next()
  3. @Transactional:这个注解至关重要。流式查询需要在同一个数据库连接和事务中完成遍历。如果没有事务,MyBatis 可能在每次cursor.next()时都尝试从连接池获取新连接,这会导致错误或性能问题。@Transactional确保了整个方法在一个连接和一个事务中执行。

5. 完整示例与代码实现:一个数据导出的实战案例

让我们通过一个完整的、可运行的 Spring Boot 示例,将上述流程串联起来。场景是:将百万级用户数据导出为 CSV 文件,这是流式查询的经典应用。

5.1 项目结构与实体类

src/main/java/com/example/demo/ ├── DemoApplication.java ├── entity/ │ └── User.java ├── mapper/ │ ├── UserMapper.java │ └── UserMapper.xml ├── service/ │ └── UserExportService.java └── controller/ └── ExportController.java

实体类User.java:

package com.example.demo.entity; import lombok.Data; import java.time.LocalDateTime; @Data public class User { private Long id; private String name; private String email; private Integer status; private LocalDateTime createTime; }

5.2 Mapper 接口与 XML

UserMapper.java:

package com.example.demo.mapper; import com.example.demo.entity.User; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.cursor.Cursor; @Mapper public interface UserMapper { /** * 流式查询用户(用于导出) * @return 用户游标 */ Cursor<User> selectUsersForExport(); }

UserMapper.xml(放在resources/mapper/目录下):

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <select id="selectUsersForExport" resultType="com.example.demo.entity.User"> SELECT id, name, email, status, create_time FROM user WHERE status = 1 -- 只导出有效用户 ORDER BY id ASC -- 必须排序,保证导出顺序 <!-- 注意:这里绝对不能使用 LIMIT!流式查询是逐行获取,不是分页查询 --> </select> </mapper>

5.3 核心服务层:流式查询与文件写入

UserExportService.java:

package com.example.demo.service; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import lombok.extern.slf4j.Slf4j; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVPrinter; import org.apache.ibatis.cursor.Cursor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.io.FileWriter; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; @Slf4j @Service public class UserExportService { private final UserMapper userMapper; public UserExportService(UserMapper userMapper) { this.userMapper = userMapper; } /** * 使用流式查询导出用户数据到CSV文件 * @param filePath 导出的文件路径 * @return 导出的记录条数 */ @Transactional(readOnly = true) // 只读事务,对于导出场景更合适 public long exportUsersToCsv(String filePath) { long count = 0; Path outputPath = Paths.get(filePath); // 使用 try-with-resources 管理 CSV 写入器和 Cursor 两个资源 try (FileWriter fileWriter = new FileWriter(outputPath.toFile()); CSVPrinter csvPrinter = new CSVPrinter(fileWriter, CSVFormat.DEFAULT .withHeader("ID", "Name", "Email", "Status", "CreateTime")); Cursor<User> cursor = userMapper.selectUsersForExport()) { // 关键点:获取游标 log.info("开始流式导出用户数据..."); for (User user : cursor) { // 将对象转换为CSV行 csvPrinter.printRecord( user.getId(), user.getName(), user.getEmail(), user.getStatus(), user.getCreateTime() ); count++; // 可选:每处理一定数量记录,刷新一次文件缓冲区,避免内存堆积 if (count % 10000 == 0) { csvPrinter.flush(); log.debug("已处理 {} 条记录", count); } } csvPrinter.flush(); // 最后刷新一次 log.info("流式导出完成,共处理 {} 条记录,文件已保存至: {}", count, outputPath.toAbsolutePath()); } catch (IOException e) { log.error("写入CSV文件失败", e); throw new RuntimeException("导出文件失败", e); } // Cursor 和 CSVPrinter 都会在这里自动关闭 return count; } }

代码精讲

  1. 多层资源管理:我们同时管理了CSVPrinterFileWriterCursor三个需要关闭的资源。将它们全部放在try-with-resources的括号内,是确保在任何情况下都能正确释放资源的最佳实践。
  2. @Transactional(readOnly = true):导出操作不需要修改数据,使用只读事务可以提高数据库性能,也明确了操作意图。
  3. 分批刷新if (count % 10000 == 0) { csvPrinter.flush(); }这是一个重要的优化。虽然流式查询控制了 JVM 内存中的用户对象数量,但写入文件时,数据会先进入系统的 I/O 缓冲区。定期刷新缓冲区可以防止它无限制增长,尤其是在处理速度(数据库流式读取)和写入速度(磁盘 I/O)不匹配时。
  4. 日志记录:在处理大量数据时,适当的日志(如每处理 10000 条记录打印一次)有助于监控进度和性能。

5.4 提供一个简单的 HTTP 端点触发导出

ExportController.java:

package com.example.demo.controller; import com.example.demo.service.UserExportService; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @Slf4j @RestController @RequestMapping("/api/export") public class ExportController { private final UserExportService userExportService; public ExportController(UserExportService userExportService) { this.userExportService = userExportService; } @GetMapping("/users/csv") public String exportUsersToCsv(@RequestParam(value = "path", required = false) String customPath) { String filePath; if (customPath != null && !customPath.trim().isEmpty()) { filePath = customPath; } else { // 生成默认路径,包含时间戳 String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); filePath = "export_users_" + timestamp + ".csv"; } log.info("接收到导出请求,目标文件: {}", filePath); long startTime = System.currentTimeMillis(); try { long count = userExportService.exportUsersToCsv(filePath); long costTime = System.currentTimeMillis() - startTime; return String.format("导出成功!共导出 %d 条记录,耗时 %.2f 秒,文件位置: %s", count, costTime / 1000.0, filePath); } catch (Exception e) { log.error("导出过程发生异常", e); return "导出失败: " + e.getMessage(); } } }

6. 运行结果与效果验证

6.1 启动应用并测试

  1. 确保你的 MySQL 数据库中有user表,并包含足够多的测试数据(可以使用脚本生成百万条测试数据)。
  2. 启动 Spring Boot 应用。
  3. 访问http://localhost:8080/api/export/users/csv
  4. 观察控制台日志,你会看到类似以下的输出:
    ... 开始流式导出用户数据... ... 已处理 10000 条记录 ... 已处理 20000 条记录 ... 流式导出完成,共处理 1000000 条记录,文件已保存至: /your_path/export_users_20231026_143022.csv
  5. 检查生成的 CSV 文件,确认数据完整。

6.2 关键效果验证:内存监控

这是证明流式查询价值的关键。你可以使用 JConsole、VisualVM 或 Arthas 等工具监控 JVM 堆内存。

  • 传统方式(返回 List):在导出过程中,你会看到堆内存使用量急剧上升,形成一个明显的“高峰”,直到所有数据被加载完毕并开始 GC。峰值内存可能接近或超过数据总大小,极易触发 OOM。
  • 流式方式(使用 Cursor):堆内存使用量会保持在一个相对稳定、较低的水平,仅随着当前处理批次的数据量有小幅波动。整个导出过程中,内存曲线平稳,没有明显的高峰。

如何验证连接释放?在导出完成后,观察数据库连接池(如 HikariCP)的活跃连接数。如果代码正确使用了try-with-resources@Transactional,你会看到连接在执行完毕后被顺利归还,活跃连接数恢复正常。如果连接数持续增长,则说明存在连接泄漏,需要检查资源是否未正确关闭。

7. 常见问题与排查思路

流式查询虽然强大,但使用不当会引入新问题。下表总结了最常见的问题及其解决方法:

问题现象可能原因排查方式解决方案
遍历时抛出Connection is closed异常1. 未使用@Transactional,导致 MyBatis 在遍历过程中关闭了连接。
2. 在遍历Cursor时,在另一个线程中操作了同一事务下的其他数据库方法,意外提交或关闭了事务。
1. 检查 Service 方法是否添加了@Transactional
2. 检查代码中是否有嵌套的数据库操作。
1. 确保流式查询方法被@Transactional注解。
2. 确保遍历Cursor的过程中,不要在同一事务内执行其他会提交或回滚的数据库操作。
数据导出速度非常慢1. 数据库查询本身慢(缺少索引)。
2. 网络延迟高。
3. 单条处理业务逻辑 (doSomeBusiness) 过于复杂耗时。
4.useCursorFetch=true参数导致驱动使用低效的逐行抓取。
1. 在数据库端分析 SQL 执行计划。
2. 检查网络状况。
3. 对处理逻辑进行性能分析。
4. 尝试调整 JDBC 的fetchSize参数。
1. 为查询条件添加合适的索引。
2. 优化业务处理逻辑,考虑批量处理。
3. 在连接 URL 中尝试设置defaultFetchSize,如&defaultFetchSize=1000,让驱动一次获取多行。
内存使用仍然很高1. 虽然结果集是流式的,但映射后的对象在处理前被积累在了某个集合中(如错误地存入了一个List)。
2. 数据库驱动或连接池配置不当。
1. 仔细检查遍历Cursor的循环体,确保没有将对象添加到全局集合。
2. 检查 JVM 内存 dump,分析大对象。
1. 确保处理逻辑是“处理完即丢弃”的模式,不要持有对象引用。
2. 确保正确配置了useCursorFetch=true
MySQL 报错:Commands out of sync在遍历一个Cursor的同时,在同一连接上执行了新的查询语句。检查是否在for (User user : cursor)循环内调用了其他 Mapper 方法。绝对禁止在遍历流式查询结果时,在同一方法/事务内执行其他查询。如果需要,先将流式数据收集到本地(如果内存允许),或使用两个独立的事务。
Cursor无法被 Spring 管理/注入试图在其他 Bean 中直接@Autowired一个Cursor理解Cursor一次性的、状态化的资源,不是普通的 Bean。Cursor必须由 MyBatis 的 Mapper 方法返回,并在调用者方法内部通过try-with-resources进行生命周期管理。不能将其作为 Bean 注入或跨方法传递。

8. 最佳实践与工程建议

将流式查询安全、高效地应用于生产环境,需要遵循以下最佳实践:

  1. 严格限定使用场景:仅用于大数据量的、顺序的、只读的数据处理。如数据导出、批量数据迁移、ETL 管道、日志分析等。切勿用于需要随机访问或频繁交互的在线业务接口。

  2. 始终使用 Try-With-Resources:这是防止数据库连接泄漏的生命线。将Cursor的获取放在try()括号内。

  3. 必须与@Transactional配对使用:确保整个遍历过程在一个数据库连接和事务中完成。对于只读场景,使用@Transactional(readOnly = true)

  4. 设置合理的fetchSizeuseCursorFetch=true会启用服务端游标,但默认的fetchSize可能不是最优的。你可以在连接 URL 中设置(如&defaultFetchSize=1000)或在 MyBatis 配置中为特定语句设置。这个值表示每次从数据库网络往返获取的行数,太小会增加网络开销,太大会增加客户端内存压力。需要根据数据行大小和网络状况进行测试调优。

  5. 优化源 SQL 查询

    • 使用SELECT column1, column2代替SELECT *
    • 确保WHERE条件能利用索引。
    • 添加ORDER BY子句保证顺序。
    • 绝对不要在流式查询的 SQL 中使用LIMIT ?, ?进行分页,这违背了流式的初衷。
  6. 监控与超时控制:流式查询会长时间占用连接。务必在数据库和连接池层面设置合理的查询超时事务超时。例如,在 Spring 中可以通过@Transactional(timeout = 3600)设置事务超时(单位:秒),防止长时间运行的任务拖垮连接池。

  7. 处理过程中的异常处理:在for循环内处理单条数据时,要决定好异常处理策略。是记录错误跳过当前条继续处理,还是立即终止整个任务?通常建议使用try-catch包裹单条记录的处理逻辑,记录错误并继续,确保部分失败不影响整体任务。

  8. 考虑替代方案:对于超大数据集(十亿级),即使流式查询也可能因为单次事务时间过长而不适用。此时应考虑:

    • 数据库原生导出工具:如mysqldumpSELECT ... INTO OUTFILE
    • 分批查询:虽然不如流式优雅,但通过自增 ID 或时间范围进行分批 (WHERE id > ? LIMIT 10000),是更可控的方案。
    • CDC(变更数据捕获)工具:如 Debezium,用于实时同步海量数据。

流式查询是 MyBatis 提供的一把处理海量数据的利器,它能将你从 OOM 的噩梦中解救出来。其核心在于将“一次性装载”转变为“按需流动”,通过牺牲连接占用时间来换取极低的内存消耗。成功使用的关键在于正确的资源管理(Try-With-Resources +@Transactional)和清晰的场景认知(大数据量顺序处理)。

在下一篇文章(下)中,我们将深入更进阶的话题:如何与 MyBatis-Plus 结合使用?在复杂的多表关联查询中如何使用流式查询?如何对流式查询进行单元测试?以及如何利用 Spring 的@TransactionalEventListener在事务提交后再进行异步处理,进一步优化性能?敬请期待。

建议你将本文中的示例代码收藏并实践,在遇到下一个“数据导出”或“批量处理”需求时,可以自信地避开内存陷阱,优雅地完成任务。