☰
流式查询实战:从MyBatis到JVM,彻底解决大数据量导出OOM
2026/10/14 4:20:15 网站建设 项目流程

一次线上导出任务,服务直接没了。日志里躺着一行OutOfMemoryError: Java heap space,当时我还没反应过来——导出的数据表不过几十万行,平时开发环境跑得好好的,怎么一上生产就炸了。

后面排查才发现,问题根本不在导出模板,也不在文件生成逻辑,而是我写的那句SELECT * FROM order_table。数据被一次性从数据库拉回客户端,整个结果集在 JVM 里被对象化、放进集合,直接撑爆了堆内存。大数据量导出的 OOM 问题,几乎都出在这个环节。

这篇文章我会从一个实际会遇到的服务崩溃案例切入,讲清楚流式查询的原理、落地代码、边界条件,以及它和分页、临时表方案的取舍。如果你的系统里也有导出几百万行、甚至上千万行的需求,或者你做数据迁移、全量统计时经常被 OOM 困扰,这篇文章应该对你有用。

1. 导出场景下,为什么会把 JVM 内存跑爆

1.1 一次全量查询的内存轨迹

假设有一张订单表,单行数据加上索引、字段,平均 1KB 左右,表里总共 500 万行。执行SELECT * FROM orders WHERE create_time >= ...这种查询,JDBC 驱动会尝试把所有匹配行都拉回客户端。对于 MySQL 来说,默认情况下结果集是一次性传输的,不会因为你忘了写 fetchSize 就自动变成流式。

数据到达客户端之后,MyBatis 或 Hibernate 这类 ORM 框架还会做一件事:把结果集里的每一行映射成一个 Java 对象,放进 ArrayList。这一步才是内存杀手。

一个对象在 JVM 里的开销远比裸数据大。对象头占 12 到 16 字节,字段对齐要补位,List 本身还有数组扩容的开销。同一个订单数据,数据库里可能只占几十 KB 的内存,被 Java 对象化之后,膨胀 2 到 5 倍很正常。500 万行数据,最后堆内存里可能多出 2GB 到 5GB 的对象,一个 4G 堆的实例不死才怪。

1.2 OOM 的真正来源:集合持有 + 生命周期过长

很多人以为是数据库把几十万行数据一次性传给客户端导致 OOM。但其实单次网络传输几十 MB、几百 MB 并不致命,驱动缓冲的数据可以在下一轮 GC 中被回收。真正致命的是我们把查询结果对象化之后,放在一个长期存活的集合里。

比如一段很常见的代码:

// 这里就是 OOM 的高发区 List<OrderDO> allOrders = orderMapper.selectAll(); for (OrderDO order : allOrders) { writeCsvLine(order); }

selectAll()把结果全部塞进了allOrders,而allOrders又占着栈引用,GC 根本回收不了。只要堆大小小于结果集的峰值内存,OOM 就是必然的。

我曾经见过一个更荒谬的写法:为了解决 OOM,在循环里手动调用System.gc()。结果当然没用,反而拖慢了整个导出流程。GC 只能回收不可达对象,集合还被引用着的时候,一次 Full GC 也没办法把它们请出去。

1.3 你以为的"优化"很多是伪优化

  • 把 Xmx 从 2G 调到 8G:只是把爆内存的时间点往后推迟,数据量再涨一截照样崩,而且调大堆会增加 GC 停顿,接口响应更慢。
  • 分批拼接 Excel 再导出:很多 Excel 导出库(尤其同时操作多个 Sheet 的)会先把整个 workbook 对象立在内存里,最终内存还是会被打满。
  • 用 FastJSON 序列化成 JSON 再一次性返回:序列化过程同样会把所有对象的字符串副本同时存在堆里,雪上加霜。
  • 只查需要的字段:这个有一定效果,但只是降低了单个对象的大小,没有解决"N 个对象同时存活"的本质问题。

根本上,我们需要的是让数据经过内存,而不是住在内存里。

2. 流式查询的核心逻辑:让数据"过"内存而不是"住"内存

2.1 流式查询到底是什么

流式查询的本质,是让数据库服务端维护游标,客户端每次只从游标上取一批数据,处理完再取下一条。数据不是一口气从服务端搬到客户端内存,而是像水管一样持续流动,每次到达内存的只有一小段水流。

生活里最常见的类比就是接水:一次性查询相当于拿一个杯子去接一桶水,杯子不够大必然溢出;流式查询相当于把水龙头开着,杯子接满一杯就倒掉,再继续接下一杯。水还是在持续流动,但占用内存的永远只有"正在处理的这一杯"。

在 JDBC 层面,流式查询的表现就是ResultSet.next()逐行返回,而不是一个装满的集合。你取一行,处理一行,取完就丢,内存中同时存活的只有一行对象。

2.2 游标和网络协议层面发生了什么

这里需要补一点底层知识。以 MySQL 为例,普通查询默认使用"一次性传输"模式,驱动会发起查询后把完整结果集缓冲到客户端内存。MySQL 驱动其实支持流式模式,但触发条件比较特殊:

  • ResultSet类型必须是TYPE_FORWARD_ONLY
  • 并发模式必须是CONCUR_READ_ONLY
  • fetchSize必须设置为Integer.MIN_VALUE(即-2147483648)

只有同时满足这三个条件,MySQL 驱动才会让连接进入流式读取状态。此时服务端不会一下子把全部结果集发给客户端,而是等待客户端每次通过游标继续取数。换句话说,真正的"流式"是由服务端游标和客户端驱动共同完成的。

如果用 PostgreSQL,做法又不太一样:通常需要显式声明游标,然后用FETCH FORWARD 500这类语句分批提取。MySQL 的流式模式少写很多 SQL,这是它的便利之处。

2.3 流式查询适合什么场景

以下场景用流式查询收益最大:

  1. 导出 CSV / TSV 文件:数据一行一行写文件,天然适合流式。
  2. 同步数据到下游 MQ:比如把订单表全量推给大数据平台,逐条发送消息,内存占用极低。
  3. 全量表数据的数值计算:求总和、平均值、最大最小值,不需要一次性载入内存。
  4. 数据迁移:从老表搬到新表,逐行读取并插入。

不适合的场景也有,比如需要将全量数据做排序、去重、分组汇总后输出的查询。这些操作本身还是要动用大量内存或临时文件,流式查询只能解决"读取"阶段的内存问题,解决不了"计算"阶段的。

3. 代码落地方案:一套可复用的流式导出骨架

3.1 MyBatis 里的流式配置

现在的项目大多用 MyBatis 或 MyBatis-Plus。先说 MyBatis,使用流式查询有两个办法。

第一种方式,在 Mapper 接口方法上直接加注解:

import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.mapping.ResultSetType; @Select("SELECT id, order_no, amount, create_time FROM orders WHERE create_time >= #{startTime} AND create_time < #{endTime}") @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE) Cursor<OrderDO> scanOrders(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);

注意fetchSize要设置成Integer.MIN_VALUE,这是 MySQL 驱动触发流式模式的关键。如果你写fetchSize = 1000,MySQL 驱动不但不会进入流式模式,还可能因为这个值去驱动层缓冲更多数据。

第二种方式,在 XML 中同样可以设置:

<select id="scanOrders" resultType="com.example.OrderDO" fetchSize="-2147483648" resultSetType="FORWARD_ONLY"> SELECT id, order_no, amount, create_time FROM orders WHERE create_time &gt;= #{startTime} AND create_time &lt; #{endTime} </select>

这里的-2147483648就是Integer.MIN_VALUE。

返回值用Cursor<OrderDO>,它不是一次性把数据加载到内存的List,而是实现了Iterable的游标包装,底层走的就是ResultSet.next()。

3.2 Service 层导出逻辑:让每一行及时消费

拿到Cursor之后,导出的写法就变成了下面这样:

import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) public void exportOrdersToCsv(LocalDateTime startTime, LocalDateTime endTime, Writer writer) throws IOException { try (Cursor<OrderDO> cursor = orderMapper.scanOrders(startTime, endTime)) { Iterator<OrderDO> iterator = cursor.iterator(); BufferedWriter bufferedWriter = new BufferedWriter(writer); int lineCount = 0; while (iterator.hasNext()) { OrderDO order = iterator.next(); bufferedWriter.write(formatCsvLine(order)); bufferedWriter.newLine(); // 每 1000 行刷一次盘,减少频繁 IO if (++lineCount % 1000 == 0) { bufferedWriter.flush(); } } bufferedWriter.flush(); } }

这段代码的核心逻辑是:iterator.next()每调用一次,驱动底层就去游标取下一行数据。写入 CSV 文件后,OrderDO这个局部变量不再被引用,下一轮就可以被 GC 回收。内存里始终只有个别存活对象。

在 Spring 项目里,Service 方法一定要加@Transactional(哪怕只读),这点极其重要。原因在于 MyBatis 的Cursor依赖于底层SqlSession所持有的连接。一旦连接在查询返回后被释放,游标就拿不到数据了。开启事务,连接才会在整个方法执行期间保持不被归还连接池。

如果不想用注解事务,也可以手动控制SqlSession:

SqlSession session = sqlSessionFactory.openSession(); try (Cursor<OrderDO> cursor = session.selectCursor( "com.example.mapper.OrderMapper.scanOrders", Map.of("startTime", startTime, "endTime", endTime))) { for (OrderDO order : cursor) { writeCsvLine(order); } } finally { session.close(); }

这种方式更加直白,但需要自己管理 Session 的关闭。

3.3 写文件的几个细节

流式查询只解决了"读"的问题,"写"这件事也有讲究。

  • 使用缓冲输出流:建议用BufferedWriter或CSVWriter包一层,避免每写一行都触发一次底层 IO。
  • 控制 flush 频率:每 1000 行左右 flush 一次比较合理。频率太高,IO 开销大;频率太低,如果中途导出失败,已经写进缓冲但没落盘的数据会丢失。
  • 文件写出路径避免端口污染:先把内容写入临时文件,导出完成后再 rename 成正式文件名,防止下载过程中的并发问题。
  • 字符集统一:CSV 文件建议带 BOM,用"\uFEFF"开头,不然 Windows 下 Excel 打开会乱码。这是一个很小但很实用的点。

4. 流式查询的边界条件和实际踩坑

4.1 MySQL 驱动的版本差异与 fetchSize 陷阱

MySQL 流式模式最典型的坑就是"设置了但没生效"。很多人在 MyBatis 的 XML 里写了fetchSize="1000",以为开启了流式查询,实际上 MySQL 驱动根本不走流式逻辑,数据还是被一次性缓冲到了客户端。

根据已知的驱动行为:

  • mysql-connector-java5.1.x 和 8.0.x 中,流式模式触发条件依然是fetchSize = Integer.MIN_VALUE+TYPE_FORWARD_ONLY+CONCUR_READ_ONLY。
  • 如果fetchSize > 0,驱动可能把数据批量缓冲到客户端内存,结果集行数越大,内存占用越高。
  • 在部分驱动版本里,流式模式下调用resultSet.getMetaData()会被禁止或导致异常,因为游标开启后元数据已经不在预期位置。

所以如果你发现改完配置内存没有下降,先确认两件事:第一,fetchSize是否真的传入了负数;第二,连接是不是被连接池里的其他配置项重新包装过。

4.2 游标引用失效:连接必须保持活跃

这是刚上手流式查询最容易踩的雷。如果 Service 方法没有事务包裹,MyBatis 在 Mapper 方法执行完就会关闭SqlSession,连接归还连接池。等你再迭代游标时,底层ResultSet早已失效,会抛ResultSet is closed之类异常。

更隐蔽的一点:即使有事务,如果流式读取期间数据库连接被网络代理、负载均衡器切断,游标也会直接失效。长连接持续数分钟的导出任务,会遇到服务器端的wait_timeout,或者客户端 Socket 读超时。真遇到这种场景,我的建议是不要死磕单条流式查询,考虑拆成多个分段任务(见第 5 章)。

4.3 循环中不能再执行同连接 SQL

流式查询期间,当前连接是独占的。MySQL 驱动在流式模式下不允许同一个连接再执行新的查询,否则会报错:

Cannot create more than one active ResultSet per connection

也就是说,你不能在while (iterator.hasNext())里面,又去调orderMapper.selectXXX()查关联数据。这会直接影响同一条连接上的游标。正确的做法是提前把关联数据一次性查好放到内存 Map 里,或者换一个独立连接做辅助查询。

我第一次实现导订单功能时,就做过在循环里查用户昵称的蠢事。当时报错后我还在想,明明开了两个 Mapper 方法,怎么会冲突?其实就是连接被事务占用了,同一个事务只会用一条连接。

4.4 大连接占用可能导致连接池耗尽

流式查询本身不释放连接,是因为连接被事务占用时间很长。如果同时有多个用户发起导出,每个导出都占一个连接几分钟,连接池数量很快会被耗尽,其他正常业务 SQL 全部等着拿连接。

生产环境里需要对导出任务做并发限流。常见的做法是:

  • 导出任务走独立线程池,限制同时只有 1 到 2 个任务运行。
  • 连接池单独划出几个连接给导出场景,避免占满主业务连接池。
  • 控制导出请求频率,比如同一用户十分钟内只能发起一次导出。

5. 兜底策略:流式查询不是银弹,配合分页与临时表

5.1 数据快照问题:导出过程数据变了怎么办

流式查询逐行读取,如果查询本身不在一个事务里,数据库内部可能无法给你一个统一快照。数据可能读到一半,记录被更新或删除,导致导出结果前后不一致。

如果业务要求导出数据是一致性快照,最稳妥的办法还是开启事务,尤其 MySQL 在REPEATABLE READ隔离级别下,普通的读操作会自动获得一致性快照,同一个事务内的读结果是一致的。这也是为什么我在第 3 章的示例里给 Service 方法加上了@Transactional(readOnly = true):它保证连接不释放的同时,也顺手提供了事务内读一致。

但注意,长时间事务也意味着 undo 日志不会被清理,如果导出持续十几分钟,数据库 UNDO 表空间可能膨胀。这里需要根据表大小做权衡。

5.2 流式、分页、临时表的横向对比

说到大数据量导出,不只有流式查询一个解法。我整理了一张对比表,方便你根据场景选型:

方案内存占用数据一致性单连接占用时间适用场景
全量查询高接口外无法保证短数据量小,几十万以内
流式查询低依赖事务长大量数据导出/全量迁移
分页查询中等弱,翻页时数据可能变化短排序规则支持稳定分页
临时表 + 分页低强短复杂聚合后再导出

分页方案常见的做法是:

long lastId = 0; int pageSize = 5000; while (true) { List<OrderDO> page = orderMapper.selectPage(lastId, pageSize); if (page.isEmpty()) break; writePageToFile(page); lastId = page.get(page.size() - 1).getId(); }

这种方式不会长时间占用连接,每批处理完都能释放连接。但它的前提是查询结果必须有稳定排序,一般用主键或唯一键排序才能避免翻页错乱。更深的问题在于,如果数据量大,每一页的 offset 越来越深,性能会下降。所以这种写法更适合用"主键/游标 ID 大于 lastId"的方式,而不是传统的LIMIT offset, size。

临时表方案则是先把需要导出的数据经过一些复杂计算后,插入到一张临时表,再分批从临时表导出。临时表本身可以走流式查询,也可以走分页,自由度更高。缺点是导入导出两步操作,总体上慢一些。

5.3 导出任务队列化与进度展示

流量大的系统里,同步导出接口很容易被连接超时打死。比如导出 500 万行,流式查询加写入文件,短则几十秒,长则几分钟。用户浏览器早超时了,你还得不停重试或反馈失败。

我的实践经验是把导出做成异步任务:

  1. 前端点击导出,后端生成一条导出任务记录,状态为"排队中"。
  2. 后台线程池轮询任务队列,逐条执行流式导出。
  3. 导出进度实时写入任务状态表,前端轮询展示进度条。
  4. 导出完成后,把文件放到对象存储或临时磁盘目录,给用户一个下载链接。

这样做的好处是,用户不会被长时间阻塞,服务端也能很好地控制并发。即便导出失败,还能让用户重新触发,不用重跑整个流程。

6. 从操作层面还能做的优化

6.1 查询条件与索引:先瘦身,再考虑查法

流式查询不是免死金牌。如果查询条件本身是全表扫,一次 1000 万行的扫描依然会让数据库 IO 和网络吃紧。导出前先问几个问题:

  • 时间范围有没有必要覆盖全表?很多场景其实只需要导出最近三个月。
  • 字段能不能只查必要的?导出 5 个字段,就别SELECT *,减少单行数据量,也能减少网络传输。
  • 查询走没走索引?create_time范围查询要建索引,否则流式读取会压在慢查询上,整个导出任务更慢。

你还可以用EXPLAIN验证一下执行计划。哪怕是流式查询,一个全表扫加 filesort 的生产任务,也会把数据库磁盘 IO 打满。

6.2 导出产物压缩与分段文件

导出 500 万行 CSV,文件可能 1GB 以上。给文件做 gzip 压缩,能节省大量下载流量。对下游程序来说,读取 gz 压缩包也不复杂。如果你的导出目标是给业务方用的 Excel 文件,Excel 单表最多支持约 100 万行,所以超过这个量级别硬塞进一个 Excel,要么拆成多个文件,要么直接提供 CSV。

分段文件的做法是:在导出任务队列里,把一个大查询按主键 ID 范围切分成若干段,每段负责写一个文件。这样同样能缓解长时间流式查询带来的连接压力。

6.3 JVM 和 GC 层面的配合

流式查询虽然大幅降低了堆内存峰值,但写文件时每行都会产生字符串中间对象,GC 频率还是可能偏高。如果导出线程长时间大量产出新对象,可以留意以下几点:

  • 尽量复用StringBuilder,避免每行都 new 一个 StringBuilder。
  • 用bufferedWriter.write()连续写字段,而不是一次format()出一大段字符串。
  • 在导出线程池里设置合理的线程数,避免多个导出线程同时触发大量 GC。
  • 导出任务跑完主动让线程池空闲,不需要额外改动 JVM 参数,但如果是频繁导出,建议观察 GC 日志确认没有频繁 Full GC。

写 CSV 的时候,我习惯用一个内部工具方法,直接把字段拼接好再写,避免让 POJO 对象增加toString的内存压力。细节多了,导出的吞吐差距是肉眼可见的。

最后再说一点我的体会

做了几年后端,我见过太多次"导出接口 OOM"的工单。解决 OOM 最直接的方法,永远是先质疑那一句全量查询:数据量一旦达到几十万行,一次性查全量的路径就该被否定掉。

流式查询是我遇到百万级、千万级导出任务时的首选,它把内存压力从"扛住全部数据"变成了"一行一行消化"。但它不是免费的午餐:长时间占用连接、不能并发执行其他 SQL、需要事务配合,这些限制必须在设计阶段就想清楚。

我个人最推荐的组合是:异步导出任务 + 流式查询 + 临时文件落盘 + 分段控制。如果有实时同步导出场景,那就用"主键 ID 分段 + 小批分页",避免一条 SQL 跑到底。

最后分享一个小技巧:上线前一定拿生产量级的数据压一遍,别用几十万行的测试数据骗自己。导出代码跑得通,不代表一百万行时连接和 GC 还扛得住。先预估行数,再选策略,比事后救火省太多时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询