Java百万级数据导出OOM解决:POI vs EasyExcel 演进与实战(附完整代码)
发布时间:2026/10/3 20:15:35 锦皓数字建站
`)
老炮踩坑录 · D03 · 技术深挖系列基于「企业融合评估平台」真实源码复盘一条百万级导出链路的三次自救和一次补课关键词POI OOM · SXSSFWorkbook · CellStyle 64000 上限 · 游标分页 · EasyExcel 欢迎阅读个人主页知守观我的专栏老炮踩坑录当前内容百万数据导出OOM引子2022 年 12 月的一个晚上运维在群里 我管理后台那台应用服务器 CPU 飙满堆打满服务自动重启了。起因简单到离谱政府侧要出年终总结管理后台那个从上线起就没几个人点的导出全部按钮第一次被按在了全年数据上。申报记录加诊断明细九十多万行每行二十多列。在那晚之前导出在我心里属于能跑就行的边角料。自那晚之后我把项目里所有 Excel 导出路径翻了个底朝天——一共三条写法互不相同每条都有自己的死法。这篇我们就按当年的修复顺序讲HSSF、XSSF、SXSSF、游标分页、流式下载。EasyExcel 那部分要说清楚项目当年停在了 SXSSF 游标这一步EasyExcel 是我离职后复盘时自己补做的对照实验代码和内存数据都是后来跑的。案发现场三条导出路径路径一HSSF2003 年的格式// EnterpriseRegistController.java/** 第一步创建一个Workbook对应一个Excel文件 */HSSFWorkbookwbnewHSSFWorkbook();HSSFSheetsheetwb.createSheet(精益数字化);HSSF 是全内存 DOM外加一个硬上限——单个 sheet 最多 65536 行。数据过线会直接抛异常java.lang.IllegalArgumentException: Invalid row number (65536) outside allowable range (0..65535)这条路径的死法最体面报错不炸服务。路径二XSSF 模板填充堆里的三重奏// ExcelUtil.javaisnewFileInputStream(newFile);workbooknewXSSFWorkbook(is);// 整个模板解析成 DOM...FileOutputStreamfosnewFileOutputStream(newFile);for(intm0;msize;m){rowsheet.createRow((int)mrowIndex);// ...逐格填数据}workbook.write(fos);// 写回临时文件...byte[]buffernewbyte[fis.available()];// 成品文件整个读回内存三个动作叠一起模板 DOM 全量数据 成品文件整个 byte[]。顺带记一个和 OOM 无关的 bug循环里cell.getCellStyle().setWrapText(true)拿到的是工作簿共享的默认样式这一改全表遭殃同一个下标 createCell 还调了两次前一个 cell 直接被扔掉。路径三flag 绕过分页全量 List// ElecDeclareController.javaPostMapping(/exportExcel)publicvoidexportExcel(...,RequestBodyJSONObjectjson){...json.put(exportExcel,1);// 导出和列表共用一个查询只是不带分页mapper XML 里对这个 flag 的全部处理只是换个 ORDER BY字段!-- ElecDeclareMapper.xml --choosewhentestexportExcel ! null and exportExcel !ORDER BY et.id,re.createTime DESC/whenotherwiseORDER BY re.createTime DESC/otherwise/choose查询没分页MyBatis 把九十多万行装进一个ListMap。到了ElecExcelExportUtil又逐行来一次 JSON 往返// ElecExcelExportUtil.javafor(inti0;ilist.size();i){MapString,ObjectitemJSONObject.parseObject(list.get(i).toJSONString(),Map.class);data.add(item);}内存里同一份数据两个副本JSON 序列化那一趟还白白浪费了 CPU。先算一笔账XSSF 的对象模型是每个 Cell 一个对象。九十多万行乘二十列接近两千万个 XSSFCell每格连对象头带字符串引用按三四百字节来算光 Cell 层就是大约 6~8GB。精确数字我给不了跟字段长度和字符串池有关但量级不会错。当时那台 4C6G 的虚拟机堆分配了 2G大小——离 6GB 差着一个数量级。事后用 MAT 工具看dumpDominator Tree 长成这样java.util.HashMap$Node[] 1.2GB // 全量 ListMap XSSFWorkbook 891MB // 模板 DOM 已生成的行 byte[104857600] 100MB // fis.available() 那一下三样东西加起来超了堆上限2G谁先触发 OOM 就要看运气了。空口无凭跑一个示例复现照着真实代码的骨架数据减到 20 万行 × 15 列JVM 给 512mpublicclassExportOomTest{publicstaticvoidmain(String[]args)throwsException{introws200_000,cols15;WorkbookwbnewXSSFWorkbook();// 换成别的实现再跑一遍Sheetsheetwb.createSheet(data);for(intr0;rrows;r){Rowrowsheet.createRow(r);for(intc0;ccols;c){row.createCell(c).setCellValue(企业-r-c);}}try(FileOutputStreamfosnewFileOutputStream(out.xlsx)){wb.write(fos);}wb.close();}}输出结果HSSFWorkbook//写到 65537 行抛异常2003 格式的硬上限 XSSFWorkbook//七八万行时 OOMdump 里 90% 是 XSSFCell SXSSFWorkbook//跑完了堆峰值百 MB 上下每次跑结果的数字会有变化浮动只要看量级就行。第一次自救换 SXSSF为什么没救回来代码库里其实早就躺着 SXSSF——ExportExcelUtils2022 年就有了this.workbooknewSXSSFWorkbook(256);SXSSFWorkbook(256)的意思是内存里只留 256 行的滑动窗口超窗的行刷到磁盘临时文件。写侧内存从 O(全量) 降到 O(窗口)。但生产上还是炸过一次。主要原因有三个一个比一个隐蔽。坑一查侧全量SXSSF 管的是写查询侧那个全量 List 它一概不管。九十多万行的ListMap照旧整个加入堆中——写侧平了查侧垫高堆曲线从冲破天花板 变成 “高位横盘”。坑二CellStyle 一个一个造// ExportExcelUtils.javapublicvoidsetCell(intindex,Stringvalue){Cellcellthis.row.createCell((short)index);CellStylestyworkbook.createCellStyle();// 每格一个新样式sty.setAlignment(HSSFCellStyle.ALIGN_CENTER);sty.setBorderTop(HSSFCellStyle.BORDER_THIN);...}每个数据格 createCellStyle 一次。百万行 × 20 列就是两千万个 style 对象。关键在于SXSSF 的滑动窗口只管 Row 和 CellCellStyle 挂在 Workbook 上一个都不会被刷走。你把窗口调到 10 行也没用style 还是全量在堆里。内存涨之外还有个明显限制——xlsx 格式规定一个工作簿最多 64000 个样式超了直接抛异常java.lang.IllegalStateException: The maximum number of Cell Styles was exceeded. You can define up to 64000 style in a .xlsx Workbook修改方法是把样式先预热全表复用// 表头、正文各建一次进循环前备好CellStyleheaderStylebuildHeaderStyle(wb);CellStyletextStylebuildTextStyle(wb);...cell.setCellStyle(textStyle);坑三下载前整文件进内存就算写完落了临时文件下载那一步还有一手byte[] buffer new byte[fis.available()]——多大的文件就吃多大的堆。修改方法法是 response 头先写好workbook 直接写响应流response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet);response.setHeader(Content-Disposition,attachment;filenameencodedName);workbook.write(response.getOutputStream());// 边生成边出网络临时文件方案只在真要改模板的场景保留SXSSF 也能直接包在模板上try(XSSFWorkbooktplnewXSSFWorkbook(templateIs);SXSSFWorkbookwbnewSXSSFWorkbook(tpl)){// 在模板基础上流式追加行}第二次自救把查询变成流写侧和下载侧都压下去了只剩查询侧。备选三种解决方案。PageHelper 循环分页最直觉。翻到后面会撞上深分页——LIMIT 900000, 5000这种MySQL 得先扫过前九十万行越翻越慢导出到 80% 的时候单页查询已经是秒级。MyBatis Cursor真流式Options(fetchSizeInteger.MIN_VALUE)Select(SELECT ... FROM re WHERE ...)CursorMapString,Objectscan(params);try(CursorMapString,Objectcursormapper.scan(params)){for(MapString,Objectrow:cursor){writeRow(row);}}MySQL 的前提是fetchSize Integer.MIN_VALUE或 JDBC 串加useCursorFetchtrue不然驱动还是一次拉全量进内存等于白流。还有个约束Cursor 必须包在一个打开的 SqlSession 里事务一结束连接就归还连接池再迭代直接抛异常。当年在这个上面 还浪费了我小半天的时间。id 游标项目最终采用的SELECT ... FROM re WHERE re.id #{lastId} ORDER BY re.id LIMIT 5000每次查一页记住这页最大的 id 当下一次的游标。每页都走索引代价跟翻到第几页无关。代价是排序——导出顺序从 createTime 改成了 id产品侧确认能接受Excel 里本来就有时间列。分组导出的场景按企业分组用的是 (et.id, re.id) 双游标思路相同代码丑一点。补课EasyExcel 对照实验说实在的 EasyExcel 当年没用。项目在 SXSSF 游标 流式下载这一步稳定了改造的收益撑不起排期就停了。下面是我离职后自己跑的对照版本用的 2.2 系——小版本记不清了3.x 之后 API 有调整以官方文档为准。try(ExcelWriterwriterEasyExcel.write(out,ApplyRow.class).build()){WriteSheetsheetEasyExcel.writerSheet(申报数据).build();longlastId0L;while(true){ListApplyRowpagemapper.pageByCursor(lastId,5000);if(page.isEmpty())break;writer.write(page,sheet);lastIdpage.get(page.size()-1).getId();}}跑下来写侧内存量级跟 SXSSF 游标持平——这正常EasyExcel 写侧底层就是 SXSSF。它真正给我的是三样东西样式默认复用64000 那个坑它替你踩掉了预热逻辑不用再写注解定义列二十行 setCell 循环缩成一次 doWrite代码量砍一半读侧是 SAX 流式读。ExcelReaderUtils里那些new XSSFWorkbook(is)的全量读场景同样受益——OOM 这事在读 Excel 上一样会发生也有不划算的场景。之前写过的那套动态二级表头EasyExcel 用head(ListListString)加自定义合并策略也能做但当年那套 POI 算法已经在生产上跑着重写没有收益。级联下拉框模板同理POI 原生 API 更顺手。我们把四种方案的堆曲线放一起来看导出进度 ────────────────────────────────────────────► XSSF 全量 DOM heap ▁▂▃▅▆▇██ # 中途 OOM服务重启 SXSSF查侧全量 heap ▆▆▆▆▆▆▆▆ # 不炸了基线垫得高别人再查个列表就 Full GC SXSSF / EasyExcel 游标 流式下载 heap ▂▂▂▂▂▂▂▂ # 一条平线跟导出多少行无关方案写侧查侧百万行堆峰值量级备注HSSF全内存全量65536 行上限小模板专用XSSF全内存全量GB 级必挂排除SXSSF流式全量数据多大堆多高只解决一半SXSSF 游标 流式下载流式流式几十 MB当年落地方案EasyExcel 游标流式流式几十 MB复盘验证代码少一半峰值数字看列数和字段长度量级作数。对了xlsx 单 sheet 硬上限 1,048,576 行——“百万级” 这个词贴着天花板真正的百万级导出迟早要分 sheet。百万级的终局别在 HTTP 请求里导方案迭代到这同步导出的三个死结还在Tomcat 线程被占几分钟网关超时用户一刷新重复导。数据量再涨前面所有优化都只是续命。标准的终局是异步导出中心接口只做参数校验落一张任务表扔个 MQ 消息worker 慢慢查、慢慢写、传对象存储生成下载链接站内信通知。用户的体验从转圈十分钟变成好了叫你。当年没做成排期排不进来“分批导出也能凑合用” 是当时的结论。如实说这项目今天要是还活着这是我会排的第一件事。顺带一提产品侧把导出全部改成导出最近 N 条 / 按条件导出省下的工程量比所有技术方案加起来都多——有些需求砍一半是性价比最高的优化。自查清单检查项怎么搜危险信号全量查询喂导出看导出接口调用链里有没有 startPage导出和列表共用查询、flag 绕过分页循环里建样式搜循环体里的 createCellStyle64000 上限 内存放大整文件进内存搜available()、toByteArray()下载前先 new byte[文件大小]模板全量读搜new XSSFWorkbook(is)读 Excel 同样会 OOM读侧换流式POI 版本看 pom3.x 是 2015 年的包升级前先过兼容性老炮点评导出这类功能的麻烦在于坏得很不均匀平时几千条数据怎么写都不会有问题每一段烂代码都活着上了线等数据涨到百万级最烂的那条路径先把服务带走。OOM 还有个脾气——压测环境永远复现不出来压测的人只压列表接口没人压导出。回头看POI 3.12 是 2015 年的包两套导出工具类出自两个年代的人之手同一件事在项目里有三种写法。从全量 DOM 走到 SXSSF 游标用了两次线上事故复盘补 EasyExcel一个周末。每一步都在还上一笔债。下期预告《Redis Guava 二级缓存本地扛读、Redis 保一致》项目里真有一套 Redis Guava 的二级缓存失效策略全靠约定。下期讲这套缓存怎么设计的以及本地缓存改了数据不生效这类问题当年是怎么排查的。如果本文对你有点帮助非常欢迎 点赞 ⭐ 收藏 关注 留言。你的每一次互动鼓励都是我继续更新的动力我们下篇见我是老炮18 年 Java 老兵仍在一线。关注「Java老炮踩坑录」不错过每一篇真实案例少踩坑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。