百万级数据导出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老炮踩坑录」,不错过每一篇真实案例,少踩坑。

相关推荐
anew___1 小时前
《从零手写操作系统 (19):Ext2文件系统实战——从内存到磁盘的跨越》
java·服务器·前端·数据库·算法
蜗牛互联网1 小时前
Computer Use公共预览的安全设计:应用、动作与数据三维门禁
java·人工智能·后端·安全·策略模式
FYKJ_20101 小时前
SSM校园互助与闲置交易平台62145-计算机课程设计、毕业设计
java·spring boot·python·mysql·架构·spark·课程设计
wangbing11251 小时前
下划线数字字面量
java
YYYing.1 小时前
【设计模式系列 (八) 】组合模式
c++·后端·设计模式·组合模式·c/c++
IT_陈寒1 小时前
Redis集群切换主节点时,服务竟然全员掉线
前端·人工智能·后端
星间都市山脉1 小时前
RecoveryUI 圆角屏安全边距配置及代码调用链
android·java·linux·windows·ubuntu
sunshine22 girl1 小时前
Java学习五 面向对象高级5 内部类4-匿名内部类(重点)
java·学习
java_nnnn2 小时前
Spring Boot 快速上手:从 Maven 到第一个 Controller
java·spring·java-ee·maven