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

发布时间:2026/10/15 6:14:28

Java百万级数据导出OOM解决:POI vs EasyExcel 演进与实战(附完整代码) 老炮踩坑录 · 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老炮踩坑录」不错过每一篇真实案例少踩坑。
延伸阅读

更多相关文章

2026/10/14 17:38:55

路线筛选以后详情跳错了,城市探索页别再保存数组下标

城市路线列表在只有三条数据时,用下标保存选中项看不出问题。加入距离筛选以后,原本排在第二位的“老城咖啡线”可能变成第一位,详情区域仍读取 routes[1],画面便跳到另一条路线。 城市步行探索的页面状态目前用 selectedItem: nu…

2026/10/15 7:13:17

deque、priority_queue与仿函数:STL容器适配器实战解析

如果你在 C 里写过一段时间,一定遇到过这样的对话:某次代码评审里,同事指着deque说“这个不是双端队列嘛,当栈用也行”,又有人用priority_queue做定时器任务队列,结果排序规则写反,整个调度直接…

2026/10/15 7:13:17

藏区特产电商平台毕业设计全复盘:从表设计到订单状态机实现

做了很多年开发,看过不少学生的毕设选题,印象最深的不是那些复杂炫技的系统,而是我当年自己做的“藏区特产销售平台”这个毕业设计。当时就想着,与其再去做一个千篇一律的图书商城、二手交易平台,不如扎进一个真实存在…

2026/10/15 7:13:17

Claude Code完全指南:从安装配置到自动化重构的终端AI编程助手

最近在改一个老项目,数据库连接方式要从头换一遍,光受影响的文件我在全局搜索里数了数,快四十个。以前碰到这种杂活,我都是泡杯咖啡,开好几个编辑器分屏,一个文件一个文件地改,改完还得编译、跑…

2026/10/15 7:13:17

AI编程协作工作流:任务驱动的工程化实践指南

1. 项目概述:这不是一份“教程”,而是一套可复用的AI编程协作工作流“2 万 Star,我的 AI 编程教程被豆包推荐了!”——看到这个标题,很多开发者第一反应是点开找链接、抄代码、速成。但作为连续三年带团队落地AI辅助开…

2026/10/15 7:13:17

最新V1.5.rar解压与运行全指南:从校验到部署的工程实践

简介:科东纵向加密客户端V1.5是面向电力系统运维与安全技术人员的一款安全通信工具,配合科东PSTunnel2000加密装置使用,用于解决调度指令、电网运行状态等敏感数据在跨区域、跨层级网络节点间传输时的保密与防篡改问题。资源以rar压缩包形式提…

2026/10/15 7:08:16

YOLOv8路面裂缝检测系统开发实践:从数据到部署全流程

我已经在多个项目里用过YOLO系列做视觉检测,但真正让我觉得“这套流程可以沉淀下来复用”的,还是最近完成的这个路面裂缝检测系统。起因是某交通巡检项目需要把道路裂缝从现场照片里自动识别出来,效率要求接近实时,同时还得应付不…

2026/10/14 11:31:03

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/14 17:41:07

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/14 2:32:55

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/15 0:02:22

防关联浏览器哪个好?三款产品实测差异解析

新手做多店铺,最纠结的就是防关联浏览器选哪个。飞跨、网易、站斧这三款被问得最多,先给结论:要一体化省心、长期便宜,选站斧;要网易招牌和稳,选网易;预算紧做东南亚 Lazada,选飞跨。…

2026/10/15 0:02:22

影刀RPA深度实战:大模型Fine-tuning定制RPA专用Prompt与指令生成

影刀RPA深度实战:大模型Fine-tuning定制RPA专用Prompt与指令生成 一、技术背景 大模型(LLM)在通用场景下表现强大,但当我们将 GPT-4 或 Qwen 直接用于生成影刀RPA操作指令和 XPath 定位时,准确率通常只有 40%-60%。原因…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑