你有没有遇到过这样的场景:一个看似简单的查询,数据量稍微大一点,服务就突然 OOM(Out Of Memory)崩溃了?控制台日志里赫然写着java.lang.OutOfMemoryError: Java heap space,而你检查代码,发现只是一行普通的List<User> users = userMapper.selectList(queryWrapper);。
这行代码,就是今天要讨论的“内存杀手”。它背后隐藏着一个在数据量增长时极易被忽视的陷阱:全量数据一次性加载到内存。当查询结果集达到几十万甚至上百万条时,这行代码会瞬间吃光 JVM 堆内存,导致服务不可用。
本文要解决的,就是这个“一行代码挤爆内存”的典型问题。我们将深入剖析其背后的原因,并给出一个在生产环境中被验证有效的解决方案:MyBatis 流式查询。这不是一个简单的 API 替换,而是一种从“批量拉取”到“按需流动”的编程思维转变。通过本文,你将彻底理解:
- 为什么传统的
selectList会成为内存炸弹?—— 从 JDBC 驱动到 MyBatis 结果集映射的全链路分析。 - 流式查询究竟是什么?—— 它如何像打开水龙头一样,让数据“流”进应用,而不是“倒”进内存。
- 如何用一行代码改造你的查询,实现内存友好?—— 提供可复制、可落地的 MyBatis 原生及 MyBatis-Plus 流式查询代码。
- 流式查询的“坑”与“最佳实践”—— 事务、连接、超时、框架兼容性,一个都不能少。
如果你正在处理报表导出、大数据量同步、分页深度遍历等场景,或者你的服务已经开始被大查询困扰,那么这篇文章正是为你准备的。我们不仅讲“是什么”,更讲“为什么”和“怎么做”,让你知其然,更知其所以然。
1. 这篇文章真正要解决的问题:从“内存溢出”到“数据流动”
很多开发者第一次遇到 OOM 时,会本能地去调整 JVM 参数,比如把-Xmx从 1G 调到 2G、4G。这就像房间堆满了东西,不去整理,反而去换一个更大的房子。短期内可能缓解,但数据量持续增长,房子总有被塞满的一天,而且更大的堆内存意味着更长的 GC 停顿时间,影响服务稳定性。
问题的根源在于数据处理模式。传统的数据库查询操作,无论是 JDBC 的ResultSet默认行为,还是 MyBatis 封装后的selectList,其本质都是:在数据库执行查询后,驱动会尝试将整个结果集通过网络传输到客户端,并缓存在内存中,然后应用程序才能开始处理。
这个过程可以拆解为:
- 数据库执行 SQL,生成结果集。
- JDBC 驱动(如 MySQL Connector/J)默认会通过
fetchSize参数控制,但很多驱动默认会尝试一次性获取所有数据或大量数据到客户端内存。 - MyBatis 拿到这个结果集后,遍历它,并通过反射创建 Java 对象,填充属性,最后将所有对象添加到一个
List中。 - 这个包含了所有结果的
List被返回给调用者。
关键点:在第三步,所有数据已经全部在 JVM 堆内存里了。如果你的查询返回 100 万条用户记录,每条记录占用 1KB,那么瞬间就需要约 1GB 的堆内存来承载这个列表(这还没算上列表本身和对象引用的开销)。这对于一个常规的微服务实例来说,是难以承受的。
流式查询要做的,就是颠覆这个模式。它的核心思想是:让结果集保持打开状态,数据库服务器按需、分批地将数据发送给客户端。客户端处理完一批后,再获取下一批,数据像水流一样持续“流”过来,而不是一次性“倒”过来。
这样做的好处显而易见:
- 内存友好:同一时刻,内存中只持有少量数据(例如一批1000条),内存占用恒定,与总数据量无关。
- 快速响应:应用程序不需要等待所有数据都传输完毕才开始处理,可以一边接收一边处理,对于导出、转换等操作,能更快地给出首次响应。
接下来,我们从原理到实战,彻底掌握这个“救火队长”。
2. 基础概念与核心原理:JDBC、游标与流
要理解 MyBatis 流式查询,必须回到它的基石:JDBC(Java Database Connectivity)。
2.1 JDBC 的ResultSet与fetchSize
当我们执行一条查询语句时,JDBC 的核心对象是ResultSet。你可以把它想象成指向数据库结果集的一个“游标”。默认情况下,这个游标的行为(即数据如何从数据库服务器传输到客户端)由fetchSize参数控制。
fetchSize = 0(默认,依赖驱动):很多数据库驱动(如旧版 MySQL 驱动)的默认行为是“全部获取”。驱动会尽可能多、尽可能快地把所有行都拉到客户端内存中。fetchSize = n(n > 0):这告诉驱动:“每次从服务器取 n 行数据到客户端缓冲区”。这就是客户端游标的基础。但注意,这并不意味着流式,它只是分批加载到客户端内存,最终所有数据还是会进入内存。
2.2 真正的流式:服务端游标与TYPE_FORWARD_ONLY
要实现真正的流式,需要两个关键设置配合:
ResultSet.TYPE_FORWARD_ONLY:创建只能向前移动的ResultSet。这是流式查询的前提,因为流式数据是单向的。ResultSet.CONCUR_READ_ONLY:创建只读的ResultSet。fetchSize = Integer.MIN_VALUE(MySQL 驱动):这是一个驱动特定的“魔法值”。对于 MySQL 的Connector/J驱动,设置fetchSize为Integer.MIN_VALUE会启用服务端游标。此时,驱动不会一次性拉取所有数据,而是告诉服务器:“请保持结果集打开,我一条一条(或一小批一小批)地来取”。
服务端游标意味着结果集的数据仍然留在数据库服务器上,客户端通过游标逐条请求。这极大地减少了客户端的内存压力,但会增加数据库连接的占用时间(因为连接必须保持打开直到结果集处理完毕)。
2.3 MyBatis 的封装:Cursor<T>
MyBatis 在 JDBC 之上做了优雅的封装。它提供了一个Cursor<T>接口来支持流式查询。Cursor实现了Iterable<T>和Iterator<T>接口,这意味着你可以像使用for-each循环或者Iterator一样来遍历它。
核心区别:
List<T> selectList(...):拉取模式。MyBatis 在方法内部完成所有数据的获取、映射、组装,然后返回一个完整的List。Cursor<T> selectCursor(...):流式模式。MyBatis 返回一个Cursor对象,此时数据还没有开始传输。当你开始遍历Cursor时,MyBatis 才通过底层的 JDBC 流式ResultSet逐条获取数据,并动态映射为对象。
理解了这个原理,我们就知道,使用流式查询不仅仅是换一个方法调用,更需要关注其带来的副作用:数据库连接持有时间变长。这是我们在实践中必须妥善处理的问题。
3. 环境准备与前置条件
在开始编写代码之前,请确保你的开发环境满足以下要求。我们将以一个典型的 Spring Boot + MyBatis-Plus 项目为例。
- JDK: 1.8 或更高版本(推荐 JDK 11+)
- 构建工具: Maven 或 Gradle
- 主要依赖:
- Spring Boot: 2.3.x 或更高版本
- MyBatis Spring Boot Starter: 与 Spring Boot 版本对应
- MyBatis-Plus(可选但推荐): 3.4.x 或更高版本。MyBatis-Plus 对流式查询有更好的支持。
- 数据库驱动: 例如 MySQL Connector/J 8.0.x
- 数据库: 任意支持 JDBC 流式查询的数据库,如 MySQL, PostgreSQL。本文以 MySQL 为例。
- IDE: IntelliJ IDEA, Eclipse 或 VS Code 等。
Maven 核心依赖示例 (pom.xml):
<dependencies> <!-- Spring Boot Web (可选,根据项目类型) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus 启动器 (包含了 MyBatis 和 MyBatis-Spring) --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> <!-- 请使用最新稳定版 --> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok (简化实体类,可选) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>数据库表准备: 我们创建一个简单的用户表用于演示。
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `name` varchar(30) DEFAULT NULL COMMENT '姓名', `age` int(11) DEFAULT NULL COMMENT '年龄', `email` varchar(50) DEFAULT NULL COMMENT '邮箱', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 插入一些测试数据,可以插入大量数据(例如10万条)来模拟大查询场景 -- 这里使用存储过程或程序批量插入,示例略。实体类User.java:
package com.example.demo.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("user") public class User { @TableId(type = IdType.AUTO) private Long id; private String name; private Integer age; private String email; private LocalDateTime createTime; }环境就绪,接下来我们进入核心环节:代码实现。
4. 核心流程拆解:从传统查询到流式查询
让我们先看看问题代码,然后一步步将其改造为流式查询。
4.1 问题代码:内存炸弹是如何炼成的
// 文件路径:src/main/java/com/example/demo/service/impl/UserServiceImpl.java @Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; /** * 危险!大数据量查询,极易导致 OOM */ @Override public List<User> getAllUsers() { // 假设 user 表有 100 万条数据 // 这行代码会尝试将 100 万个 User 对象全部加载到内存的 List 中 return userMapper.selectList(null); // 查询所有用户 } /** * 同样危险!即使加了条件,如果结果集很大,一样会 OOM */ @Override public List<User> getUsersByCondition(String name) { QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.like("name", name); // 如果 name 匹配的记录有 50 万条,这 50 万条记录会全部进入内存 return userMapper.selectList(wrapper); } }问题分析:selectList方法内部会执行查询,并通过DefaultResultSetHandler将ResultSet的所有行转换为对象,并添加到一个ArrayList中。当数据量巨大时,这个ArrayList就是压垮 JVM 的最后一根稻草。
4.2 改造第一步:使用 MyBatis 原生的@Select与Cursor
如果你的项目使用的是原生 MyBatis,可以通过在 Mapper 接口的方法上使用@Select注解,并返回Cursor<T>类型。
// 文件路径:src/main/java/com/example/demo/mapper/UserMapper.java import org.apache.ibatis.annotations.Select; import org.apache.ibatis.cursor.Cursor; @Mapper public interface UserMapper extends BaseMapper<User> { // 如果用了 MyBatis-Plus /** * 流式查询所有用户 * 关键:返回值是 Cursor<User> */ @Select("SELECT * FROM user") Cursor<User> selectAllUsersStream(); }Service 层调用:
// 文件路径:src/main/java/com/example/demo/service/impl/UserServiceImpl.java @Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override @Transactional // 重点:流式查询必须在一个事务中! public void processAllUsersStream() { try (Cursor<User> cursor = userMapper.selectAllUsersStream()) { for (User user : cursor) { // 在这里处理每一条用户数据 // 例如:写入文件、发送到消息队列、进行业务计算等 processSingleUser(user); // 关键:内存中同一时刻只有一个或一小批 User 对象 } } // try-with-resources 会自动关闭 Cursor,进而关闭底层的 ResultSet } private void processSingleUser(User user) { // 模拟处理逻辑 System.out.println("Processing user: " + user.getName()); // 实际可能是:csvWriter.write(user); 或 kafkaTemplate.send(user); } }代码解读:
@Select注解定义了查询 SQL。- 方法返回类型为
Cursor<User>。 - 调用该方法会得到一个
Cursor对象,但此时查询尚未真正执行,数据也未传输。 - 在
try-with-resources语句中遍历Cursor,这才是触发流式读取的开始。 @Transactional注解至关重要。因为流式查询需要保持数据库连接和结果集打开直到遍历结束,必须在一个事务上下文中管理,否则连接可能提前关闭导致异常。
4.3 改造第二步:使用 MyBatis-Plus 的selectCursor方法(推荐)
MyBatis-Plus 在BaseMapper中直接提供了selectCursor方法,使用起来更加方便,无需手写@SelectSQL。
// 文件路径:src/main/java/com/example/demo/service/impl/UserServiceImpl.java @Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; // UserMapper 继承自 BaseMapper<User> @Override @Transactional public void processUsersByConditionStream(String name) { QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.like("name", name); // 使用 MyBatis-Plus 的 selectCursor 方法 try (Cursor<User> cursor = userMapper.selectCursor(wrapper)) { int count = 0; for (User user : cursor) { processSingleUser(user); count++; // 可以每处理一定数量后记录日志,方便监控进度 if (count % 10000 == 0) { System.out.println("已处理 " + count + " 条记录"); } } System.out.println("流式处理完成,总计处理: " + count + " 条记录"); } } private void processSingleUser(User user) { // 业务处理逻辑 // 例如,转换为 JSON 字符串写入文件 // String line = objectMapper.writeValueAsString(user); // fileWriter.write(line + "\n"); } }代码解读:
- 构建普通的
QueryWrapper。 - 调用
userMapper.selectCursor(wrapper),传入查询条件。 - 同样使用
try-with-resources和@Transactional。 - 在循环中逐条处理数据。
Cursor内部会通过 JDBC 流式ResultSet按需获取数据。
这就是将“一行代码挤爆内存”改造为“一行代码启动流式查询”的核心过程。方法签名从selectList换成了selectCursor,并确保了事务上下文。
5. 完整示例与代码实现:一个真实的导出场景
让我们构建一个更完整的示例:将数据库中的用户数据流式导出为一个 CSV 文件。这是一个非常典型的大数据量处理场景。
5.1 项目结构
src/main/java/com/example/demo/ ├── DemoApplication.java ├── config/ ├── controller/ │ └── UserExportController.java ├── service/ │ ├── UserService.java │ └── impl/ │ └── UserServiceImpl.java ├── mapper/ │ └── UserMapper.java └── entity/ └── User.java5.2 Service 层实现:流式查询与文件写入
// 文件路径:src/main/java/com/example/demo/service/UserService.java public interface UserService { /** * 流式导出用户数据到CSV文件 * @param filePath 导出的文件路径 */ void exportUsersToCsv(String filePath); }// 文件路径:src/main/java/com/example/demo/service/impl/UserServiceImpl.java package com.example.demo.service.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import com.example.demo.service.UserService; import lombok.extern.slf4j.Slf4j; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVPrinter; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.mybatis.spring.MyBatisSystemException; import java.io.BufferedWriter; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; @Slf4j @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @Override @Transactional(readOnly = true) // 使用只读事务,对数据库更友好 public void exportUsersToCsv(String filePath) { // 1. 创建CSV文件并写入表头 try (BufferedWriter writer = Files.newBufferedWriter(Paths.get(filePath), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); CSVPrinter csvPrinter = new CSVPrinter(writer, CSVFormat.DEFAULT.withHeader("ID", "姓名", "年龄", "邮箱", "创建时间"))) { // 2. 构建查询条件(这里查询所有,可按需修改) QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.orderByAsc("id"); // 建议按主键排序,保证导出顺序 // 3. 执行流式查询 // 关键代码:使用 selectCursor 替代 selectList try (var cursor = this.getBaseMapper().selectCursor(queryWrapper)) { long count = 0; // 4. 遍历游标,逐行写入CSV for (User user : cursor) { csvPrinter.printRecord( user.getId(), user.getName(), user.getAge(), user.getEmail(), user.getCreateTime() ); count++; // 可选:每处理一定数量后刷新缓冲区,并记录日志 if (count % 10000 == 0) { csvPrinter.flush(); // 刷新缓冲区,防止内存中积累过多字符串 log.info("已流式导出 {} 条用户记录", count); } } csvPrinter.flush(); log.info("流式导出完成,总计导出 {} 条记录到文件: {}", count, filePath); } // Cursor 自动关闭 } catch (IOException e) { log.error("写入CSV文件失败: {}", filePath, e); throw new RuntimeException("导出文件失败", e); } catch (MyBatisSystemException e) { // 特别注意:流式查询相关的异常可能在这里被包装抛出 log.error("流式查询执行失败", e); throw new RuntimeException("数据查询失败", e); } // 注意:事务在方法结束时提交,连接才会关闭。在此之前,流式查询使用的连接一直打开。 } }5.3 Controller 层提供 HTTP 接口
// 文件路径:src/main/java/com/example/demo/controller/UserExportController.java package com.example.demo.controller; import com.example.demo.service.UserService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletResponse; import java.io.File; import java.nio.file.Files; import java.nio.file.Path; @Slf4j @RestController @RequestMapping("/api/user") public class UserExportController { @Autowired private UserService userService; @GetMapping("/export/csv") public void exportToCsv(HttpServletResponse response) { // 生成临时文件路径 String fileName = "users_export_" + System.currentTimeMillis() + ".csv"; Path tempFilePath; try { tempFilePath = Files.createTempFile("user_export_", ".csv"); String filePath = tempFilePath.toAbsolutePath().toString(); log.info("开始流式导出用户数据到临时文件: {}", filePath); // 核心调用:流式查询并写入文件 userService.exportUsersToCsv(filePath); log.info("数据导出完成,开始向客户端传输文件"); // 设置响应头,告诉浏览器这是一个文件下载 response.setContentType("text/csv; charset=UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\""); response.setHeader("Cache-Control", "no-cache"); // 将文件内容写入 HTTP 响应流 Files.copy(tempFilePath, response.getOutputStream()); response.flushBuffer(); log.info("文件传输完成"); } catch (Exception e) { log.error("导出CSV过程发生异常", e); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); // 实际项目中应返回更友好的错误信息 } finally { // 清理临时文件 if (tempFilePath != null) { try { Files.deleteIfExists(tempFilePath); } catch (IOException e) { log.warn("删除临时文件失败: {}", tempFilePath, e); } } } } }5.4 关键配置:确保流式查询生效
仅仅代码中使用selectCursor还不够,必须确保 MyBatis 和数据库驱动正确配置了流式模式。
application.yml或application.properties配置:
# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/your_database?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8 # 关键配置:在连接字符串中添加参数,告诉MySQL驱动使用流式结果集 # 对于 MySQL 8.x 驱动,这个参数通常是 `useCursorFetch=true` 或通过 `fetchSize` 设置 # 但更通用的方式是在 MyBatis 映射语句中配置 fetchSize username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: # 全局设置默认的 fetchSize,对于流式查询,通常设置为 Integer.MIN_VALUE (MySQL) # 注意:这个设置会影响所有语句,需谨慎。更推荐在具体的 Mapper 方法上通过 @Options 注解设置。 default-fetch-size: -2147483648 # Integer.MIN_VALUE # 或者,保持默认,在需要流式查询的方法上单独配置(推荐)更推荐的做法:在 Mapper 方法上使用@Options注解进行精确控制
// 文件路径:src/main/java/com/example/demo/mapper/UserMapper.java import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.mapping.ResultSetType; @Mapper public interface UserMapper extends BaseMapper<User> { /** * 使用 @Options 注解精确配置流式查询参数 * fetchSize = Integer.MIN_VALUE: 对于 MySQL,启用服务端游标(流式) * resultSetType = FORWARD_ONLY: 结果集仅向前滚动,这是流式必须的 * timeout: 查询超时时间(秒),防止长时间占用连接 */ @Select("SELECT * FROM user ORDER BY id") @Options(fetchSize = Integer.MIN_VALUE, resultSetType = ResultSetType.FORWARD_ONLY, timeout = 600) Cursor<User> selectAllUsersStreamWithOptions(); }为什么推荐@Options注解?
- 精准控制:只对需要流式查询的方法生效,不影响其他普通查询。
- 可读性高:一看就知道这个方法使用了特殊的查询设置。
- 避免全局副作用:全局设置
default-fetch-size可能会影响一些不需要流式的简单查询,带来不必要的性能开销或兼容性问题。
6. 运行结果与效果验证
6.1 如何验证流式查询真的生效了?
内存监控(最直观):
- 在导出大量数据(例如 100 万条)时,使用 JVM 监控工具(如 VisualVM, JConsole, Arthas)观察堆内存使用情况。
- 传统
selectList:你会看到堆内存使用量瞬间飙升,形成一个陡峭的“山峰”,很可能触发 Full GC 甚至 OOM。 - 流式
selectCursor:堆内存使用量会保持在一个相对稳定、较低的水平,随着处理数据有小幅波动,但不会形成巨大的峰值。
数据库监控:
- 在数据库端(如 MySQL),使用
SHOW PROCESSLIST;命令查看连接状态。 - 流式查询执行期间,你会看到对应连接的状态一直是
Sending data,直到客户端遍历完所有结果。这证明了结果集是“流式”传输的,连接被长时间占用。
- 在数据库端(如 MySQL),使用
日志输出:
- 在 Service 层的循环中,每处理一定数量(如 1 万条)打印一条日志。
- 你会看到日志是持续、分批输出的,而不是等待很长时间后一次性全部输出。这说明数据处理是边读边处理的。
6.2 执行示例
启动你的 Spring Boot 应用,访问http://localhost:8080/api/user/export/csv。
控制台日志可能如下:
2023-10-27 10:00:00.123 INFO com.example.demo.service.impl.UserServiceImpl - 开始流式导出用户数据到临时文件: /tmp/user_export_123456.csv 2023-10-27 10:00:05.456 INFO com.example.demo.service.impl.UserServiceImpl - 已流式导出 10000 条用户记录 2023-10-27 10:00:10.789 INFO com.example.demo.service.impl.UserServiceImpl - 已流式导出 20000 条用户记录 ... // 持续输出 2023-10-27 10:02:30.111 INFO com.example.demo.service.impl.UserServiceImpl - 已流式导出 980000 条用户记录 2023-10-27 10:02:35.222 INFO com.example.demo.service.impl.UserServiceImpl - 已流式导出 990000 条用户记录 2023-10-27 10:02:40.333 INFO com.example.demo.service.impl.UserServiceImpl - 流式导出完成,总计导出 1000000 条记录到文件: /tmp/user_export_123456.csv 2023-10-27 10:02:40.444 INFO com.example.demo.controller.UserExportController - 数据导出完成,开始向客户端传输文件 2023-10-27 10:02:45.555 INFO com.example.demo.controller.UserExportController - 文件传输完成关键观察点:
- 在“开始导出”和“导出完成”之间,日志是分批打印的。
- 文件传输是在所有数据处理完成后才开始的。这意味着服务端先将数据流式处理并写入临时文件,最后才一次性发送给浏览器。对于超大数据量,可以考虑更复杂的“分块传输编码”来实现真正的边处理边传输,但这超出了本文范围。
7. 常见问题与排查思路
流式查询虽然强大,但使用不当会引入新的问题。下表总结了最常见的坑及其解决方案。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
抛出Connection is closed或ResultSet is closed异常 | 1.未添加@Transactional注解。流式查询需要在事务内保持连接打开,方法执行完连接就关了。2. 事务范围太小,在遍历 Cursor前事务就结束了。3. 手动管理连接时,提前关闭了连接。 | 1. 检查 Service 方法是否有@Transactional。2. 检查事务的传播行为,确保遍历 Cursor的整个循环都在事务内。3. 检查是否有其他代码关闭了连接或 SqlSession。 | 确保流式查询方法被@Transactional注解修饰。对于只读操作,使用@Transactional(readOnly = true)。 |
| 查询速度非常慢 | 1. 数据库网络延迟高。 2. 服务端游标对数据库有额外开销。 3. 客户端处理每条数据太慢,拖慢了整体流速。 4. 没有合适的索引,导致数据库本身查询慢。 | 1. 监控数据库服务器和网络。 2. 对比非流式查询的耗时。 3. 分析客户端处理逻辑的耗时。 4. 使用 EXPLAIN分析 SQL 执行计划。 | 1. 优化客户端处理逻辑,考虑批量处理(如每1000条提交一次)。 2. 确保查询条件有索引。 3. 评估是否真的需要全量流式,能否分页。 |
| 内存使用仍然很高(但未OOM) | 1. 虽然结果集是流式的,但客户端处理后的数据没有及时释放。例如,将流式读取的所有对象又存入了一个新的List。2. 数据库驱动或 MyBatis 的 fetchSize缓冲设置过大。 | 1. 检查处理循环内,是否将对象添加到了外部集合。 2. 检查 @Options(fetchSize=...)的设置。 | 1.流式读取的数据必须“处理即丢弃”,不要积累。 2. 适当调小 fetchSize(但 MySQL 流式需要Integer.MIN_VALUE)。 |
MySQL 报错:Commands out of sync; you can't run this command now | 1. 在流式查询结果集未关闭前,在同一连接上执行了其他 SQL 语句。 2. MyBatis 或连接池管理不当。 | 1. 检查代码,确保在关闭Cursor前没有其他数据库操作。2. 检查连接池配置(如 HikariCP),看是否有“自动提交”或“重置连接”的干扰。 | 1. 确保流式查询在一个独立的事务和方法中完成,避免混杂其他操作。 2. 考虑使用 @Transactional(propagation = Propagation.REQUIRES_NEW)为新事务创建新连接。 |
| 长时间不返回,连接超时 | 1. 数据量极大,处理时间超过数据库或应用的超时设置。 2. 网络不稳定。 | 1. 查看数据库的wait_timeout、interactive_timeout参数。2. 查看连接池的 connectionTimeout、idleTimeout。3. 查看 MyBatis @Options(timeout=)设置。 | 1.务必设置合理的超时时间。在@Options中设置timeout(单位:秒)。2. 优化处理逻辑,提升单条处理速度。 3. 考虑将任务异步化,避免阻塞 HTTP 请求。 |
8. 最佳实践与工程建议
掌握了基本用法和避坑指南后,以下最佳实践能帮助你在生产环境中更稳健地使用流式查询。
8.1 事务管理的艺术
- 必须加
@Transactional:这是铁律。确保方法有事务注解。 - 使用
readOnly = true:对于纯查询操作,设置只读事务,数据库可能会做一些优化。 - 事务隔离级别:通常使用默认的隔离级别即可。如果处理过程中需要数据一致性快照,可以考虑
REPEATABLE_READ,但要注意对数据库性能的影响。 - 事务传播行为:流式查询方法最好独立,使用
Propagation.REQUIRED(默认)或Propagation.REQUIRES_NEW。避免被嵌套在大型事务中,导致连接占用时间过长。
8.2 资源关闭与异常处理
- 使用 try-with-resources:这是关闭
Cursor的最佳方式,能确保在任何情况下(包括异常)资源都被释放。 - 在 finally 块中处理:如果不能用 try-with-resources,务必在
finally块中手动调用cursor.close()。 - 处理
MyBatisSystemException:流式查询的异常通常被包装为此异常,需要捕获并妥善处理,记录日志并可能回滚事务。
8.3 性能与稳定性
- 设置查询超时 (
timeout):在@Options中一定要设置,防止慢查询拖死连接。 - 监控连接池:流式查询会长时间占用一个数据库连接。确保你的连接池(如 HikariCP)有足够的
maximumPoolSize,并监控活跃连接数,防止连接被耗尽。 - 批处理思想:虽然流式是逐条获取,但客户端处理可以批量进行。例如,每处理1000条记录后,批量写入文件、批量发送到 Kafka,可以减少 I/O 操作次数,提升吞吐量。
- 索引是王道:流式查询解决的是客户端内存问题,而不是数据库查询慢的问题。确保你的
WHERE、ORDER BY条件有合适的索引。
8.4 框架与驱动兼容性
- MyBatis 版本:确保使用较新的 MyBatis 版本(如 3.5.x+),对
Cursor的支持更完善。 - 数据库驱动:不同数据库的流式支持方式和驱动参数不同。本文以 MySQL 为例,其驱动参数是
fetchSize=Integer.MIN_VALUE。对于PostgreSQL,则是fetchSize=50(或其他正数)并配合ResultSet.TYPE_FORWARD_ONLY。务必查阅你所使用数据库的官方 JDBC 驱动文档。 - MyBatis-Plus:MyBatis-Plus 的
selectCursor方法是对 MyBatis 原生Cursor的良好封装,可以放心使用。注意其版本与 MyBatis 核心版本的兼容性。
8.5 应用场景与替代方案
- 适合场景:
- 大数据量导出(Excel, CSV)。
- 数据迁移或同步。
- 批量数据处理(如给所有用户发消息、批量更新衍生字段)。
- 需要逐步处理结果集的后台任务。
- 不适合场景:
- 需要随机访问结果集(流式是单向的)。
- 需要将结果集返回给前端做分页展示(请用常规分页)。
- 结果集本身很小(小于1万条),此时流式的开销可能得不偿失。
- 替代方案:
- 分页查询:最通用、最安全的方案。通过
LIMIT offset, size分批查询。缺点是深度分页性能差,且无法保证在分页过程中数据变动的绝对一致性。 - 游标分页(基于索引):使用
WHERE id > last_id LIMIT size的方式,性能极佳,且能保证顺序和大致的一致性。是处理大数据量列表的推荐方案,但它不是严格的“流式”。 - 数据库原生导出工具:如
mysqldump、SELECT INTO OUTFILE,性能最好,但与应用集成度低。
- 分页查询:最通用、最安全的方案。通过
流式查询是一个强大的工具,但它引入了“长时间占用连接”这一新的复杂度。正确理解其原理,遵循最佳实践,它就能成为你处理海量数据时的利器,而不是另一个“坑”的来源。当你下次再遇到需要处理百万级数据的需求时,不要再本能地调大 JVM 内存了,想想Cursor,让它帮你把数据“流”起来。