MyBatis流式查询实战:解决海量数据查询内存溢出问题

发布时间:2026/7/28 18:43:39
MyBatis流式查询实战:解决海量数据查询内存溢出问题 这次我们来看一个 Java 开发中非常实际的问题如何用 MyBatis 的流式查询解决一次性加载海量数据导致的内存溢出OOM。很多开发者都遇到过一个看似简单的SELECT * FROM large_table在数据量达到百万级时如果直接返回ListJVM 堆内存瞬间就会被撑爆服务直接崩溃。MyBatis 提供的流式查询Streaming Query就是为这种场景而生的它允许你像处理流一样逐条从数据库读取和处理数据而不是一次性把所有数据都加载到内存里。这篇文章的重点不是讲复杂的理论而是直接告诉你流式查询能不能用怎么用用了之后效果如何我们会从核心概念、代码实现、性能对比到生产环境的最佳实践一步步拆解。如果你正在处理报表导出、大数据分析、数据迁移等需要处理大量数据的任务这篇文章可以直接收藏备用。1. 核心能力速览在深入细节之前我们先快速了解 MyBatis 流式查询的核心特性和适用边界。能力项说明核心功能以“流”的方式逐条处理数据库查询结果避免一次性加载全部数据到 JVM 内存。解决痛点防止因查询百万级以上数据导致的内存溢出OOM和长时间 Full GC。技术实现基于 JDBC 的ResultSet游标通过fetchSize和resultSetType等参数控制。适用场景大数据量报表生成、数据导出、ETL 数据迁移、日志分析等需要遍历海量结果集的场景。不适用场景需要随机访问结果集、频繁进行聚合计算或需要将全部数据在内存中多次遍历的场景。性能影响网络 I/O 和数据库连接占用时间可能变长但内存占用极低适合内存敏感型应用。启动/使用方式通过 MyBatis 的CursorT接口或自定义ResultHandler实现无需额外服务部署。简单来说流式查询就是把“一口吃成胖子”的查询变成了“细嚼慢咽”的处理过程。2. 适用场景与使用边界2.1 谁需要流式查询后端开发者需要从数据库导出大量数据到 CSV/Excel 文件。数据平台工程师负责将数据从在线库迁移到离线分析库。报表系统维护者系统需要生成包含数十万甚至百万行数据的复杂报表。任何面临OutOfMemoryError: Java heap space的 Java 服务开发者。2.2 它能解决什么问题核心是解决内存瓶颈。传统查询ListUser users userMapper.selectAll();会在内存中构造一个包含所有User对象的列表。假设一条记录 1KB100 万条就是 1GB这很容易超过 JVM 堆内存限制触发 OOM。流式查询则是在遍历Cursor时MyBatis 和 JDBC 驱动配合每次只从数据库网络连接中获取少量记录例如几百条到内存处理完后再获取下一批内存中始终只保持一个很小的数据窗口。2.3 不适合什么场景需要全量数据在内存中运算例如需要对整个结果集进行排序、分组、多次遍历。流式查询是单向遍历无法回头。事务时间非常长的场景流式查询通常需要保持数据库连接和事务直到处理完毕长时间占用连接可能影响连接池。网络环境极差因为需要多次网络往返获取数据高延迟网络下总耗时可能比一次性获取更长。对数据库压力敏感流式查询可能会对数据库造成更长时间的读压力持有游标需要评估数据库端的承受能力。2.4 合规与边界数据库兼容性并非所有数据库和 JDBC 驱动都完美支持流式查询需测试目标数据库如 MySQL, PostgreSQL的对应驱动。连接池配置使用如 HikariCP、Druid 等连接池时需注意连接归还机制避免流未关闭导致连接泄漏。资源及时释放必须确保Cursor或ResultSet被正确关闭否则会导致数据库游标泄漏和内存泄漏。3. 环境准备与前置条件在开始编码前请确保你的环境满足以下条件。这不是一个需要 GPU 或特定硬件的 AI 模型但对 Java 环境和数据库有一定要求。Java 开发环境JDK 8 或更高版本推荐 JDK 11 或 17。Maven 或 Gradle 构建工具。一个 IDE如 IntelliJ IDEA 或 Eclipse。MyBatis 依赖在pom.xml中引入 MyBatis 核心依赖。本文示例基于 MyBatis 3.5.x 以上版本。dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.10/version !-- 请使用最新稳定版 -- /dependency如果使用 Spring Boot可以直接使用mybatis-spring-boot-starter。dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.0/version /dependency数据库与驱动一个测试数据库如 MySQL、PostgreSQL。对应的 JDBC 驱动依赖。!-- MySQL 示例 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version scoperuntime/scope /dependency测试数据准备一张数据量较大的表用于测试。可以编写脚本插入 50 万到 100 万条记录。表结构可以简单如下CREATE TABLE large_user ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(255) DEFAULT NULL, email varchar(255) DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;4. 两种流式查询实现方式详解MyBatis 主要提供了两种方式实现流式查询使用CursorT接口和使用ResultHandler。我们分别来看。4.1 方式一使用CursorT接口推荐Cursor提供了迭代器风格的 API使用起来最直观类似于遍历一个List但背后是流式读取。第一步Mapper 接口定义在 Mapper 接口中将返回类型定义为CursorYourEntity。import org.apache.ibatis.cursor.Cursor; public interface UserMapper { CursorUser selectAllUsersStreaming(); }第二步XML 映射文件在对应的 XML 映射文件中编写 SQL。这里不需要特殊的配置MyBatis 会根据返回类型自动处理。!-- UserMapper.xml -- mapper namespacecom.example.mapper.UserMapper select idselectAllUsersStreaming resultTypecom.example.entity.User SELECT id, name, email, created_at FROM large_user !-- 可以添加 WHERE 条件但注意流式查询最好有排序保证顺序 -- ORDER BY id ASC /select /mapper第三步Service 层调用与遍历这是关键步骤必须在一个数据库事务中完成遍历并且确保最终关闭Cursor。import org.apache.ibatis.cursor.Cursor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.io.IOException; Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper userMapper; } /** * 使用 Transactional 确保整个遍历过程在一个事务内 * 事务保证了数据库连接和游标的一致性 */ Transactional public void processAllUsersWithCursor() { // 获取 Cursor try (CursorUser cursor userMapper.selectAllUsersStreaming()) { // 遍历 Cursor每次迭代才会真正从数据库取数据 for (User user : cursor) { // 在这里处理每一条用户数据 // 例如写入文件、发送消息、进行计算 System.out.println(Processing user: user.getName()); // 模拟处理耗时 // Thread.sleep(1); // 谨慎使用会拖慢整体速度 } } catch (IOException e) { // Cursor 的 close 方法会抛出 IOException throw new RuntimeException(Failed to close cursor, e); } // try-with-resources 会自动调用 cursor.close()释放资源 } }核心要点Transactional注解至关重要。流式查询需要在整个遍历期间保持同一个数据库连接和事务上下文。使用try-with-resources语句确保Cursor被自动关闭释放底层ResultSet和数据库游标。遍历Cursor时才真正从数据库获取数据。userMapper.selectAllUsersStreaming()本身执行很快它只是返回了一个Cursor对象并没有立即加载数据。4.2 方式二使用ResultHandlerResultHandler是一个回调接口MyBatis 在读取每一条结果时都会调用它。这种方式控制粒度更细但代码稍显繁琐。第一步定义 ResultHandler 实现类import org.apache.ibatis.session.ResultHandler; import com.example.entity.User; public class UserResultHandler implements ResultHandlerUser { private int count 0; Override public void handleResult(ResultContext? extends User resultContext) { // 获取当前结果对象 User user resultContext.getResultObject(); // 处理当前对象 System.out.println(Handling user[ (count) ]: user.getName()); // 可以通过 resultContext 停止处理 // if (count 10000) { // resultContext.stop(); // } } public int getCount() { return count; } }第二步Mapper 接口定义Mapper 方法返回类型为void需要传入ResultHandler参数。public interface UserMapper { void selectAllUsersWithHandler(ResultHandlerUser handler); }第三步XML 映射文件与普通查询一致。mapper namespacecom.example.mapper.UserMapper select idselectAllUsersWithHandler resultTypecom.example.entity.User SELECT id, name, email, created_at FROM large_user ORDER BY id ASC /select /mapper第四步Service 层调用同样需要在事务中执行。Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper userMapper; } Transactional public void processAllUsersWithHandler() { UserResultHandler handler new UserResultHandler(); // 执行查询结果会通过回调给 handler userMapper.selectAllUsersWithHandler(handler); System.out.println(Total processed: handler.getCount()); } }两种方式对比CursorT更现代代码更简洁类似迭代器模式推荐大多数场景使用。ResultHandler更底层可以在处理每条数据时获得ResultContext有能力提前停止处理 (resultContext.stop())适合需要复杂控制流的场景。5. 功能测试与效果验证理论讲完了我们来实际测试一下看看流式查询到底如何解决 OOM 问题。5.1 测试准备制造内存危机首先我们写一个会 OOM 的传统查询方法作为对比。// UserMapper.java ListUser selectAllUsers(); // 传统方法 // UserService.java public void processAllUsersTraditional() { ListUser allUsers userMapper.selectAllUsers(); // 一次性加载所有数据到内存 for (User user : allUsers) { System.out.println(Processing user: user.getName()); } System.out.println(Total users: allUsers.size()); }在application.yml中我们为测试限制 JVM 堆内存让问题更容易暴露。# Spring Boot 测试配置 spring: datasource: url: jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneUTC username: root password: yourpassword # 通过JVM参数限制堆内存更直接例如 -Xmx256m5.2 测试一传统查询 vs 流式查询内存占用启动服务使用-Xmx256m最大堆内存 256MB启动你的 Spring Boot 应用。执行传统方法调用processAllUsersTraditional()处理一个 50 万条记录的表。极大概率你会看到控制台输出java.lang.OutOfMemoryError: Java heap space程序崩溃。执行流式方法重启服务调用processAllUsersWithCursor()。观察控制台它会开始一条条输出进程不会崩溃。使用 JConsole、VisualVM 或jcmd pid GC.heap_info工具观察堆内存使用情况你会发现内存使用是一条平稳的曲线峰值远低于 256MB而不会出现一个陡峭的峰值。判断成功流式查询方法在有限内存下能完成全部数据处理且内存占用平稳传统方法抛出 OOM 异常。5.3 测试二结合业务逻辑导出 CSV流式查询最常见的用途是数据导出。我们来模拟将百万用户导出为 CSV 文件。import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVPrinter; import org.springframework.transaction.annotation.Transactional; import java.io.FileWriter; import java.io.IOException; import java.io.Writer; Service public class DataExportService { private final UserMapper userMapper; public DataExportService(UserMapper userMapper) { this.userMapper userMapper; } Transactional // 关键保持事务 public void exportUsersToCsv(String filePath) throws IOException { // 使用 try-with-resources 管理文件写入器和 CSV 打印机 try (Writer writer new FileWriter(filePath); CSVPrinter csvPrinter new CSVPrinter(writer, CSVFormat.DEFAULT.withHeader(ID, Name, Email, CreatedAt)); CursorUser cursor userMapper.selectAllUsersStreaming()) { // 关键获取流式 Cursor for (User user : cursor) { // 将每一条记录写入 CSV 行 csvPrinter.printRecord(user.getId(), user.getName(), user.getEmail(), user.getCreatedAt()); // 每处理10000条刷新一下缓冲区避免内存中积累太多字符串 // if (cursor.getCurrentIndex() % 10000 0) { // csvPrinter.flush(); // } } // 最终刷新并关闭 csvPrinter.flush(); } // 自动关闭 cursor, csvPrinter, writer System.out.println(CSV export completed to: filePath); } }效果验证运行此方法即使数据量很大也不会导致 OOM。同时由于是边读边写生成的文件会逐渐变大而不是等所有数据在内存中组装好才一次性写入。5.4 测试三性能与资源观察内存使用jstat -gc pid 1000观察 GC 情况。流式查询下Young GC 可能更频繁因为不断创建和回收 User 对象但几乎不会发生 Full GC。传统查询则会因为分配超大数组触发多次 Full GC 甚至直接 OOM。数据库连接通过数据库的SHOW PROCESSLIST;命令可以看到流式查询执行期间会有一个连接长时间处于Sending data状态直到遍历结束。这意味着连接被占用强调了事务管理和及时关闭游标的重要性。耗时对比流式查询的总耗时可能略高于传统查询因为多了多次网络往返的开销。但对于内存敏感的场景用稍长的时间换取服务的稳定性是绝对值得的。6. 高级配置与调优要让流式查询工作得更好可能需要一些额外的配置。6.1 配置fetchSizefetchSize是 JDBC 驱动的一个提示告诉数据库每次网络往返返回多少条记录。设置一个合理的值可以优化性能。!-- 在 MyBatis 的 select 标签中配置 -- select idselectAllUsersStreaming resultTypecom.example.entity.User fetchSize1000 SELECT id, name, email, created_at FROM large_user ORDER BY id ASC /selectMySQL需要连接参数useCursorFetchtrue并且驱动版本要支持。在 JDBC URL 中添加jdbc:mysql://...?useCursorFetchtrue。然后fetchSize才会生效。PostgreSQL原生支持配置fetchSize即可。Oracle也支持但可能有自己的语法要求。6.2 配置resultSetType设置结果集类型为FORWARD_ONLY默认和READ_ONLY这是流式查询的典型配置。select idselectAllUsersStreaming resultTypecom.example.entity.User fetchSize1000 resultSetTypeFORWARD_ONLY SELECT id, name, email, created_at FROM large_user ORDER BY id ASC /select6.3 连接池注意事项以 HikariCP 为例需要确保连接不会因为查询时间过长而被回收或检测为“泄漏”。spring: datasource: hikari: connection-timeout: 60000 # 连接超时时间流式查询可能较长适当调大 max-lifetime: 1800000 # 连接最大生命周期调大 leak-detection-threshold: 60000 # 泄漏检测阈值如果流式查询处理时间可能超过此值需要调大或关闭最佳实践对于已知的长时间流式查询任务可以考虑使用独立的、配置更宽松的数据源。7. 常见问题与排查方法在实际使用流式查询时你可能会遇到以下问题。问题现象可能原因排查方式解决方案抛出InvalidResultSetAccessException或游标相关错误1. 遍历Cursor时不在事务中。2. 遍历Cursor的代码和获取Cursor的代码不在同一个事务/连接中。检查方法是否被Transactional注解且传播级别正确通常是REQUIRED。确保整个遍历过程在一个事务内。将获取和遍历Cursor的代码放在同一个Transactional方法中。数据库连接耗尽或连接泄漏Cursor没有正确关闭导致数据库连接和游标未被释放。检查代码是否使用了try-with-resources或finally块确保cursor.close()被调用。查看数据库连接池监控。强制使用try (Cursor c mapper.query())语法。确保 Service 方法不被异常中断。流式查询速度比普通查询慢很多1.fetchSize设置过小网络往返次数过多。2. 数据库端排序或没有索引导致流式传输本身慢。3. 每行数据处理逻辑太重。1. 检查并调整fetchSize。2. 对ORDER BY字段加索引。3. 分析处理逻辑的耗时。1. 适当增大fetchSize如 1000-5000。2. 优化 SQL 和索引。3. 考虑异步或批量处理单行数据。MySQL 下fetchSize不生效JDBC URL 缺少useCursorFetchtrue参数。检查 JDBC 连接字符串。在 MySQL JDBC URL 后添加useCursorFetchtrue。注意这可能会在某些版本和场景下有副作用需测试。处理过程中程序中断游标未关闭程序崩溃、被杀死或发生未捕获异常。查看应用日志和数据库的SHOW PROCESSLIST可能看到大量Sleep状态的连接。1. 加强代码健壮性做好异常处理在catch块中关闭资源。2. 设置合理的数据库wait_timeout和interactive_timeout让数据库端自动关闭超时空闲连接。在 Web 请求中使用流式查询返回Cursor试图在 HTTP 请求响应中直接返回Cursor。Cursor的生命周期与数据库连接绑定HTTP 请求结束后连接可能已关闭导致后续遍历失败。绝对禁止在 Controller 层返回Cursor。流式查询应在 Service 层完成所有数据处理将最终结果如文件路径、处理状态返回给前端。8. 最佳实践与使用建议事务是必须的这是流式查询能工作的前提务必牢记。资源必须关闭使用try-with-resources是关闭Cursor最安全、最简洁的方式。先测试后上线在生产环境使用前务必在测试环境用同等数据量进行充分测试验证内存、性能和稳定性。监控与告警对使用流式查询的服务加强对其数据库连接数、长时间查询的监控。区分使用场景明确你的场景是“需要遍历大量数据并处理每一条”而不是“需要将所有数据加载到内存进行复杂计算”。前者用流式后者可能需要考虑分页查询或移到大数据平台处理。SQL 优化即使流式查询SQL 本身也要高效。确保WHERE条件和ORDER BY的字段有索引避免全表扫描的流式查询那将是灾难。批处理思想在ResultHandler或遍历Cursor时可以考虑每积累 N 条记录进行一次批量操作如批量插入到另一个库而不是每条都操作这能显著提升吞吐量。超时控制对于可能执行时间非常长的流式查询任务要有超时中断机制防止一个任务永远占用连接。9. 总结与下一步MyBatis 流式查询是一个强大的工具它能将你从“大数据量查询必 OOM”的魔咒中解救出来。它的核心价值在于用可控的内存开销换取处理海量数据的能力。最值得尝试的点如果你的系统中存在任何将数据库大量数据导出为文件、同步到其他系统或进行逐条清洗转换的任务并且这些任务曾引发过内存告警那么流式查询应该是你的首选优化方案。最先应该验证的功能从一个简单的Cursor遍历开始搭配Transactional和try-with-resources先确保基础流程能在你的开发环境跑通。最容易踩的坑忘记加Transactional这是新手最容易犯的错误会导致游标无效。在 Web 层返回或泄漏Cursor记住流式处理必须在 Service 层完成闭环。不关闭Cursor导致连接和游标泄漏这是生产事故的隐患。下一步可以探索的方向结合 Spring Batch对于更复杂、需要容错、重启能力的大批量数据处理任务可以将 MyBatis 流式查询作为 Spring Batch 的ItemReader构建健壮的批处理作业。异步处理在流式遍历过程中将获取到的数据放入队列由消费者异步处理可以进一步提高吞吐量避免流式查询本身成为瓶颈。多数据源流式查询从一个数据库流式读取同时写入另一个数据库实现高效的数据迁移。通过本文你应该已经掌握了 MyBatis 流式查询从原理到实战的全部要点。它不是一个复杂的技术但却是构建稳健后端服务不可或缺的技能。建议你将文中的示例代码在本地运行一遍亲眼见证它如何优雅地处理百万数据而不崩这种体验远比阅读文字来得深刻。