老炮踩坑录 · 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。