百万级数据导出OOM:POI的坑与EasyExcel的流式写入实战(附内存对比)

老炮踩坑录 · D03 · 技术深挖系列

基于「企业融合评估平台」真实源码,复盘一条百万级导出链路的三次自救和一次补课

关键词:POI OOM · SXSSFWorkbook · CellStyle 64000 上限 · 游标分页 · EasyExcel

👋 欢迎阅读 🏠个人主页: 知守观

📘我的专栏: 老炮踩坑录

💻当前内容:百万级数据导出OOM

引子

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

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

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

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

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

案发现场:三条导出路径

路径一:HSSF,2003 年的格式

ini 复制代码
// EnterpriseRegistController.java
/** 第一步,创建一个Workbook,对应一个Excel文件  */
HSSFWorkbook wb = new HSSFWorkbook();
HSSFSheet sheet = wb.createSheet("精益数字化");

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

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

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

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

ini 复制代码
// ExcelUtil.java
is = new FileInputStream(newFile);
workbook = new XSSFWorkbook(is);            // 整个模板解析成 DOM
...
FileOutputStream fos = new FileOutputStream(newFile);
for (int m = 0; m < size; m++) {
    row = sheet.createRow((int) m + rowIndex);
    // ...逐格填数据
}
workbook.write(fos);                        // 写回临时文件
...
byte[] buffer = new byte[fis.available()];  // 成品文件整个读回内存

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

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

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

less 复制代码
// ElecDeclareController.java
@PostMapping("/exportExcel")
public void exportExcel(..., @RequestBody JSONObject json) {
    ...
    json.put("exportExcel", "1");   // 导出和列表共用一个查询,只是不带分页

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

xml 复制代码
<!-- ElecDeclareMapper.xml -->
<choose>
    <when test="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 往返:

dart 复制代码
// ElecExcelExportUtil.java
for (int i = 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 长成这样:

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

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

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

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

ini 复制代码
public class ExportOomTest {
    public static void main(String[] args) throws Exception {
        int rows = 200_000, cols = 15;
        Workbook wb = new XSSFWorkbook();   // 换成别的实现再跑一遍
        Sheet sheet = wb.createSheet("data");
        for (int r = 0; r < rows; r++) {
            Row row = sheet.createRow(r);
            for (int c = 0; c < cols; c++) {
                row.createCell(c).setCellValue("企业-" + r + "-" + c);
            }
        }
        try (FileOutputStream fos = new FileOutputStream("out.xlsx")) {
            wb.write(fos);
        }
        wb.close();
    }
}

输出结果:

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

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

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

代码库里其实早就躺着 SXSSF------ExportExcelUtils 2022 年就有了:

ini 复制代码
this.workbook = new SXSSFWorkbook(256);

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

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

坑一:查侧全量

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

坑二:CellStyle 一个一个造

java 复制代码
// ExportExcelUtils.java
public void setCell(int index, String value) {
    Cell cell = this.row.createCell((short) index);
    CellStyle sty = workbook.createCellStyle();   // 每格一个新样式
    sty.setAlignment(HSSFCellStyle.ALIGN_CENTER);
    sty.setBorderTop(HSSFCellStyle.BORDER_THIN);
    ...
}

每个数据格 createCellStyle 一次。百万行 × 20 列就是两千万个 style 对象。关键在于:SXSSF 的滑动窗口只管 Row 和 Cell,CellStyle 挂在 Workbook 上,一个都不会被刷走。你把窗口调到 10 行也没用,style 还是全量在堆里。

内存涨之外还有个明显限制------xlsx 格式规定一个工作簿最多 64000 个样式,超了直接抛异常:

makefile 复制代码
java.lang.IllegalStateException: The maximum number of Cell Styles was exceeded.
You can define up to 64000 style in a .xlsx Workbook

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

ini 复制代码
// 表头、正文各建一次,进循环前备好
CellStyle headerStyle = buildHeaderStyle(wb);
CellStyle textStyle   = buildTextStyle(wb);
...
cell.setCellStyle(textStyle);

坑三:下载前整文件进内存

就算写完落了临时文件,下载那一步还有一手 byte[] buffer = new byte[fis.available()]------多大的文件就吃多大的堆。

修改方法法是 response 头先写好,workbook 直接写响应流:

vbscript 复制代码
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment;filename=" + encodedName);
workbook.write(response.getOutputStream());   // 边生成边出网络

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

java 复制代码
try (XSSFWorkbook tpl = new XSSFWorkbook(templateIs);
     SXSSFWorkbook wb = new SXSSFWorkbook(tpl)) {
    // 在模板基础上流式追加行
}

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

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

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

MyBatis Cursor,真流式:

less 复制代码
@Options(fetchSize = Integer.MIN_VALUE)
@Select("SELECT ... FROM re WHERE ...")
Cursor<Map<String, Object>> scan(params);
javascript 复制代码
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 游标,项目最终采用的:

vbnet 复制代码
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 有调整,以官方文档为准。

ini 复制代码
try (ExcelWriter writer = EasyExcel.write(out, ApplyRow.class).build()) {
    WriteSheet sheet = EasyExcel.writerSheet("申报数据").build();
    long lastId = 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 更顺手。

我们把四种方案的堆曲线放一起来看:

bash 复制代码
导出进度 ────────────────────────────────────────────►
​
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 绕过分页
循环里建样式 搜循环体里的 createCellStyle 64000 上限 + 内存放大
整文件进内存 搜 available()、toByteArray() 下载前先 new byte文件大小
模板全量读 搜 new XSSFWorkbook(is) 读 Excel 同样会 OOM,读侧换流式
POI 版本 看 pom 3.x 是 2015 年的包,升级前先过兼容性

老炮点评

导出这类功能的麻烦在于坏得很不均匀:平时几千条数据,怎么写都不会有问题,每一段烂代码都活着上了线;等数据涨到百万级,最烂的那条路径先把服务带走。OOM 还有个脾气------压测环境永远复现不出来,压测的人只压列表接口,没人压导出。

回头看,POI 3.12 是 2015 年的包;两套导出工具类出自两个年代的人之手;同一件事在项目里有三种写法。从全量 DOM 走到 SXSSF + 游标,用了两次线上事故;复盘补 EasyExcel,一个周末。每一步都在还上一笔债。


下期预告:《Redis + Guava 二级缓存:本地扛读、Redis 保一致》

项目里真有一套 Redis + Guava 的二级缓存,失效策略全靠约定。下期讲这套缓存怎么设计的,以及"本地缓存改了数据不生效"这类问题当年是怎么排查的。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注作者 | 💬 留言。

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。

相关推荐
步行cgn3 分钟前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
y1su19 分钟前
Leetcode 字符串模板
java·数据结构·算法·leetcode·职场和发展·动态规划
码事漫谈1 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780512 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123452 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒2 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记2 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598782 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端
雨中飘荡的记忆2 小时前
Claude Code 入门必会:@、!、Slash Commands,一篇讲清楚
人工智能·后端
苜蓿muxu2 小时前
Java 字符串拼接:+、StringBuilder、StringBuffer的区别
java