news 2026/9/12 2:25:34

百万级数据导出零OOM:流式查询与SXSSFWorkbook实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
百万级数据导出零OOM:流式查询与SXSSFWorkbook实战

1. 一次线上导出 OOM:事故现场与根因分析

打开服务端的异常日志,最让人心里一沉的就是这行:

java.lang.OutOfMemoryError: Java heap space

如果这行出现在导出功能里,那基本可以断定:大量数据被一股脑加载进了堆内存,GC 回收不过来,JVM 最终放弃抵抗。我接手这个导出模块时,线上已经连续崩了三次,用户导一次百万级订单数据,服务就"消失"几分钟,然后报 502。说白了,就是导出实现图省事,把全表查出来塞进内存再写文件。

1.1 最典型的反面代码

我当时看到的第一版实现,几乎是网上随处可见的写法:

public void exportAll(HttpServletResponse response) { List<Order> list = orderMapper.selectAll(); // 100万+ 条,全量进堆 Workbook workbook = new XSSFWorkbook(); Sheet sheet = workbook.createSheet("orders"); for (int i = 0; i < list.size(); i++) { Row row = sheet.createRow(i); // 每个单元格都 createCell + setCellValue } workbook.write(response.getOutputStream()); }

这段代码每一步都在制造内存压力。查询时全量加载到 List,生成 Excel 时在内存里构建完整的单元格树,最后 write 的时候又把整个工作簿对象序列化一次。2GB 堆内存,在 80 万行、每行 15 个字段的场景下就已经开始频繁 Full GC,到 120 万行直接 OOM。

1.2 内存放大效应:数据到底占了多少倍空间

很多人觉得"100 万条数据也就几百兆",这是把数据在磁盘上的大小直接等同于内存占用,实际完全不是一回事。我给一个粗略但贴近真实的估算:

环节单条开销100万条总量说明
数据库行数据(磁盘/网络)~500 B~500 MB以 20 个字段的订单表为例
ORM 对象(List )~2 KB~2 GB对象头、字段包装、集合扩容
POI XSSF 单元格树~1 KB/单元格15 GB+每行 15 列就是 1500 万个 Cell 对象

这个表格里 POI 的单元格开销尤其致命。XSSF 是基于 DOM 的内存模型,每创建一个 Cell 就有对应的 Java 对象驻留在堆里,1500 万个对象对 JVM 的 GC 压力是毁灭性的。这也是为什么"数据本身不大"却照样 OOM 的核心原因——中间过程的对象放大远比原始数据要多。

1.3 先分清是哪种 OOM

这里我想多说一句:OOM 不是一个"包治百病"的名词,排查前最好先看异常信息到底属于哪种类型。

  • Java heap space:堆空间耗尽,最常见,导出场景基本都属于这种。
  • GC overhead limit exceeded:GC 一直在回收但效果甚微,JVM 认为"怎么回收都腾不出空间",本质还是堆不够或者对象堆积太快。
  • Direct buffer memory/unable to create native thread:堆外内存或线程耗尽的提示,在导出场景里相对少见,但如果用了 Netty 或大量线程池,也要留意。

日志里明确是Java heap space,那就聚焦堆内存的使用方式:别把所有数据都堆在堆里。

2. 零 OOM 的核心思路:从"全量加载"切换到"流式消费"

问题的答案不是"把堆内存调大",而是改变数据流转的模型。调大堆内存只是把崩溃点往后挪,数据量再涨一截,该 OOM 还是 OOM。

2.1 用一个比喻理解流式处理

把全量导出比作搬家:普通做法是先叫一辆大卡车,把所有家具一次性装上车再开到新家,行李一多车就装不下。流式做法的思路是"传送带"——家具从旧家一件一件挪出来,经过传送带送进新家,全程不需要一个能装下所有家具的仓库。

对应到代码里,就是三句话:

  • 数据库侧:用游标/流式查询,逐行或逐批取数据,而不是一次executeQuery把结果集全部拉回客户端。
  • 处理侧:每取到一批就处理一批,处理完就丢弃引用,让对象尽快变成"垃圾"。
  • 写出侧:写 Excel 用流式 API,写 CSV 直接按行 append,绝不先构建完整个文件对象再写。

2.2 三条主流实现路径对比

根据自己的数据源和场景,可以从下面三种路径里选:

路径实现方式优点缺点适用场景
流式查询JDBCStatement设置TYPE_FORWARD_ONLY+fetchSize,MySQL 配合useCursorFetch=true内存占用极低,稳定连接占用时间长,需要保持事务百万级以上、表结构固定
分页查询循环按 ID/时间范围取批,每批 1000~5000 条实现简单,不需要特殊驱动配置深分页有性能陷阱,需要 order by 保证稳定排序无法游标化时的替代方案
分批写出配合以上任一读取方式,每批攒够了统一 write 到输出流减少 IO 次数,兼顾性能批次过大反而内存上涨所有场景

我在实际项目中,优先使用流式查询;数据库版本或驱动限制导致流式查询不可用时,再退到基于索引的键集分页(keyset pagination),而不是传统 limit/offset 分页。原因很简单:limit/offset 越翻越慢,offset 到几十万之后,数据库的扫描代价会大得离谱。

2.3 流式查询时的一个易错点

流式查询通常要求结果集是"只读、单向滚动"的,即用ResultSet.TYPE_FORWARD_ONLYResultSet.CONCUR_READ_ONLY创建 Statement。如果你看到某些框架或者封装好的 Mapper 方法默认用了缓存结果集或双向滚动,流式就会失效,数据还是会被整个拉进内存。所以实现时要确认你拿到的ResultSet确实是活的、按行拉取的,而不是框架帮你一次性 list 出来再包装的。

3. Java + 流式查询 + SXSSFWorkbook 落地细节

理论说完了,直接给一份可以"抄作业"的实现。我把这套方案放在一个 2GB 堆、双核 4GB 内存的测试环境里跑过 300 万行导出,堆水位始终没超过 800MB,GC 次数也完全可控。

3.1 MySQL 侧的 JDBC 配置

MySQL Connector/J 有个历史包袱:默认不启用游标式读取,executeQuery会一口气把客户端结果集拉到 JVM 内存里。必须显式打开:

// jdbc 连接串加参数 // jdbc:mysql://host:3306/db?useCursorFetch=true&defaultFetchSize=1000

如果不开useCursorFetch,就算你在代码里设置了fetchSize,MySQL 驱动也只会把它当作"一次性拉多少条到本地",不会真正做服务端游标。这是很多人踩过的坑:代码里写了setFetchSize,内存该爆还是爆,因为驱动压根走的不是流式路径。

3.2 核心导出代码(MySQL + SXSSFWorkbook)

@Component public class StreamingExcelExporter { private static final int BATCH_SIZE = 5000; public void exportOrders(OutputStream outputStream) { // SXSSFWorkbook 的窗口大小:内存里最多保留 100 行,旧行自动刷到临时文件 try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { Sheet sheet = workbook.createSheet("orders"); writeHeader(sheet); String sql = "SELECT id, order_no, user_id, amount, status, create_time FROM orders"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setFetchSize(BATCH_SIZE); try (ResultSet rs = ps.executeQuery()) { int rowIndex = 1; List<Object[]> batch = new ArrayList<>(BATCH_SIZE); while (rs.next()) { batch.add(new Object[]{ rs.getLong("id"), rs.getString("order_no"), rs.getLong("user_id"), rs.getBigDecimal("amount"), rs.getString("status"), rs.getTimestamp("create_time") }); if (batch.size() >= BATCH_SIZE) { writeBatch(sheet, rowIndex, batch); rowIndex += batch.size(); batch.clear(); } } if (!batch.isEmpty()) { writeBatch(sheet, rowIndex, batch); } } } workbook.write(outputStream); } finally { // SXSSF 的临时文件必须手动清理 // workbook.dispose(); } } }

3.3 代码里有几个关键点,逐个解释

第一个是new SXSSFWorkbook(100)。SXSSFWorkbook 是 POI 提供的流式版本,跟 XSSFWorkbook 最大的区别是:它只保留窗口大小(window size)内的行对象在内存里,超过窗口的旧行会自动序列化到磁盘临时文件。窗口设得太小会让磁盘 IO 变频繁,太大则内存回收不彻底。我实测 100~200 是比较均衡的区间。

第二个是fetchSize与批次的配合。fetchSize=5000意思是每次从数据库网络层取 5000 行到驱动缓冲,业务代码里再攒够 5000 行写一次 Sheet。因为 SXSSF 每写一行都可能有内部刷新逻辑,攒批能显著减少不必要的调用。但批次也不是越大越好,5000 行每行 15 个字段的对象数组,撑死也就几个 MB,属于安全区间。

第三个是workbook.dispose()。SXSSFWorkbook 在写出过程中会把旧行刷到系统临时文件,如果只调close()而不调dispose(),临时文件不会清理,长期运行会积累磁盘垃圾。这个坑在文档里写得很轻,但实际线上运行久了就会发现临时目录越来越大。

3.4 如果表里带了大字段怎么办

订单表是常规行数据,但如果表里有很长的备注、JSON、甚至 BLOB/CLOB 字段,内存模型要重新考虑。一个 2MB 的文本字段,在rs.getString()时就产生了 2MB 的字符串对象,5000 条一攒就是 10GB 的风险。这种情况下要做"嵌套流式":外层是结果集的流式游标,遇到大字段不要攒批,立即写盘、立即释放引用,批次大小要动态调小。我的经验是:表里有超过 100KB 的大字段时,批次降到 500 行是比较安全的。

4. 不是所有导出都该用 Excel:CSV 与文件压缩的取舍

做完第一版 Excel 导出后,我又遇到了另一个问题:Excel 2007+ 的单 Sheet 行数上限是 1048576,百万级数据导出的行数已经贴着天花板了。就算 SXSSFWorkbook 不会 OOM,用户拿到的文件也可能因为行数超限而打不开或显示不全。

4.1 Excel 行数上限与多 Sheet 拆分

Excel 的硬性限制必须心里有数:

版本单 Sheet 最大行数说明
Excel 97-2003 (.xls)65,536POI 的 HSSF 模型
Excel 2007+ (.xlsx)1,048,576POI 的 XSSF/SXSSF 模型

如果数据量超过 100 万,就得考虑拆成多个 Sheet,每个 Sheet 控制在 50 万行左右,既留出余量,也避免单个 Sheet 打开太慢。多 Sheet 的实现很简单:workbook.createSheet("数据_1")workbook.createSheet("数据_2"),写满一个就切换下一个。

4.2 用 CSV 绕开 Excel 的内存问题

如果业务方对文件格式没有硬性要求,很多场景我会直接建议导 CSV。CSV 是纯文本,可以逐行写输出流,没有单元格对象,内存占用几乎可以忽略不计。100 万行 CSV 文件也就几十 MB,比 xlsx 小得多,也更容易被各种系统解析。

public void exportOrdersToCsv(OutputStream outputStream) throws IOException { try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { // 写 UTF-8 BOM,否则 Windows 上的 Excel 打开中文会乱码 writer.write('\uFEFF'); writeCsvHeader(writer); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setFetchSize(5000); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { writer.write(formatCsvRow(rs)); writer.newLine(); } } } writer.flush(); } }

这里有个小细节:OutputStreamWriter不要用UTF-8之外的编码直接写中文,否则 Excel 打开会出现乱码。还是建议明确指定 UTF-8 并加 BOM。另外 CSV 导出在while (rs.next())里逐行写即可,不需要攒批——BufferedWriter 本身已经在内存里做了缓冲。

4.3 大文件压缩与下载体验

数据量到百万级后,文件本身可能上百 MB。我通常会在导出服务外面套一层压缩:

// 直接输出 .zip,用户下载后解压 try (ZipOutputStream zipOut = new ZipOutputStream(response.getOutputStream())) { zipOut.putNextEntry(new ZipEntry("orders.csv")); // 将上面的 CSV 导出写入 zipOut zipOut.closeEntry(); }

压缩对文本型数据效果非常好,经常能把 200MB 的 CSV 压到 30MB 以内,下载时长和带宽压力都会小很多。

5. 数据源差异:MySQL、Oracle、StarRocks 等场景的导出注意点

导出代码写完之后,我又在不同数据源上踩了不少差异化的坑,这里单独列一节,按数据源说。

5.1 MySQL:必须开 useCursorFetch,但也要小心事务

前面提过,MySQL 流式查询要useCursorFetch=true。开了游标之后,一个隐含问题是:游标读取期间连接不能归还连接池,否则游标会被打断。因此这段读取代码要么用独立的数据库连接,要么确保整个读取过程处于一个未提交的事务里,读取完成立即关闭连接。绝不能在流式读取过程中去做其他"借连接"的操作,不然极容易死锁或拿不到连接。

5.2 Oracle:fetchSize 默认值小得惊人

Oracle JDBC 驱动的默认 fetchSize 是 10,也就是每 10 行去数据库取一次网络包。如果不设置 fetchSize,百万级数据导出会慢到怀疑人生——每 10 行一次网络往返,光 IO 开销就能把导出时间拉长几倍。所以 Oracle 场景同样要ps.setFetchSize(5000),但不需要像 MySQL 那样额外加连接参数。

5.3 StarRocks/Apache Doris 等 OLAP 引擎:别让 JVM 当搬运工

像 StarRocks、Doris 这类 OLAP 引擎,本身的数据导出能力很强,官方推荐的做法是用SELECT ... INTO OUTFILE或通过 Broker 直接把结果写到 HDFS/S3/对象存储,而不是让应用服务器把数据拉回来再转发。之前遇到一个需求,数据量几千万行,如果按"Java 拉流式结果集再写文件"的方式走,应用节点的带宽和内存开销都很大。改成INTO OUTFILE之后,导出任务完全在引擎内部完成,JVM 只负责触发任务和轮询状态,内存压力直接归零。

5.4 工具类导出的适用边界

有人提到 DBeaver、PL/SQL Developer 这类工具。工具不是不能用,但要看场景:

  • DBeaver 自身的导出是流式的,导出百万级数据基本不会撑爆工具自身内存,日常做数据抽取很方便。
  • PL/SQL Developer 在 Oracle 场景下导出表结构和数据,适合小表、一次性操作;大表导出时也会在客户端积累数据,不建议作为生产环境的批量导出通道。
  • 自己的服务集成导出,还是要考虑内存可控性、权限控制和任务进度记录,这是工具替代不了的。

判断标准就一条:是一次性临时操作,还是用户会反复使用的系统功能。前者用工具没毛病,后者必须走代码方案。

6. 验证"零 OOM"的监控与压测方法

改造完成后,"零 OOM"不是拍脑袋说出来的,要有监控数据和压测结果支撑。我分享一套自己用的验证方法。

6.1 关键监控指标

导出一类任务的监控,重点看三个指标:

  • 堆内存使用率:导出过程中堆水位是否呈现"稳定平台"而不是"持续上涨"。
  • Full GC 频率:正常流式导出下,Full GC 应该极少甚至为 0,Young GC 可以频繁但不能伴随长时间停顿。
  • 活跃对象大小:可以用jmap -histo:live或 Arthas 看导出期间堆里活跃对象,如果看到大量org.apache.poi.xssf.usermodel.XSSFCell,说明流式 API 没生效。

6.2 压测中的表现

用 2GB 堆跑 300 万行导出,实测结果大概是这样的:

指标优化前(全量加载 + XSSFWorkbook)优化后(流式查询 + SXSSFWorkbook)
堆峰值1.9GB,接近 OOM~700MB
Full GC 次数8 次,多次长时间停顿0 次
导出耗时未跑完就 OOM约 45 秒
临时文件写入 60MB 左右临时文件

这里的临时文件是 SXSSFWorkbook 刷出去的,属于正常现象,记得在finally里处理即可。

6.3 压测时最容易翻车的几个边界

有几类边界情况,我在压测阶段反复调整才稳定下来,列出来供参考:

  • 并发导出:如果 10 个用户同时触发导出,每个导出占用 700MB 堆,那 2GB 堆照样 OOM。需要加信号量或队列,限制同时执行的任务数,比如最多 3 个并发,其余排队。
  • 查询超时:流式查询占用连接时间变长,数据库 wait_timeout 或 JDBC socketTimeout 设置过小,导出中途会断连。建议在导出专用连接上适当调大超时时间。
  • 恢复/重试:百万级导出耗时不短,网络闪断、对端关闭等异常要处理好。我习惯把导出过程拆成"生成文件"和"传输文件"两个阶段,先落盘到临时目录,再通过响应流推送,推送失败还能从临时文件重试。

7. 这套思路后来还被我用在哪

按这套方案重构导出模块之后,后续又遇到过几个和"数据大、内存小"相关的新需求,比如定时全量备份、把数据从正式环境同步到测试环境、Kafka 消费链路积压时把内存打满的问题。它们的共性和导出完全一致:数据量一大,只要中间环节有"攒"的动作,内存迟早出问题。

7.1 Kafka 消费场景的同类问题

换到数据链路里看,同一个坑会以不同的面貌出现。我遇到过消费线程把拉取到的消息先在本地 List 里攒一批,攒够 5 万条再统一落库,结果高峰期消息积压,List 越攒越多,最后 OOM。当时修复的办法很朴素:把"攒 5 万条"改成"攒 500 条 + 定时批量落库 + 每批提交位移",本质上就是一个小窗口的流式消费。数据流不被截断、不被无限缓存,内存就安全。

7.2 从导出延伸到同步数据的场景

同样的思路也用在把数据从正式区导出到测试区的场景。之前有人会先把源表全量查出来,内存里转一圈,再插入目标库;换成流式读源表、一批一插入目标库、定期提交事务的方案后,内存峰值下降了 60% 以上。这里面的核心不是具体用什么框架,而是始终提醒自己:数据是流,不是堆。

这几句话看着像心得,其实都是我一次次改代码改出来的血泪教训。现在接手任何大数据量的功能,我都会先画一条数据流,标出每个环节可能滞留的对象,凡是有"全部、一次性、整个"这种字眼的实现,我都会先打个问号。百万级数据导出零 OOM,靠的是让数据像水一样流过程序,而不是靠某个神仙参数。

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

RuoYi-Vue房屋租赁系统:权限-流程-数据三重落地实践

简介&#xff1a;本资源是一套基于RuoYi-Vue框架开发的房屋租赁管理系统完整源码&#xff0c;面向Java全栈开发者、毕业设计学生及中小型企业技术选型参考者&#xff0c;旨在提供开箱即用的前后端分离式租赁业务管理解决方案。压缩包共682个文件&#xff0c;大小6.62MB&#xf…

作者头像 李华
网站建设 2026/9/12 2:24:27

Java+Vue超市管理系统实战:高校实训轻量部署方案

简介&#xff1a;本资源是面向高校计算机专业学生与Web全栈初学者的超市管理系统课程设计实践项目&#xff0c;基于Java后端、Vue前端及HTML/JavaScript技术栈完整实现湖北工业大学超市管理业务场景&#xff0c;涵盖商品管理、库存统计、员工权限与基础订单流程。压缩包共240个…

作者头像 李华
网站建设 2026/9/12 2:24:02

WSL2多实例安装与重命名:Ubuntu 24.04开发环境隔离实战

上周我在win11上折腾了一件事&#xff1a;给WSL里的Ubuntu 24.04再装一个副本&#xff0c;并且把两个实例重命名成自己能一眼看懂的名字。起因很简单&#xff0c;主力环境已经养得很肥——编译链、CUDA、Python虚拟环境、VSCode Remote-WSL的整套配置全在里面。我想装ROS2 humb…

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

嵌入式开发CodeReview实战:硬件优化与代码规范

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 2:21:51

STM32+MPU6050六轴传感器完整实战:I2C读取、姿态解算与上位机显示

简介&#xff1a;MPU6050六轴传感器数据实时显示资源&#xff0c;基于STM32ZET6实现三轴加速度与三轴角速度的采集&#xff0c;并通过LCD实时展示&#xff0c;内置配套上位机&#xff0c;适合嵌入式初学者、物联网开发者以及无人机、机器人等姿态检测项目设计者。压缩包共94个文…

作者头像 李华
网站建设 2026/9/12 2:20:51

Windows磁盘占用统计利器:替代Linux du的5种高效方法

先说个场景&#xff1a;你维护的 Windows 服务器磁盘又红了&#xff0c;打开资源管理器一层层点&#xff0c;这个目录几十 GB、那个目录十几个 GB&#xff0c;点到最后手指都酸了。这时候你会怀念 Linux 下那个叫 du 的命令——一条du -sh *直接按目录给你排好&#xff0c;谁大…

作者头像 李华