拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java百万级数据导出OOM解决:POI vs EasyExcel 演进与实战(附完整代码)

Java百万级数据导出OOM解决:POI vs EasyExcel 演进与实战(附完整代码)

老炮踩坑录 · D03 · 技术深挖系列
基于「企业融合评估平台」真实源码,复盘一条百万级导出链路的三次自救和一次补课
关键词:POI OOM · SXSSFWorkbook · CellStyle 64000 上限 · 游标分页 · EasyExcel

👋 欢迎阅读


🏠个人主页:知守观
📘我的专栏:老炮踩坑录
💻当前内容:百万数据导出OOM

引子

2022 年 12 月的一个晚上,运维在群里 @ 我:管理后台那台应用服务器 CPU 飙满,堆打满,服务自动重启了。

起因简单到离谱:政府侧要出年终总结,管理后台那个从上线起就没几个人点的"导出全部"按钮,第一次被按在了全年数据上。申报记录加诊断明细,九十多万行,每行二十多列。

在那晚之前,导出在我心里属于"能跑就行"的边角料。自那晚之后,我把项目里所有 Excel 导出路径翻了个底朝天——一共三条,写法互不相同,每条都有自己的死法。

这篇我们就按当年的修复顺序讲:HSSF、XSSF、SXSSF、游标分页、流式下载。

EasyExcel 那部分要说清楚:项目当年停在了 SXSSF + 游标这一步,EasyExcel 是我离职后复盘时自己补做的对照实验,代码和内存数据都是后来跑的。

案发现场:三条导出路径

路径一:HSSF,2003 年的格式

// EnterpriseRegistController.java/** 第一步,创建一个Workbook,对应一个Excel文件 */HSSFWorkbookwb=newHSSFWorkbook();HSSFSheetsheet=wb.createSheet("精益数字化");

HSSF 是全内存 DOM,外加一个硬上限——单个 sheet 最多 65536 行。数据过线会直接抛异常:

java.lang.IllegalArgumentException: Invalid row number (65536) outside allowable range (0..65535)

这条路径的死法最体面:报错,不炸服务。

路径二:XSSF 模板填充,堆里的三重奏

// ExcelUtil.javais=newFileInputStream(newFile);workbook=newXSSFWorkbook(is);// 整个模板解析成 DOM...FileOutputStreamfos=newFileOutputStream(newFile);for(intm=0;m<size;m++){row=sheet.createRow((int)m+rowIndex);// ...逐格填数据}workbook.write(fos);// 写回临时文件...byte[]buffer=newbyte[fis.available()];// 成品文件整个读回内存

三个动作叠一起:模板 DOM + 全量数据 + 成品文件整个 byte[]。

顺带记一个和 OOM 无关的 bug:循环里cell.getCellStyle().setWrapText(true)拿到的是工作簿共享的默认样式,这一改全表遭殃;同一个下标 createCell 还调了两次,前一个 cell 直接被扔掉。

路径三:flag 绕过分页,全量 List

// ElecDeclareController.java@PostMapping("/exportExcel")publicvoidexportExcel(...,@RequestBodyJSONObjectjson){...json.put("exportExcel","1");// 导出和列表共用一个查询,只是不带分页

mapper XML 里对这个 flag 的全部处理,只是换个 ORDER BY字段:

<!-- ElecDeclareMapper.xml --><choose><whentest="exportExcel != null and exportExcel !=''">ORDER BY et.id,re.createTime DESC</when><otherwise>ORDER BY re.createTime DESC</otherwise></choose>

查询没分页,MyBatis 把九十多万行装进一个List<Map>。到了ElecExcelExportUtil,又逐行来一次 JSON 往返:

// ElecExcelExportUtil.javafor(inti=0;i<list.size();i++){Map<String,Object>item=JSONObject.parseObject(list.get(i).toJSONString(),Map.class);data.add(item);}

内存里同一份数据两个副本,JSON 序列化那一趟还白白浪费了 CPU。

先算一笔账

XSSF 的对象模型是每个 Cell 一个对象。九十多万行乘二十列,接近两千万个 XSSFCell,每格连对象头带字符串引用按三四百字节来算,光 Cell 层就是大约 6~8GB。精确数字我给不了,跟字段长度和字符串池有关,但量级不会错。

当时那台 4C6G 的虚拟机,堆分配了 2G大小——离 6GB 差着一个数量级。

事后用 MAT 工具看dump,Dominator Tree 长成这样:

java.util.HashMap$Node[] 1.2GB // 全量 List<Map> XSSFWorkbook 891MB // 模板 DOM + 已生成的行 byte[104857600] 100MB // fis.available() 那一下

三样东西加起来超了堆上限2G,谁先触发 OOM 就要看运气了。

空口无凭,跑一个示例复现

照着真实代码的骨架,数据减到 20 万行 × 15 列,JVM 给 512m:

publicclassExportOomTest{publicstaticvoidmain(String[]args)throwsException{introws=200_000,cols=15;Workbookwb=newXSSFWorkbook();// 换成别的实现再跑一遍Sheetsheet=wb.createSheet("data");for(intr=0;r<rows;r++){Rowrow=sheet.createRow(r);for(intc=0;c<cols;c++){row.createCell(c).setCellValue("企业-"+r+"-"+c);}}try(FileOutputStreamfos=newFileOutputStream("out.xlsx")){wb.write(fos);}wb.close();}}

输出结果:

HSSFWorkbook//写到 65537 行抛异常,2003 格式的硬上限 XSSFWorkbook//七八万行时 OOM,dump 里 90% 是 XSSFCell SXSSFWorkbook//跑完了,堆峰值百 MB 上下

每次跑结果的数字会有变化浮动,只要看量级就行。

第一次自救:换 SXSSF,为什么没救回来

代码库里其实早就躺着 SXSSF——ExportExcelUtils2022 年就有了:

this.workbook=newSXSSFWorkbook(256);

SXSSFWorkbook(256)的意思是:内存里只留 256 行的滑动窗口,超窗的行刷到磁盘临时文件。写侧内存从 O(全量) 降到 O(窗口)。

但生产上还是炸过一次。主要原因有三个,一个比一个隐蔽。

坑一:查侧全量

SXSSF 管的是"写",查询侧那个全量 List 它一概不管。九十多万行的List<Map>照旧整个加入堆中——写侧平了,查侧垫高,堆曲线从"冲破天花板" 变成 “高位横盘”。

坑二:CellStyle 一个一个造

// ExportExcelUtils.javapublicvoidsetCell(intindex,Stringvalue){Cellcell=this.row.createCell((short)index);CellStylesty=workbook.createCellStyle();// 每格一个新样式sty.setAlignment(HSSFCellStyle.ALIGN_CENTER);sty.setBorderTop(HSSFCellStyle.BORDER_THIN);...}

每个数据格 createCellStyle 一次。百万行 × 20 列就是两千万个 style 对象。关键在于:SXSSF 的滑动窗口只管 Row 和 Cell,CellStyle 挂在 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

修改方法是把样式先预热,全表复用:

// 表头、正文各建一次,进循环前备好CellStyleheaderStyle=buildHeaderStyle(wb);CellStyletextStyle=buildTextStyle(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;filename="+encodedName);workbook.write(response.getOutputStream());// 边生成边出网络

临时文件方案只在真要改模板的场景保留,SXSSF 也能直接包在模板上:

try(XSSFWorkbooktpl=newXSSFWorkbook(templateIs);SXSSFWorkbookwb=newSXSSFWorkbook(tpl)){// 在模板基础上流式追加行}

第二次自救:把查询变成流

写侧和下载侧都压下去了,只剩查询侧。备选三种解决方案。

PageHelper 循环分页,最直觉。翻到后面会撞上深分页——LIMIT 900000, 5000这种,MySQL 得先扫过前九十万行,越翻越慢,导出到 80% 的时候单页查询已经是秒级。

MyBatis Cursor,真流式:

@Options(fetchSize=Integer.MIN_VALUE)@Select("SELECT ... FROM re WHERE ...")Cursor<Map<String,Object>>scan(params);
try(Cursor<Map<String,Object>>cursor=mapper.scan(params)){for(Map<String,Object>row:cursor){writeRow(row);}}

MySQL 的前提是fetchSize = Integer.MIN_VALUE(或 JDBC 串加useCursorFetch=true),不然驱动还是一次拉全量进内存,等于白流。还有个约束: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(ExcelWriterwriter=EasyExcel.write(out,ApplyRow.class).build()){WriteSheetsheet=EasyExcel.writerSheet("申报数据").build();longlastId=0L;while(true){List<ApplyRow>page=mapper.pageByCursor(lastId,5000);if(page.isEmpty())break;writer.write(page,sheet);lastId=page.get(page.size()-1).getId();}}

跑下来,写侧内存量级跟 SXSSF + 游标持平——这正常,EasyExcel 写侧底层就是 SXSSF。它真正给我的是三样东西:

  • 样式默认复用,64000 那个坑它替你踩掉了,预热逻辑不用再写
  • 注解定义列,二十行 setCell 循环缩成一次 doWrite,代码量砍一半
  • 读侧是 SAX 流式读。ExcelReaderUtils里那些new XSSFWorkbook(is)的全量读场景同样受益——OOM 这事在读 Excel 上一样会发生

也有不划算的场景。之前写过的那套动态二级表头,EasyExcel 用head(List<List<String>>)加自定义合并策略也能做,但当年那套 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。

返回列表