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年8月,眼视光行业里有一类容易被忽略的可核验信号:连续举办的行业论坛已经办到第十届,第十一届计划于2026年12月举行;与之配套的职业培训课程栏目,从2020年8月到2026年7月至少列示22期。这类"能数出来&qu…

2026/10/3 20:16:00 阅读更多 →
es6知识点

es6知识点

1.谈谈你对 ES6 的理解,为什么要学习es6? ES6 是 2015 年发布的 JavaScript 第六版标准,主要解决 ES5 的语法缺陷(如作用域混乱、字符串处理繁琐、模块化困难等),引入多项新特性,以提升代码简洁性、规范性与可维护性。…

2026/10/3 20:16:00 阅读更多 →
onetake 参考片拆解指南:analyze_ref 用能量图、切点与 t80 曲线读懂任何参考视频

onetake 参考片拆解指南:analyze_ref 用能量图、切点与 t80 曲线读懂任何参考视频

onetake 参考片拆解指南:analyze_ref 用能量图、切点与 t80 曲线读懂任何参考视频 【免费下载链接】onetake Motion films that never cut to the next slide: every beat grows out of the one before, one continuous camera, continuity measured by an oracle. …

2026/10/3 20:16:00 阅读更多 →

最新新闻

VRChat头像性能优化:避免“贪多”工程,打造高评分角色

VRChat头像性能优化:避免“贪多”工程,打造高评分角色

VRChat 里有一个很常见的日文词叫“よくばり”,翻译过来就是“贪多”。这个词用来形容一类头像工程再合适不过:一个人物模型里想同时塞进 4K 贴图、几十个待机动作、满身 PhysBone、粒子特效、换装部件,甚至再挂一个音乐播放器。结果往往是模…

2026/10/3 20:45:45 阅读更多 →
DeepSeek Harness 开源贡献手记:从零到合入主线

DeepSeek Harness 开源贡献手记:从零到合入主线

1. 引言:为什么参与开源贡献 本文记录我参与 DeepSeek Harness 开源项目的完整过程,从发现问题、定位源码、编写补丁到最终合入主线的真实经历,希望能为同样想参与开源贡献的开发者提供一份可参考的路线图。 2. 项目背景与初步调研 在动手…

2026/10/3 20:40:42 阅读更多 →
面试官:MySQL中的 distinct 和 group by 哪个效率更高?

面试官:MySQL中的 distinct 和 group by 哪个效率更高?

一、开篇:一道高频面试题背后的问题在 MySQL 相关的面试中,有一道题经常被面试官问到:distinct 和 group by 都能去重,它们哪个效率更高?很多候选人听到这个问题后会下意识地回答「distinct 更快,因为它的语…

2026/10/3 20:40:42 阅读更多 →
面试官:BIO、NIO、AIO 的区别是什么?

面试官:BIO、NIO、AIO 的区别是什么?

一、开篇:从一个面试场景说起面试官经常会抛出一个看似简单、实则非常考察底层功底的题目:「说说 BIO、NIO、AIO 的区别」。很多同学能背出「BIO 是阻塞、NIO 是非阻塞、AIO 是异步非阻塞」,但如果继续追问「为什么 NIO 是非阻塞的」「底层分…

2026/10/3 20:40:41 阅读更多 →
Python实现绘制同切圆

Python实现绘制同切圆

程序源码:# 绘制同切圆 import turtle as t # 导入turtle绘图库,取别名t t.pensize(3) # 设置画笔粗细为3像素 t.circle(10) # 画半径为10的圆 t.circle(20) # 画半径为20的圆 t.circle(40) …

2026/10/3 20:39:41 阅读更多 →
数据管理与论文写作并行:按阶段推进的研究节奏怎么排

数据管理与论文写作并行:按阶段推进的研究节奏怎么排

数据工作和论文写作挤在同一段时间里,几乎是每位研究生都会遇到的排期难题。多数人卡住的不是不会写,而是两条线的节拍没有对齐。我们在梳理用户反馈时发现,把研究数据与论文写作按成熟度切成四段、给每段设定明确的两线配比,返工…

2026/10/3 20:39:41 阅读更多 →

日新闻

把回忆蒸馏成 AI 的浪漫实验:为什么你需要前任.skill 完整指南

把回忆蒸馏成 AI 的浪漫实验:为什么你需要前任.skill 完整指南

把回忆蒸馏成 AI 的浪漫实验:为什么你需要前任.skill 完整指南 【免费下载链接】ex-skill 前任 skill 项目地址: https://gitcode.com/gh_mirrors/exsk/ex-skill 前任.skill 是一个运行在 Claude Code 上的开源 Skill:导入微信、iMessage、短信、…

2026/10/3 0:00:27 阅读更多 →
45个经典Linux面试题:从命令到网络排障的完整考点解析

45个经典Linux面试题:从命令到网络排障的完整考点解析

刚开始带应届生的时候,我最头疼的就是他们拿着一摞Linux面试题背得滚瓜烂熟,一上机全露馅。后来自己从被面的人变成面别人的人,才慢慢摸清楚:Linux面试题考的根本不是答案本身,而是你面对一个不确定的系统问题时&#…

2026/10/3 0:01:28 阅读更多 →
SAP生产预留实战指南:MB21/MB23/MB25协同与MRP集成

SAP生产预留实战指南:MB21/MB23/MB25协同与MRP集成

简介:本资源是一份面向SAP ABAP开发人员、生产计划专员及ERP实施顾问的实操型操作指南,聚焦SAP生产预留核心业务场景,系统解决物料预留创建、查询、校验与批量处理等高频问题。文档以结构化方式覆盖预留背景原理、OMC2编码规则、工厂级参数配…

2026/10/3 0:01:28 阅读更多 →

周新闻

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

如何划分训练/验证集: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 9:14:33 阅读更多 →
SEO怎么推广速查手册新手避坑实战指南

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

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

2026/10/3 9:47:50 阅读更多 →
FireRed-OpenStoryline少样本仿写深度解析:AI Agent如何复刻你的独特文案风格与节奏

FireRed-OpenStoryline少样本仿写深度解析:AI Agent如何复刻你的独特文案风格与节奏

FireRed-OpenStoryline少样本仿写深度解析:AI Agent如何复刻你的独特文案风格与节奏 【免费下载链接】FireRed-OpenStoryline FireRed-OpenStoryline is an AI video editing agent that transforms manual editing into intention-driven directing through natural language …

2026/10/3 9:42:31 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/2 10:36:31 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/3 9:42:35 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/3 9:42:36 阅读更多 →