导读:零工系统要给企业批量导出结算单,一次 10 万行。第一次我图省事,查出 List 直接 EasyExcel 写,结果堆内存 512MB 直接 OOM,接口 30 秒超时。后来分页流式写 + 自定义样式 + 异步导出三招下来,内存占用降了 90%,这篇把优化过程完整记下来。
xllg 结算单导出 10 万行,Excel 写崩了:EasyExcel 三个优化,内存降到 1/10
先说事故现场。企业财务要导当月零工结算单,10 万条记录带 20 个字段。第一版代码很朴素:查全量 List,EasyExcel.write(outputStream).sheet().doWrite(list)一把梭。结果接口直接 OOM,服务重启。看 GC 日志,Full GC 连续触发,堆 512MB 顶不住。
第一个优化:分页查询 + 流式写入
EasyExcel 本身是流式写的(SAX 解析),但doWrite(list)会把整个 List 一次性喂进去。问题不在 EasyExcel,在我把 10 万行数据全查进了内存。改成查一批写一批:
@Transactional(readOnly=true)publicvoidexportSettleDetail(OutputStreamout,ExportQueryquery){ExcelWriterwriter=EasyExcel.write(out,SettleDetailVO.class).build();WriteSheetsheet=EasyExcel.writerSheet("结算明细").build();intpageSize=5000;longpage=1;while(true){// 分页查询,查一批写一批ListpageData=settleMapper.selectPageData(query,page,pageSize);if(pageData.isEmpty()){break;}writer.write(pageData,sheet);pageData.clear();// 释放引用,让 GC 能回收page++;}writer.finish();}关键就两点:分页查(limit offset)+ 写完一批立即 clear。内存里最多同时存在 5000 行的 VO,10 万行也不再是问题。
第二个优化:样式别一行一设
第一版给单元格加了边框和列宽,ExcelWriteHandler里每个 Cell 都cellStyle.setBorderBottom,10 万行 × 20 列 = 200 万次样式设置,光样式对象就吃掉一大块内存。EasyExcel 的优化是预创建样式,复用同一个 CellStyle:
publicclassSettleStyleHandlerimplementsWriteHandler{privateCellStyleheadStyle;privateCellStylecontentStyle;@Overridepublicvoidsheet(intsheetNo,Sheetsheet){// 只创建一次样式,全局复用headStyle=createHeadStyle(sheet.getWorkbook());contentStyle=createContentStyle(sheet.getWorkbook());}@Overridepublicvoidcell(intcellIndex,Cellcell,WriteSheetHoldersheetHolder,WriteWorkbookHolderworkbookHolder){if(cellIndex==0){cell.setCellStyle(headStyle);// 表头用头样式}else{cell.setCellStyle(contentStyle);// 数据行统一复用}}}sheet()回调里创建样式、cell()里只 setCellStyle 引用。样式对象从 200 万个降到 2 个,内存省了一大截。
第三个优化:异步导出,别让 HTTP 请求干等
10 万行哪怕优化了,也要写 3-5 秒,同步接口在浏览器里就是转圈到超时。改成异步:接口先返回任务 ID,后台线程导出,导出完存 OSS,前端轮询任务状态再下载:
@ServicepublicclassExportService{privatefinalMaptaskMap=newConcurrentHashMap<>();@Async("exportExecutor")publicvoidasyncExport(LongtaskId,ExportQueryquery){ExportTasktask=taskMap.computeIfAbsent(taskId,k->newExportTask());task.setStatus(ExportStatus.RUNNING);try{StringossKey=doExport(query);// 分页流式写入 OSS 临时文件task.setFileUrl(ossKey);task.setStatus(ExportStatus.SUCCESS);}catch(Exceptione){log.error("导出失败 taskId={}",taskId,e);task.setStatus(ExportStatus.FAILED);}}privateStringdoExport(ExportQueryquery)throwsIOException{// 用 EasyExcel 写本地临时文件或直接写 OSS 的流,分页查询逻辑同上returnuploadToOss(tempFile);}}@RestControllerpublicclassExportController{@PostMapping("/settle/export")publicResultsubmitExport(@RequestBodyExportQueryquery){longtaskId=System.currentTimeMillis()%100000+ThreadLocalRandom.current().nextInt(1000);exportService.asyncExport(taskId,query);returnResult.success(taskId);// 前端拿着 taskId 轮询}@GetMapping("/settle/export/status")publicResultqueryStatus(@RequestParamLongtaskId){returnResult.success(exportService.getStatus(taskId));}}异步导出 + 任务状态轮询,用户点导出后该干嘛干嘛,导出完成收到链接再下载。
踩坑记录:临时文件撑爆磁盘
现象:导出功能上线第三天,服务器磁盘告警,/tmp 目录被临时文件塞满。
排查过程:看目录发现一堆easyExcel_xxx.tmp文件,每个几百 MB。查代码,异常分支里writer.finish()没执行,临时文件没人清理,finally里也没删。
定位思路:EasyExcel 写文件时内部会建临时文件,finish()或者流关闭时才清理。我的doExport方法异常时直接抛出去,清理逻辑没跟上。
最终解决:doExport改成 try-finally,finally 里删临时文件,同时加了一个定时任务,每天凌晨清理超过 24 小时的导出临时文件。异步任务最容易漏的是资源清理,异常分支必须走 finally。
可直接复用的要点
- 分页查询 + 流式写入是 EasyExcel 大导出的核心,内存里最多保留一批数据。
- 样式在
sheet()回调里预创建,cell()里只复用引用,别每行新建 CellStyle。 - 大导出必须异步化:任务 ID + 状态轮询 + 文件链接,别让 HTTP 干等。
- 导出文件的清理写进 finally,异常分支也要删临时文件。
- 导出 VO 字段尽量精简,20 个字段已经是上限,别一把梭 50 个列。
项目源码:https://gitee.com/gzqkl/xllg