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

发布时间:2026/10/3 20:15:48

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/3 20:10:48

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

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

2026/10/3 21:05:50

光储充换电站分时电价与用户充电负荷双层优化建模与复现

前阵子有个朋友让我帮忙复现一篇光储充换电站的优化论文,标题就是“考虑用户充电负荷与最优分时电价互动”。我第一反应是这模型肯定又是“上层定电价、下层调负荷”的双层套路,结果真坐下来拆的时候,发现细节比想象中多不少:既要…

2026/10/3 21:05:50

秋叶ComfyUI中文整合包:8GB显存跑SDXL实战指南

1. 这不是“又一个UI安装包”,而是中文AI工作流落地的临界点我第一次在客户现场看到有人用秋叶ComfyUI跑通Stable Diffusion XL的LoRA微调,是在北京朝阳区一间不到20平米的独立设计工作室里。客户用的是台2019款MacBook Pro,Radeon Pro 555X显…

2026/10/3 21:05:50

OpenShell实战指南:把Windows 11开始菜单改回经典高效

这两年Windows 11铺开后,我身边越来越多朋友开始抱怨:开始菜单怎么越做越难用了?磁贴没了,推荐来了,应用程序列表还总把最常用的东西挤到三屏开外。如果你也有同样烦恼,别急着换操作系统,试试Op…

2026/10/3 21:05:50

Hindsight工程实践:AI系统回溯式可观测性设计与落地

1. 项目概述:这不是一个工具,而是一种“事后视角”的工程化实践“Hindsight”这个词在英文里直译是“后见之明”,但在软件工程、可观测性与AI应用开发的语境下,它早已超越了哲学意味,演变成一种以回溯式分析为核心的设…

2026/10/3 21:00:50

OpenShell:跨平台Shell体验统一与AI接入实战

最近两三个月,我基本上把日常所有终端操作都搬到了 OpenShell 上。起因很简单:家里一台 Ubuntu 服务器、公司一台 macOS 笔记本、还有一台常驻 Windows 的折腾机,每台机器的 Shell 行为都不一样。zsh 有它的补全生态,fish 有交互式…

2026/10/2 8:16:46

东莞市品牌网站建设报价常见报错与解决

东莞品牌网站建设报价单背后:一份保姆级建站教程避坑实录 网站做好了没人访问,这大概是很多老板最头疼的事。花了大几万做的品牌站,上线后流量惨淡,比路边摊还冷清。别急着骂外包公司,很多“东莞品牌网站建设报价”里藏着不少猫腻,比如用模板站冒充定制…

2026/10/2 18:20:53

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解 【免费下载链接】spirula-studio Cross-vendor 3D Gaussian Splatting trainer - video to splat to mesh, Vulkan or CUDA. 项目地址: https://gitcode.com/GitHub_Trending/sp/spirula-studio Sp…

2026/10/3 15:02:19

SEO怎么推广速查手册新手避坑实战指南

SEO怎么推广速查手册新手避坑实战指南 模板网站太丑不够用?别急着加滤镜,那是治标不治本。很多老板盯着后台流量掉得眼红,却还在纠结首页Banner的圆角是不是3像素。这就像穿着西装去挖土,姿势不对,努力白费。我整理这份 速查手册…

2026/10/3 0:04:31

国内大学生必备的AI写作辅助软件是哪款?

国内高校学生在论文写作过程中,越来越依赖AI辅助工具提升效率,主流方案以本土化全流程工具为核心,结合通用大模型与专业插件,覆盖选题构思、框架搭建、初稿撰写、查重降重、格式调整等关键环节,本文将深入解析当前主流…

2026/10/3 0:04:31

Codex接入Jev模型完整指南:配置方法、本地部署与踩坑排查

最近不少人在讨论 Codex 搭配 Jev 这套玩法,我一开始没太当回事,直到自己把 Jev 接进 Codex跑了几轮编码任务之后,才明白那些说“直接起飞”的人是怎么想的。Codex 作为工具本身已经够能打了,但模型固定、上下文策略固定&#xff…

2026/10/3 0:04:31

GitHub 热门: NVIDIA/Model-Optimizer

👋 Hi,我擅长 AI 大模型应用落地、意识解码与 AI 开发工具链 。 💡 创业路上,用技术换时间,一起把 AI 变成生产力 🚀 >GitHub 热门: NVIDIA/Model-Optimizer 凌晨两点,你刚把跑通了的 Qwen3.…

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

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

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