MyBatis流式查询实战:告别OOM,高效处理百万级数据 你有没有遇到过这样的场景一个看似简单的查询数据量稍微大一点服务就突然 OOMOut Of Memory崩溃了控制台日志里赫然写着java.lang.OutOfMemoryError: Java heap space而你检查代码发现只是一行普通的ListUser 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 流式查询必须回到它的基石JDBCJava 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 的封装CursorTMyBatis 在 JDBC 之上做了优雅的封装。它提供了一个CursorT接口来支持流式查询。Cursor实现了IterableT和IteratorT接口这意味着你可以像使用for-each循环或者Iterator一样来遍历它。核心区别ListT selectList(...)拉取模式。MyBatis 在方法内部完成所有数据的获取、映射、组装然后返回一个完整的List。CursorT 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus 启动器 (包含了 MyBatis 和 MyBatis-Spring) -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version !-- 请使用最新稳定版 -- /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- Lombok (简化实体类可选) -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 ListUser getAllUsers() { // 假设 user 表有 100 万条数据 // 这行代码会尝试将 100 万个 User 对象全部加载到内存的 List 中 return userMapper.selectList(null); // 查询所有用户 } /** * 同样危险即使加了条件如果结果集很大一样会 OOM */ Override public ListUser getUsersByCondition(String name) { QueryWrapperUser 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注解并返回CursorT类型。// 文件路径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 BaseMapperUser { // 如果用了 MyBatis-Plus /** * 流式查询所有用户 * 关键返回值是 CursorUser */ Select(SELECT * FROM user) CursorUser 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 (CursorUser 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。方法返回类型为CursorUser。调用该方法会得到一个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 继承自 BaseMapperUser Override Transactional public void processUsersByConditionStream(String name) { QueryWrapperUser wrapper new QueryWrapper(); wrapper.like(name, name); // 使用 MyBatis-Plus 的 selectCursor 方法 try (CursorUser 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 ServiceImplUserMapper, 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. 构建查询条件这里查询所有可按需修改 QueryWrapperUser 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; charsetUTF-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?useSSLfalseserverTimezoneUTCuseUnicodetruecharacterEncodingutf8 # 关键配置在连接字符串中添加参数告诉MySQL驱动使用流式结果集 # 对于 MySQL 8.x 驱动这个参数通常是 useCursorFetchtrue 或通过 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 BaseMapperUser { /** * 使用 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) CursorUser selectAllUsersStreamWithOptions(); }为什么推荐Options注解精准控制只对需要流式查询的方法生效不影响其他普通查询。可读性高一看就知道这个方法使用了特殊的查询设置。避免全局副作用全局设置default-fetch-size可能会影响一些不需要流式的简单查询带来不必要的性能开销或兼容性问题。6. 运行结果与效果验证6.1 如何验证流式查询真的生效了内存监控最直观在导出大量数据例如 100 万条时使用 JVM 监控工具如 VisualVM, JConsole, Arthas观察堆内存使用情况。传统selectList你会看到堆内存使用量瞬间飙升形成一个陡峭的“山峰”很可能触发 Full GC 甚至 OOM。流式selectCursor堆内存使用量会保持在一个相对稳定、较低的水平随着处理数据有小幅波动但不会形成巨大的峰值。数据库监控在数据库端如 MySQL使用SHOW PROCESSLIST;命令查看连接状态。流式查询执行期间你会看到对应连接的状态一直是Sending data直到客户端遍历完所有结果。这证明了结果集是“流式”传输的连接被长时间占用。日志输出在 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. 评估是否真的需要全量流式能否分页。内存使用仍然很高但未OOM1. 虽然结果集是流式的但客户端处理后的数据没有及时释放。例如将流式读取的所有对象又存入了一个新的List。2. 数据库驱动或 MyBatis 的fetchSize缓冲设置过大。1. 检查处理循环内是否将对象添加到了外部集合。2. 检查Options(fetchSize...)的设置。1.流式读取的数据必须“处理即丢弃”不要积累。2. 适当调小fetchSize但 MySQL 流式需要Integer.MIN_VALUE。MySQL 报错Commands out of sync; you cant run this command now1. 在流式查询结果集未关闭前在同一连接上执行了其他 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. 查看 MyBatisOptions(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 为例其驱动参数是fetchSizeInteger.MIN_VALUE。对于PostgreSQL则是fetchSize50或其他正数并配合ResultSet.TYPE_FORWARD_ONLY。务必查阅你所使用数据库的官方 JDBC 驱动文档。MyBatis-PlusMyBatis-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让它帮你把数据“流”起来。