MyBatis流式查询(Cursor)实战:解决大数据量查询OOM问题 你有没有遇到过这样的场景一个看似简单的查询数据量稍微大一点应用就突然卡死然后监控告警疯狂提示“内存溢出OOM”。你检查代码发现就是一行普通的ListUser users userMapper.selectList(queryWrapper);逻辑清晰毫无破绽。但正是这“一行代码”在数据量达到几十万甚至上百万时会瞬间将 JVM 堆内存撑爆导致服务不可用。这不是危言耸听而是后端开发中一个非常典型且隐蔽的“性能陷阱”。问题的根源在于传统的数据库查询方式会一次性将所有结果集加载到应用内存中。当数据量远超内存容量时OOM 就成了必然结局。今天我们就来彻底解决这个问题反手祭出MyBatis 流式查询Cursor这把利器让你在处理海量数据时既能保证功能又能稳住内存。本文将不仅仅告诉你“怎么用”更会深入剖析“为什么需要”、“背后的原理是什么”以及“有哪些坑必须避开”。我们会从一次真实的 OOM 事故复盘开始逐步拆解流式查询的核心机制并提供从 Spring Boot 集成到生产环境最佳实践的完整指南。无论你是正在被大数据量查询困扰还是想提前储备高并发场景下的优化方案这篇文章都将为你提供可直接落地的解决方案。1. 为什么一行普通的查询代码会成为“内存杀手”在深入技术细节之前我们首先要建立一个清晰的认知OOM 往往不是由复杂的业务逻辑引起的而是由对数据规模的无意识假设导致的。想象一下这个场景你需要导出一份所有用户的注册信息报表。代码可能长这样// UserMapper.java ListUser selectAllUsers(); // Service 层 public ListUser exportAllUsers() { return userMapper.selectAllUsers(); // 危险操作 }或者使用 MyBatis-Plus// Service 层 public ListUser exportAllUsers() { QueryWrapperUser wrapper new QueryWrapper(); return userMapper.selectList(wrapper); // 同样危险 }这行代码背后发生了什么MyBatis 执行 SQLSELECT * 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 Allgraph TD A[应用程序执行查询] -- B[发送SQL到数据库]; B -- C[数据库执行查询br并生成完整结果集]; C -- D[JDBC驱动一次性br拉取所有结果到内存]; D -- E[MyBatis映射所有结果br为对象List]; E -- F[返回巨大的List对象];特点简单直观适用于数据量小的场景。所有操作在内存中完成速度快但内存压力与数据量成正比。2.2 流式查询Cursor的工作原理Fetch Incrementallygraph 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 关键对比表格特性传统查询 (ListT)流式查询 (CursorT)内存占用高与结果集大小正相关低仅缓存少量行数据库连接查询执行完毕立即释放必须保持打开直到遍历完成适用场景数据量小千/万级需要随机访问数据量大十万/百万级顺序处理网络传输一次性大批量多次小批量可缓解瞬时压力代码复杂度低相对较高需注意资源关闭能否分页可结合LIMIT实现本身就是一种“逻辑分页”但不能用LIMIT OFFSET会破坏流式核心结论流式查询不是用来替代传统查询的它是应对大数据量顺序处理这一特定场景的专用工具。滥用流式查询例如处理小数据会带来不必要的连接占用和复杂度。3. 环境准备与前置条件在开始编写代码之前请确保你的开发环境满足以下要求。我们将以最常用的 Spring Boot MyBatis 组合为例。JDK 版本1.8 或更高版本推荐 JDK 11。流式查询依赖的 JDBC 特性在主流版本中均已支持。构建工具Maven 或 Gradle。数据库MySQL是本文的主要示例。请注意不同数据库如 PostgreSQL、Oracle对游标和流式结果集的支持方式和驱动配置可能不同请查阅对应数据库的 JDBC 驱动文档。核心依赖Spring Boot: 2.x 或 3.xMyBatis Spring Boot Starter: 与 Spring Boot 版本对应MySQL Connector/J: 驱动版本建议 8.0以下是 Mavenpom.xml的关键依赖配置!-- pom.xml -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version !-- 或 3.1.x -- /parent dependencies !-- Spring Boot Web (根据项目需要) -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis 整合 Spring Boot -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version !-- 对应 Spring Boot 2.7.x -- /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope version8.0.33/version /dependency !-- Lombok (可选简化代码) -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies重要提醒使用 MySQL 进行流式查询必须在 JDBC 连接 URL 中配置一个关键参数否则驱动默认行为仍是 Fetch All。# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/your_database?useSSLfalseserverTimezoneUTCuseCursorFetchtrue # 关键参数 username: root password: your_password driver-class-name: com.mysql.cj.jdbc.DriveruseCursorFetchtrue这个参数告诉 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.CursorT接口作为返回值。这与返回ListT有本质区别。// UserMapper.java import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.annotations.Mapper; Mapper public interface UserMapper { /** * 流式查询所有用户 * return 用户游标 */ CursorUser selectAllUsersByCursor(); /** * 带条件的流式查询 * param status 用户状态 * return 用户游标 */ CursorUser selectUsersByStatus(Param(status) Integer status); }关键点方法返回值必须是CursorT。方法本身可以接受参数用于构造带条件的查询。4.2 第二步编写对应的 Mapper XML在 XML 映射文件中SQL 的写法与普通查询完全一样。MyBatis 会根据方法返回值类型自动选择执行模式。!-- UserMapper.xml -- ?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper select idselectAllUsersByCursor resultTypecom.example.demo.entity.User SELECT id, name, email, create_time FROM user !-- 这里可以排序但避免使用会导致全表扫描的复杂条件 -- ORDER BY id ASC /select select idselectUsersByStatus resultTypecom.example.demo.entity.User SELECT id, name, email, create_time FROM user WHERE status #{status} ORDER BY id ASC /select /mapperSQL 编写建议务必使用ORDER BY流式处理通常是顺序的一个明确的排序能保证结果顺序一致也利于某些数据库优化。避免SELECT *只查询需要的字段减少网络传输和对象封装开销。WHERE 条件要高效尽量使用索引覆盖的条件避免流式查询变成流式全表扫描那样数据库压力依然很大。4.3 第三步在 Service 层使用 Try-With-Resources 遍历这是最核心、最容易出错的一步。Cursor实现了Closeable接口必须确保在任何情况下正常结束或异常都能被关闭以释放底层数据库连接。错误示范会导致连接泄漏public void processUsersWrong() { CursorUser cursor userMapper.selectAllUsersByCursor(); for (User user : cursor) { // 如果在这里发生异常cursor不会关闭 // 处理用户 System.out.println(user.getName()); } // 忘记调用 cursor.close(); }正确示范使用 try-with-resourcesimport 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 (CursorUser 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 (CursorUser 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 接口与 XMLUserMapper.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 用户游标 */ CursorUser selectUsersForExport(); }UserMapper.xml(放在resources/mapper/目录下):?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper select idselectUsersForExport resultTypecom.example.demo.entity.User SELECT id, name, email, status, create_time FROM user WHERE status 1 -- 只导出有效用户 ORDER BY id ASC -- 必须排序保证导出顺序 !-- 注意这里绝对不能使用 LIMIT流式查询是逐行获取不是分页查询 -- /select /mapper5.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)); CursorUser 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.useCursorFetchtrue参数导致驱动使用低效的逐行抓取。1. 在数据库端分析 SQL 执行计划。2. 检查网络状况。3. 对处理逻辑进行性能分析。4. 尝试调整 JDBC 的fetchSize参数。1. 为查询条件添加合适的索引。2. 优化业务处理逻辑考虑批量处理。3. 在连接 URL 中尝试设置defaultFetchSize如defaultFetchSize1000让驱动一次获取多行。内存使用仍然很高1. 虽然结果集是流式的但映射后的对象在处理前被积累在了某个集合中如错误地存入了一个List。2. 数据库驱动或连接池配置不当。1. 仔细检查遍历Cursor的循环体确保没有将对象添加到全局集合。2. 检查 JVM 内存 dump分析大对象。1. 确保处理逻辑是“处理完即丢弃”的模式不要持有对象引用。2. 确保正确配置了useCursorFetchtrue。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)。设置合理的fetchSizeuseCursorFetchtrue会启用服务端游标但默认的fetchSize可能不是最优的。你可以在连接 URL 中设置如defaultFetchSize1000或在 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在事务提交后再进行异步处理进一步优化性能敬请期待。建议你将本文中的示例代码收藏并实践在遇到下一个“数据导出”或“批量处理”需求时可以自信地避开内存陷阱优雅地完成任务。