你有没有遇到过这样的场景:一个看似简单的查询,数据量稍微大一点,应用就突然卡死,然后监控告警疯狂提示“内存溢出(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); // 同样危险! }这行代码背后发生了什么?
- MyBatis 执行 SQL
SELECT * FROM user。 - 数据库服务器准备好所有匹配的数据(比如 100 万行)。
- JDBC 驱动尝试将这 100 万行数据,通过一次网络传输,全部拉取到应用程序的 JVM 内存中。
- MyBatis 将这 100 万行数据,每一行都实例化成一个
User对象。 - 最终,一个包含 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 组合为例。
- JDK 版本:1.8 或更高版本(推荐 JDK 11+)。流式查询依赖的 JDBC 特性在主流版本中均已支持。
- 构建工具:Maven 或 Gradle。
- 数据库:MySQL是本文的主要示例。请注意,不同数据库(如 PostgreSQL、Oracle)对游标和流式结果集的支持方式和驱动配置可能不同,请查阅对应数据库的 JDBC 驱动文档。
- 核心依赖:
- 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.DriveruseCursorFetch=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()); // 这里可以是复杂的业务逻辑 } }代码解读:
try (Cursor<User> cursor = ...):这是 Java 7 引入的 try-with-resources 语法,用于自动管理资源(实现了AutoCloseable接口)。在try块结束时(无论正常还是异常),cursor.close()会被自动调用。for (User user : cursor):使用增强 for 循环遍历Cursor,语法简洁。底层是调用cursor.hasNext()和cursor.next()。@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; } }代码精讲:
- 多层资源管理:我们同时管理了
CSVPrinter、FileWriter和Cursor三个需要关闭的资源。将它们全部放在try-with-resources的括号内,是确保在任何情况下都能正确释放资源的最佳实践。 @Transactional(readOnly = true):导出操作不需要修改数据,使用只读事务可以提高数据库性能,也明确了操作意图。- 分批刷新:
if (count % 10000 == 0) { csvPrinter.flush(); }这是一个重要的优化。虽然流式查询控制了 JVM 内存中的用户对象数量,但写入文件时,数据会先进入系统的 I/O 缓冲区。定期刷新缓冲区可以防止它无限制增长,尤其是在处理速度(数据库流式读取)和写入速度(磁盘 I/O)不匹配时。 - 日志记录:在处理大量数据时,适当的日志(如每处理 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 启动应用并测试
- 确保你的 MySQL 数据库中有
user表,并包含足够多的测试数据(可以使用脚本生成百万条测试数据)。 - 启动 Spring Boot 应用。
- 访问
http://localhost:8080/api/export/users/csv。 - 观察控制台日志,你会看到类似以下的输出:
... 开始流式导出用户数据... ... 已处理 10000 条记录 ... 已处理 20000 条记录 ... 流式导出完成,共处理 1000000 条记录,文件已保存至: /your_path/export_users_20231026_143022.csv - 检查生成的 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. 最佳实践与工程建议
将流式查询安全、高效地应用于生产环境,需要遵循以下最佳实践:
严格限定使用场景:仅用于大数据量的、顺序的、只读的数据处理。如数据导出、批量数据迁移、ETL 管道、日志分析等。切勿用于需要随机访问或频繁交互的在线业务接口。
始终使用 Try-With-Resources:这是防止数据库连接泄漏的生命线。将
Cursor的获取放在try()括号内。必须与
@Transactional配对使用:确保整个遍历过程在一个数据库连接和事务中完成。对于只读场景,使用@Transactional(readOnly = true)。设置合理的
fetchSize:useCursorFetch=true会启用服务端游标,但默认的fetchSize可能不是最优的。你可以在连接 URL 中设置(如&defaultFetchSize=1000)或在 MyBatis 配置中为特定语句设置。这个值表示每次从数据库网络往返获取的行数,太小会增加网络开销,太大会增加客户端内存压力。需要根据数据行大小和网络状况进行测试调优。优化源 SQL 查询:
- 使用
SELECT column1, column2代替SELECT *。 - 确保
WHERE条件能利用索引。 - 添加
ORDER BY子句保证顺序。 - 绝对不要在流式查询的 SQL 中使用
LIMIT ?, ?进行分页,这违背了流式的初衷。
- 使用
监控与超时控制:流式查询会长时间占用连接。务必在数据库和连接池层面设置合理的查询超时和事务超时。例如,在 Spring 中可以通过
@Transactional(timeout = 3600)设置事务超时(单位:秒),防止长时间运行的任务拖垮连接池。处理过程中的异常处理:在
for循环内处理单条数据时,要决定好异常处理策略。是记录错误跳过当前条继续处理,还是立即终止整个任务?通常建议使用try-catch包裹单条记录的处理逻辑,记录错误并继续,确保部分失败不影响整体任务。考虑替代方案:对于超大数据集(十亿级),即使流式查询也可能因为单次事务时间过长而不适用。此时应考虑:
- 数据库原生导出工具:如
mysqldump、SELECT ... INTO OUTFILE。 - 分批查询:虽然不如流式优雅,但通过自增 ID 或时间范围进行分批 (
WHERE id > ? LIMIT 10000),是更可控的方案。 - CDC(变更数据捕获)工具:如 Debezium,用于实时同步海量数据。
- 数据库原生导出工具:如
流式查询是 MyBatis 提供的一把处理海量数据的利器,它能将你从 OOM 的噩梦中解救出来。其核心在于将“一次性装载”转变为“按需流动”,通过牺牲连接占用时间来换取极低的内存消耗。成功使用的关键在于正确的资源管理(Try-With-Resources +@Transactional)和清晰的场景认知(大数据量顺序处理)。
在下一篇文章(下)中,我们将深入更进阶的话题:如何与 MyBatis-Plus 结合使用?在复杂的多表关联查询中如何使用流式查询?如何对流式查询进行单元测试?以及如何利用 Spring 的@TransactionalEventListener在事务提交后再进行异步处理,进一步优化性能?敬请期待。
建议你将本文中的示例代码收藏并实践,在遇到下一个“数据导出”或“批量处理”需求时,可以自信地避开内存陷阱,优雅地完成任务。