尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

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

发布时间:2026/9/12 2:25:44

资讯中心
01
ARTICLE

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

百万级数据导出零OOM:流式查询与SXSSFWorkbook实战
1. 一次线上导出 OOM事故现场与根因分析打开服务端的异常日志最让人心里一沉的就是这行java.lang.OutOfMemoryError: Java heap space如果这行出现在导出功能里那基本可以断定大量数据被一股脑加载进了堆内存GC 回收不过来JVM 最终放弃抵抗。我接手这个导出模块时线上已经连续崩了三次用户导一次百万级订单数据服务就消失几分钟然后报 502。说白了就是导出实现图省事把全表查出来塞进内存再写文件。1.1 最典型的反面代码我当时看到的第一版实现几乎是网上随处可见的写法public void exportAll(HttpServletResponse response) { ListOrder 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 exceededGC 一直在回收但效果甚微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_ONLYfetchSizeMySQL 配合useCursorFetchtrue内存占用极低稳定连接占用时间长需要保持事务百万级以上、表结构固定分页查询循环按 ID/时间范围取批每批 1000~5000 条实现简单不需要特殊驱动配置深分页有性能陷阱需要 order by 保证稳定排序无法游标化时的替代方案分批写出配合以上任一读取方式每批攒够了统一 write 到输出流减少 IO 次数兼顾性能批次过大反而内存上涨所有场景我在实际项目中优先使用流式查询数据库版本或驱动限制导致流式查询不可用时再退到基于索引的键集分页keyset pagination而不是传统 limit/offset 分页。原因很简单limit/offset 越翻越慢offset 到几十万之后数据库的扫描代价会大得离谱。2.3 流式查询时的一个易错点流式查询通常要求结果集是只读、单向滚动的即用ResultSet.TYPE_FORWARD_ONLY和ResultSet.CONCUR_READ_ONLY创建 Statement。如果你看到某些框架或者封装好的 Mapper 方法默认用了缓存结果集或双向滚动流式就会失效数据还是会被整个拉进内存。所以实现时要确认你拿到的ResultSet确实是活的、按行拉取的而不是框架帮你一次性 list 出来再包装的。3. Java 流式查询 SXSSFWorkbook 落地细节理论说完了直接给一份可以抄作业的实现。我把这套方案放在一个 2GB 堆、双核 4GB 内存的测试环境里跑过 300 万行导出堆水位始终没超过 800MBGC 次数也完全可控。3.1 MySQL 侧的 JDBC 配置MySQL Connector/J 有个历史包袱默认不启用游标式读取executeQuery会一口气把客户端结果集拉到 JVM 内存里。必须显式打开// jdbc 连接串加参数 // jdbc:mysql://host:3306/db?useCursorFetchtruedefaultFetchSize1000如果不开useCursorFetch就算你在代码里设置了fetchSizeMySQL 驱动也只会把它当作一次性拉多少条到本地不会真正做服务端游标。这是很多人踩过的坑代码里写了setFetchSize内存该爆还是爆因为驱动压根走的不是流式路径。3.2 核心导出代码MySQL SXSSFWorkbookComponent 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; ListObject[] 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与批次的配合。fetchSize5000意思是每次从数据库网络层取 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. 不是所有导出都该用 ExcelCSV 与文件压缩的取舍做完第一版 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 流式查询要useCursorFetchtrue。开了游标之后一个隐含问题是游标读取期间连接不能归还连接池否则游标会被打断。因此这段读取代码要么用独立的数据库连接要么确保整个读取过程处于一个未提交的事务里读取完成立即关闭连接。绝不能在流式读取过程中去做其他借连接的操作不然极容易死锁或拿不到连接。5.2 OraclefetchSize 默认值小得惊人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 应该极少甚至为 0Young GC 可以频繁但不能伴随长时间停顿。活跃对象大小可以用jmap -histo:live或 Arthas 看导出期间堆里活跃对象如果看到大量org.apache.poi.xssf.usermodel.XSSFCell说明流式 API 没生效。6.2 压测中的表现用 2GB 堆跑 300 万行导出实测结果大概是这样的指标优化前全量加载 XSSFWorkbook优化后流式查询 SXSSFWorkbook堆峰值1.9GB接近 OOM~700MBFull 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靠的是让数据像水一样流过程序而不是靠某个神仙参数。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。