大Excel导出OOM的原因、排查与优化

对应简历段落

简历中关于大 Excel 导出的经历可以写成:

针对运营后台大数据量 Excel 导出导致 OOM、Full GC 频繁和接口超时的问题,完成导出链路重构:同步导出改异步任务,数据库分页读取,流式写出文件,限制导出行数和并发数,显著降低堆内存峰值。

这是非常适合 Java 面试展开的项目,因为它同时涉及 JVM、数据库、文件 IO、线程池、业务体验和稳定性治理。面试官可能追问:为什么 Excel 导出会 OOM?POI 的 XSSFWorkbookSXSSFWorkbook 有什么区别?分页查询是不是一定安全?为什么 response.getOutputStream() 直接写也可能内存高?怎么设计异步导出?怎么防止多个大导出同时把服务打垮?

这篇文章围绕保险运营后台最常见的大报表导出场景,讲清楚原因、证据、改造和面试表达。

业务背景

保险公司运营后台有大量导出诉求。承保部门要导出保单清单,理赔部门要导出赔案明细,财务部门要导出保费和佣金对账,客服部门要导出客户回访名单,渠道部门要导出代理人业绩。数据量经常是几万、几十万甚至上百万行,字段也很宽,包含保单号、客户信息、产品信息、渠道信息、缴费计划、状态、时间、金额等。

早期系统为了开发方便,常见写法是:接口接收查询条件,一次性从数据库查出所有数据;把 Entity 转成 DTO;再把 DTO 转成 Excel VO;使用 Apache POI 的 XSSFWorkbook 创建 Workbook、Sheet、Row、Cell;最后写到 HTTP 响应。这个流程在几千行时没问题,一旦数据量变大,就会出现堆内存飙升、Full GC 频繁、接口超时甚至 OOM。

大 Excel 导出还有一个麻烦点:它往往由业务人员手动触发,时间不可控。如果多个运营人员同时导出,或者定时任务也在跑,服务实例会瞬间承受很高的内存和数据库压力。由于导出接口通常部署在和核心后台同一个应用里,它还可能影响正常查询、审核、核保等功能。

核心原理

Excel 导出 OOM 的根因通常不是“Excel 文件本身太大”这么简单,而是导出过程中同时存在多份数据和大量中间对象。

第一份是数据库查询结果。如果一次性 selectList 60 万行,每行几十个字段,Java 堆里会有大量 Entity 对象、字符串、BigDecimal、Date、集合节点。

第二份是转换结果。很多系统会把 Entity 转成 DTO,再转成 VO,再转成用于 Excel 的数组或 Map。每一层转换都会创建新对象,字段里的字符串可能还会拼接、格式化、字典翻译。

第三份是 Excel 模型对象。XSSFWorkbook 会把整个工作簿结构放在内存中,每个 Sheet、Row、Cell、Style、SharedString 都会占用对象。几十万行乘以几十列,会产生海量小对象。

第四份可能是输出缓冲。如果先写入 ByteArrayOutputStream,再一次性写到 response,就会在内存里额外保存完整文件字节数组。即使直接写 response,如果 Workbook 本身全量在内存,也无法解决根本问题。

SXSSFWorkbook 和 EasyExcel 的流式写出能缓解问题,因为它们不会把所有行都长期保留在堆中,而是保留一个窗口,旧行刷到临时文件或输出流。但流式写也不是银弹:如果前置查询一次性把所有数据查到内存,仍然会 OOM;如果样式对象重复创建、合并单元格过多、公式过多,也会带来额外压力。

从 JVM 角度看,大导出会导致年轻代快速填满,大量对象在一次导出完成前仍然存活,Minor GC 无法回收,只能晋升到老年代。老年代被导出数据和 Excel 对象占满后,触发 Mixed GC 或 Full GC。如果 Full GC 后仍然有导出任务持有对象,回收效果就很差。

项目落地

落地优化要从产品约束、架构拆分和代码实现三层同时做。

第一层是产品约束。同步导出必须设置行数上限,例如 1 万或 5 万行以内允许直接下载,超过上限提示使用异步导出。查询条件必须限制时间范围,不能允许“全量历史数据无条件导出”。导出字段也要可控,避免默认导出所有宽字段。

第二层是架构拆分。大导出从同步接口改为异步任务:用户提交导出申请,系统记录任务,后台 worker 执行,生成文件上传到文件服务或对象存储,任务完成后用户在下载中心下载。这样可以避免 HTTP 长连接超时,也便于做排队、限流、重试和失败记录。

第三层是代码实现。数据库要分页或游标读取,每页处理后立即写入流式 Excel,不把所有页累积在内存。Excel 写出使用 EasyExcel 或 SXSSFWorkbook,避免 XSSFWorkbook 全量持有。对象转换要减少中间层,能边查边写就不要先构建巨大 List。样式对象要复用,字典数据要提前批量加载,避免每行查询或每行创建样式。

一个推荐流程是:

提交导出任务 -> 校验条件和预计行数 -> 入库为 WAITING
后台线程领取任务 -> 分页查询 -> 批量补充字典/机构/渠道信息
流式写入临时文件 -> 上传文件服务 -> 更新任务为 SUCCESS
失败则记录错误信息 -> 用户在下载中心查看结果

同时要做并发控制。比如单实例最多同时执行 1 到 2 个大导出,全局通过数据库任务状态、分布式锁或队列控制总并发。导出线程池要和业务请求线程池隔离,避免导出把核心接口线程占满。

排查流程

第一步,确认 OOM 类型。日志里如果是 java.lang.OutOfMemoryError: Java heap space,重点看堆;如果是 GC overhead limit exceeded,说明大量时间花在 GC 但回收很少;如果容器被 OOMKilled,可能是堆外、线程栈、临时文件或整体内存超限。

第二步,对齐业务操作。查看 OOM 前是否有导出请求、导出条件、导出行数、发起用户、任务 ID。很多大导出问题只看 JVM 看不出业务原因,必须结合操作审计和接口日志。

第三步,看 GC 日志。大导出通常表现为 Young GC 变密、老年代快速上涨、Full GC 后回收有限。如果任务结束后能回落,说明偏峰值;如果任务结束后也不回落,要怀疑导出上下文、任务缓存或静态集合泄漏。

第四步,抓 Dump 或分析 OOM Dump。Dump 中常见对象包括 POI 相关类、Stringchar[]byte[]、业务导出 VO、ArrayListHashMap。如果看到大量 XSSFCellXSSFRowCTCell,基本能确认 XSSFWorkbook 全量模型占用很高。如果看到某个导出任务 Map 持有历史结果,就进一步查泄漏。

第五步,查代码链路。重点看是否一次性查询、是否多层转换、是否用 ByteArrayOutputStream、是否使用 XSSFWorkbook、是否每行创建样式、是否每行查字典、是否没有关闭流、是否同步接口超时后后台仍继续生成。

第六步,压测验证。用接近真实的行数和字段宽度压测,不要只用几千行样例。观察堆峰值、Full GC、导出耗时、临时文件大小、数据库压力、任务并发下的稳定性。

常见坑

第一个坑是以为分页查询就一定不会 OOM。如果分页结果被不断 add 到总 List,或者每页写完后没有释放引用,内存仍然会涨。正确做法是每页处理完即写出,并让局部变量尽快失效。

第二个坑是用了 SXSSFWorkbook 但仍然 OOM。原因可能是前面一次性查全量数据,也可能是共享字符串表、样式、合并单元格、批注、图片等对象仍然占用大量内存。流式写减少的是行对象长期驻留,不代表所有 Excel 功能都低内存。

第三个坑是用 ByteArrayOutputStream 暂存文件。大文件字节数组会直接吃掉堆,尤其是几十 MB 到几百 MB 的 Excel。更好的方式是写临时文件或对象存储流。

第四个坑是导出线程池和业务线程池混用。大导出任务耗时长、内存高、IO 多,如果占满 Web 容器线程或公共异步线程池,会拖垮正常业务。

第五个坑是没有导出上限和审计。没有行数预估、没有时间范围、没有并发限制、没有发起人记录,线上出了 OOM 很难追责和复盘。

面试追问

面试官可能问:XSSFWorkbook 为什么占内存?SXSSFWorkbook 的窗口大小是什么意思?EasyExcel 为什么更适合大数据导出?分页查询怎么保证不重复不丢数据?异步导出任务如何设计状态机?导出失败怎么重试?多个用户同时导出怎么限流?如何避免导出影响核心交易?

还可能问:如果业务要求导出 100 万行怎么办?单个 Excel sheet 行数上限是多少?为什么建议拆文件或生成 CSV?导出中途用户取消怎么办?临时文件如何清理?文件里有客户敏感信息如何控制权限和过期时间?

这些问题的本质是看你有没有把一个 OOM 问题当成完整工程治理,而不是只换个工具类。

推荐回答

可以这样回答:

大 Excel 导出 OOM 一般不是单点问题,而是一次性查询、对象多层转换、Excel 全量模型和输出缓冲叠加造成的。以前我们有接口一次性查几十万行,再用 XSSFWorkbook 生成 Excel,导出过程中 Entity、DTO、VO、Row、Cell、String 同时在堆里,导致老年代快速上涨和 Full GC。

我们的改造思路是同步小导出、异步大导出。大导出先提交任务,后台分页查询,每页数据批量补充字典和机构信息后立即用流式方式写入临时文件,完成后上传文件服务。并且限制查询时间范围、单次最大行数、单实例导出并发数,导出线程池和业务线程池隔离。上线后导出期间堆峰值明显下降,Full GC 次数减少,用户也可以在下载中心查看任务状态,不再依赖长 HTTP 连接。

这个回答把 JVM、业务和架构治理连起来,适合资深 Java 面试。

延伸学习路线

第一阶段,掌握 POI 的 HSSFWorkbookXSSFWorkbookSXSSFWorkbook 区别,理解为什么全量 Workbook 模型会占用大量对象。

第二阶段,学习 EasyExcel 或类似流式导出框架,重点关注分页读取、批量写入、样式复用、临时文件管理。

第三阶段,学习数据库分页和游标策略。大数据导出中,深分页、排序字段、索引、快照一致性都会影响稳定性。

第四阶段,学习异步任务设计。包括任务状态机、并发控制、重试、取消、文件过期、权限校验、审计日志。

第五阶段,把导出能力纳入稳定性治理。监控导出任务数量、行数、耗时、失败率、文件大小、堆内存、Full GC 和数据库压力。大导出不是一个工具方法,而是一条需要被治理的生产链路。