news 2026/9/30 2:50:25

MyBatis 流式查询实战:避免数据量过大导致 OOM

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis 流式查询实战:避免数据量过大导致 OOM

1. 为什么普通查询会导致 OOM

在 MyBatis 中,常规查询通常调用selectList、selectMap或自定义 Mapper 方法返回List、Map等集合。这些 API 会由 MyBatis 底层通过DefaultResultSetHandler把数据库返回的ResultSet全部读取到内存中,并封装为 Java 对象。当数据量较小(例如几千行)时,这种一次性装载的方式简单直观;但当结果集达到几十万、上百万甚至千万行时,所有行以及对应的 Java 对象会同时驻留在 JVM 堆内存中,很容易触发OutOfMemoryError。

例如下面这段最普通的查询方式,在百万级数据下几乎是必定内存告急的:

xml

<select id="selectAllUsers" resultType="com.example.User"> SELECT id, name, email FROM t_user </select>

java

List<User> users = userMapper.selectAllUsers(); for (User user : users) { // 处理每条用户数据 }

问题的本质并不在于 SQL 写错,而在于「集合接收」这个动作要求数据库驱动一次性返回全部记录,并由 MyBatis 把这些记录全部物化成对象。要解决这个问题,核心思路是改为逐条读取、逐条处理、用完即释放,也就是流式查询。

2. 流式查询的核心概念

流式查询并不是让数据库不返回数据,而是改变客户端接收数据的方式。普通查询是「批量拉取」:数据库把符合条件的行全部发送到客户端,驱动和 MyBatis 把它们全部暂存到内存中。流式查询则是「边拉取边处理」:应用一次只从数据库游标中取出一行,处理完后再取下一行,内存中始终只保留少量对象。

在 JDBC 层,这个能力主要由Statement的fetchSize和ResultSet的游标行为控制。MyBatis 在映射 SQL 时是否支持真正的流式读取,取决于执行器类型。MyBatis 提供了三种执行器:

  • SIMPLE:默认执行器,每次执行新建Statement,执行完关闭;执行查询后通常会把结果集读入内存,再返回。

  • REUSE:复用Statement,适合批量执行,但查询行为本质上与 SIMPLE 类似。

  • BATCH:主要用于批量更新,不适合常规查询。

真正能实现游标式读取的是将ResultSet的fetchSize设置为较小值,并限制ResultSetType为FORWARD_ONLY,让驱动按批次与数据库交互,而不是一次性把所有行加载到客户端。

3. MyBatis 中实现流式查询的三种方式

3.1 使用 Cursor 游标

MyBatis 3.2 之后提供了Cursor<T>接口。Mapper 方法的返回值可以声明为Cursor<T>,这样 MyBatis 不会把结果全部装进 List,而是返回一个可遍历的游标对象。

xml

<select id="selectAllUsersWithCursor" resultType="com.example.User"> SELECT id, name, email FROM t_user </select>

java

public interface UserMapper { Cursor<User> selectAllUsersWithCursor(); }

调用方需要拿到SqlSession,并在try-finally中关闭游标:

java

try (SqlSession sqlSession = sqlSessionFactory.openSession()) { UserMapper mapper = sqlSession.getMapper(UserMapper.class); try (Cursor<User> cursor = mapper.selectAllUsersWithCursor()) { Iterator<User> iterator = cursor.iterator(); while (iterator.hasNext()) { User user = iterator.next(); // 处理单条数据 } } }

注意,Cursor依赖SqlSession保持打开状态。如果SqlSession提前关闭,游标通常无法继续读取数据。此外,Cursor 默认不是线程安全的,不应在多个线程中同时遍历。

3.2 使用 ResultHandler 处理器

Mapper 方法还可以额外接收一个ResultHandler参数。这样 MyBatis 每处理一行,就会回调一次handleResult,由调用方决定如何处理,不再要求返回大集合。

xml

<select id="selectAllUsers" resultType="com.example.User"> SELECT id, name, email FROM t_user </select>

java

public interface UserMapper { void selectAllUsers(ResultHandler<User> handler); }

调用时传入匿名处理器:

java

userMapper.selectAllUsers(resultContext -> { User user = resultContext.getResultObject(); // 处理单条数据,例如写入文件或发送到消息队列 });

使用ResultHandler时,内存水位主要取决于单条记录的大小和处理逻辑,不会随着总行数线性增长。它通常是业务代码中最容易落地的方式。

3.3 设置 fetchSize 配合 FORWARD_ONLY 结果集

无论使用 Cursor 还是 ResultHandler,要让 JDBC 驱动真正采取流式分批次拉取,还需要在映射语句中配置fetchSize。不同数据库驱动对fetchSize的支持程度不同。

xml

<select id="selectAllUsersWithCursor" resultType="com.example.User" fetchSize="1000" resultSetType="FORWARD_ONLY"> SELECT id, name, email FROM t_user </select>

也可以在全局配置中设置defaultFetchSize:

xml

<settings> <setting name="defaultFetchSize" value="1000"/> </settings>

resultSetType="FORWARD_ONLY"表示结果集只能向前遍历。对于 MySQL 驱动,通常还需要在 JDBC URL 上添加useCursorFetch=true,否则较大的结果集可能仍然被一次性读到内存。对于 PostgreSQL,驱动默认会按 fetchSize 分批获取;对于 Oracle,则需要使用ResultSet.TYPE_FORWARD_ONLY配合合适的 fetchSize。实际项目中应结合具体数据库版本进行测试。

3.4 三种方式对比与选择建议

维度Cursor 游标ResultHandler 处理器fetchSize + FORWARD_ONLY
适用场景需要在业务代码中手动控制遍历节奏逐条处理,处理逻辑与读取过程高度耦合作为底层参数,配合 Cursor 或 ResultHandler 使用
内存占用低,仅保留游标当前位置的对象低,仅保留当前处理的行取决于是否与 Cursor/ResultHandler 配合
连接占用整个遍历期间占用连接整个遍历期间占用连接同左,取决于调用方式
实现复杂度中,需要管理 SqlSession 与 Cursor 生命周期低,直接传入回调即可低,但需根据数据库驱动调整参数
是否依赖 SqlSession 保持打开是是是
典型数据库支持MySQL 需useCursorFetch=true;PostgreSQL 默认支持;Oracle 需配合 TYPE_FORWARD_ONLY同左同左

选择建议:如果只是希望快速替换现有大结果集查询,优先使用ResultHandler,改动最小、语义最清晰;如果需要在遍历过程中灵活控制取数节奏、手动 break 或提前终止,使用Cursor更合适;fetchSize和FORWARD_ONLY不是独立方案,而是前两者的必要配套参数,必须根据目标数据库驱动确认是否真正生效。

4. 流式查询的注意事项

4.1 连接占用时间更长

流式查询期间,数据库连接一直处于活动状态,直到游标关闭或遍历完成。如果单次遍历耗时过长,会占用连接池中的连接,进而拖垮其他请求。因此流式查询不适合直接在在线请求线程中长时间执行,更适合数据导出、批量同步、报表生成等离线任务。

4.2 必须关闭游标和 SqlSession

无论使用 Cursor 还是 ResultHandler,都要确保SqlSession、Cursor被正确关闭。推荐使用try-with-resources,否则可能造成连接泄漏,反而引发更严重的问题。

4.3 事务一致性

如果遍历过程中其他事务修改了数据,不同数据库在默认隔离级别下的表现可能不同。MySQL 默认的 REPEATABLE READ 会给流式查询建立一致性快照,Oracle 则可能读到后续提交的变更。对于导出任务,是否需要严格一致性快照,应根据业务要求评估。

4.4 不要在遍历中执行耗时阻塞操作

遍历过程中每取一行就同步调用外部接口,会导致锁表时间或者连接占用时间被无限拉长。建议先把数据分批写入本地中间文件、对象存储或本地队列,再异步处理,降低数据库连接占用和事务持续时间。

4.5 与分页查询的取舍

流式查询解决的是「一次导出/处理大量数据」的问题,分页查询解决的是「按页展示」的问题。如果业务可以自然分页,使用LIMIT或键集分页往往更可控;如果必须导出全量数据,流式查询比深度分页的OFFSET方式更稳定。两者不是对立的,而是适用场景不同。

5. 实战:流式导出百万级数据到 CSV

下面给出一个使用 MyBatis 流式查询将海量数据导出到 CSV 文件的完整示例。示例中通过ResultHandler逐条处理,并使用BufferedWriter分批写入文件,避免在内存中保存完整行集合。

java

public class UserExportService { private final SqlSessionFactory sqlSessionFactory; private static final int FLUSH_SIZE = 2000; public UserExportService(SqlSessionFactory sqlSessionFactory) { this.sqlSessionFactory = sqlSessionFactory; } public void exportToCsv(String filePath) throws IOException { try (SqlSession sqlSession = sqlSessionFactory.openSession(); BufferedWriter writer = Files.newBufferedWriter(Paths.get(filePath))) { UserMapper mapper = sqlSession.getMapper(UserMapper.class); final int[] count = {0}; final StringBuilder buffer = new StringBuilder(FLUSH_SIZE * 64); mapper.selectAllUsers(resultContext -> { User user = resultContext.getResultObject(); buffer.append(user.getId()) .append(',') .append(escape(user.getName())) .append(',') .append(escape(user.getEmail())) .append('\n'); count[0]++; if (count[0] % FLUSH_SIZE == 0) { try { writer.write(buffer.toString()); } catch (IOException e) { throw new UncheckedIOException(e); } buffer.setLength(0); } }); if (buffer.length() > 0) { writer.write(buffer.toString()); } writer.flush(); } } private String escape(String value) { if (value == null) { return ""; } return "\"" + value.replace("\"", "\"\"") + "\""; } }

耗时较长的导出任务通常运行在后台线程或定时任务中,并设置独立的连接池,避免与在线业务争抢连接。可以在任务启动前打印数据量估计值和每批次读取数量,便于监控进度。

6. 常见问题排查

6.1 仍然发生 OOM

先检查是否真正使用流式接收:Mapper 方法如果仍然返回List,即使配置了fetchSize,MyBatis 最终可能还是会将结果全部装入 List。还应确认数据库驱动是否支持流式模式,例如 MySQL 是否配置了useCursorFetch=true,以及连接 URL 中的其他参数是否与流式模式冲突。

6.2 连接被提前关闭

常见原因是SqlSession在游标遍历完之前就被关闭。使用ResultHandler的场景中,处理逻辑必须位于SqlSession打开期间;不要在方法返回后再异步读取游标。使用 Cursor 时也要保证 close 顺序正确。

6.3 遍历速度很慢

可能是fetchSize设置过小,导致来回拉取次数过多。可以适当增大 fetchSize,例如从 100 调整到 1000、5000,观察吞吐变化。也可能是处理逻辑本身存在大量 IO 或网络等待,需要与读取过程解耦。

6.4 数据不一致

如果在线程遍历期间数据被修改,要结合数据库隔离级别和业务要求判断是否可以接受。对导出准确性要求高的场景,可以在只读事务中执行,或选择业务低峰期执行。

7. 最佳实践总结

  • 优先使用ResultHandler或Cursor逐条处理,避免返回大集合。

  • 为批量读取场景配置合理的fetchSize,并根据数据库驱动调整连接参数。

  • 将流式查询放在后台任务中执行,避免长时间占用在线请求线程。

  • 使用try-with-resources严格关闭SqlSession与Cursor。

  • 部分数据库需要显式开启游标模式,如 MySQL 的useCursorFetch=true。

  • 先在小数据集上验证,再逐步扩大数据量,并监控堆内存、GC 和连接池状态。

  • 能分页处理的场景优先分页,只能全量导出时再选择流式查询。

8. 流式查询在 Spring Boot 中的集成

8.1 使用 SqlSessionTemplate 获取 Mapper

在 Spring Boot 项目中,MyBatis 通常通过 mybatis-spring 与 Spring 事务、连接池集成。流式查询同样可以基于SqlSessionTemplate完成,但要特别注意:SqlSessionTemplate由 Spring 管理,不能像原生 MyBatis 那样手动关闭SqlSession。推荐通过 Mapper 方法直接声明Cursor或ResultHandler参数,让 Spring 在事务边界内保持连接打开。

java

@Service public class UserStreamService { private final UserMapper userMapper; public UserStreamService(UserMapper userMapper) { this.userMapper = userMapper; } @Transactional(readOnly = true) public void streamProcess(Consumer<User> consumer) { userMapper.selectAllUsers(resultContext -> { User user = resultContext.getResultObject(); consumer.accept(user); }); } }

这里把处理逻辑放在@Transactional(readOnly = true)方法内执行,目的是让本次流式读取在同一个数据库连接和事务上下文中完成。遍历结束后方法返回,连接由 Spring 交还给连接池。

8.2 避免在流式遍历时触发独立事务

如果在ResultHandler回调里再次调用其他 Mapper 写方法,且这些写方法带有独立的@Transactional(propagation = Propagation.REQUIRES_NEW),会新开一个连接,可能与当前流式读取连接互相等待。建议将写入操作批量缓冲到本地队列或内存批次中,待读取结束后再统一提交,或在同一事务中执行。

8.3 为导出任务配置独立线程池

耗时导出任务不应占用 HTTP 请求线程。常见做法如下:

java

@Async("exportExecutor") public void asyncExport(String filePath) throws IOException { userStreamService.streamProcess(user -> { // 写入目标文件或消息队列 }); }

同时为导出任务配置独立的连接池或连接数上限,避免大量流式查询同时执行时把在线业务连接耗尽。

9. 性能测试与监控

9.1 内存占用对比

可以通过简单实验观察普通查询和流式查询的内存差异:分别查询 10 万、50 万、100 万行数据,使用 JVisualVM 或 Arthas 观察堆内存变化。普通selectList的内存占用会随行数线性上升,而ResultHandler模式下堆内存几乎保持平稳。

9.2 fetchSize 与吞吐量调优

fetchSize过小会导致应用与数据库之间往返次数过多,吞吐下降;过大则可能让单次批次占用更多网络和内存。建议从 500 到 1000 开始测试,再逐步调整为 2000、5000 对比。不同驱动和网络环境下最优值不同,应以实测数据为准。

9.3 需要监控的关键指标

  • JVM 堆内存和 GC 次数,确认是否还会出现内存尖峰。

  • 数据库连接池活跃连接数和等待时间,避免流式任务占满连接。

  • 导出任务耗时、单批写入耗时和最终文件大小。

  • 数据库端慢查询和网络传输量。

10. 与其他大数据处理方案的对比

10.1 分页查询

分页查询适合前端按页展示,但深分页使用OFFSET会导致数据库扫描成本越来越高。如果只是导出全量数据,流式查询通常比深分页更稳定。

10.2 数据库自带导出工具

MySQL 的mysqldump、SELECT INTO OUTFILE,Oracle 的 SQL*Plus 等工具也能导出大数据,但灵活性不如应用层流式查询。应用层方案可以在读取时同步做字段脱敏、格式转换、业务过滤和上报监控。

10.3 批处理框架

Spring Batch 等框架提供了 chunk 处理、重试、跳过、作业状态管理等能力,适合复杂批处理流程。如果只是简单导出或清洗,直接用 MyBatis 流式查询更轻量;如果需要调度、断点续跑、失败重试等能力,可以结合 Spring Batch 使用。

11. 总结

MyBatis 流式查询的核心不是使用某个特殊 SQL,而是改变数据接收模型:从小集合一次性装载改为游标逐条读取。实现上优先使用ResultHandler或Cursor,并配合fetchSize和数据库驱动参数让 JDBC 真正进入流式模式。落地时要关注连接占用、事务一致性、关闭顺序和后台任务隔离,避免解决了 OOM 又引入连接耗尽或长事务问题。对于必须全量处理的大数据场景,MyBatis 流式查询是一个轻量、可控且易于集成的选择。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 2:50:24

面试官:如何用一段代码证明 JVM 加载类是懒加载模式

1. 从一道高频面试题说起在 Java 后端面试中&#xff0c;类加载机制几乎是必考的内容。很多同学能背出「双亲委派模型」「加载、验证、准备、解析、初始化」这些概念&#xff0c;但当面试官追问一句&#xff1a;「你能不能现场写一段代码&#xff0c;证明 JVM 对类的加载是懒加…

作者头像 李华
网站建设 2026/9/30 2:48:14

2026网盘视频播放器推荐:无广告全平台更流畅

选网盘视频播放器&#xff0c;优先看网盘直连、硬件解码和多端同步&#xff1b;想0成本、无广告并在电脑、手机和电视间续播&#xff0c;网易爆米花&#xff08;https://bmh.163.com/windows/&#xff1b;https://bmh.163.com/mac&#xff09;更适合建立统一影视库&#xff0c;…

作者头像 李华
网站建设 2026/9/30 2:48:03

WPC车载无线充

曦国中&#xff0c;易伏南。 曦华&#xff0c;国芯&#xff0c;中颖。易冲&#xff0c;伏达&#xff0c;南芯车载手机无线充电发展史&#xff08;全球国内&#xff0c;聚焦座舱内置手机无线充&#xff0c;不是整车高压底盘无线充电&#xff09;核心主线&#xff1a;消费Qi标准诞…

作者头像 李华
网站建设 2026/9/30 2:47:36

推理引擎 vLLM 的软件架构解析

目录 文章目录目录vLLM 核心技术PageAttention连续批处理软件架构LLMEngine 和 AsyncLLMEngineEngineCoreScheduler调度单元 SequenceGroup调度策略KVCacheManagerBlockPoolExecutorWorkerCacheEngineModelRunner核心业务流程加载模型与预分配显存流程加载模型预分配显存调度与…

作者头像 李华