Excel 导出 OOM 预防实战:30 万行从堆溢出到 50MB 的演进
背景
项目中有一个「装配计划查询导出」功能,需要导出约 25.9 万行数据。最初实现非常简单粗暴:
java
// V1.0 - 全量加载 + EasyExcel 直接写入
List<SomeVO> list = baseMapper.selectList(queryWrapper);
EasyExcel.write(response.getOutputStream()).sheet().doWrite(list);
上线后,运维告警频繁:java.lang.OutOfMemoryError: Java heap space。
JVM 堆只有 1GB,而一次全量查询返回 25.9 万行 × 多字段 VO ≈ 约 500MB 数据,再加上 Excel 写入过程中 EasyExcel 内部缓存(XSSFWorkbook 全量在内存中维护),峰值轻松突破 1.5GB,堆溢出是必然的。
本文记录从 V1.0 到 V4.0 的完整演进过程,展示如何通过六项优化将导出峰值内存从 4GB+ 降至 ~50MB ,耗时从 OOM 降到 12-18 秒。
优化一:分页查询 + 分批写入(V2.0)
最直观的想法:不一次查全部,分页查、分批写。
java
// V2.0 - 分页查询 + 分批写入
int pageNum = 1;
int pageSize = 10000;
while (true) {
// 1. 分页查询
Page<SomeVO> page = baseMapper.selectPage(
new Page<>(pageNum, pageSize), queryWrapper);
List<SomeVO> pageData = page.getRecords();
if (pageData.isEmpty()) break;
// 2. 构建行数据
List<List<Object>> rows = buildRows(pageData);
// 3. 写入Excel
excelWriter.write(rows, writeSheet);
pageNum++;
}
效果:解决了 OOM,但依然很慢(90-120 秒)。原因:
- 每次查询都要执行
COUNT(*)全表扫描(Oracle 下 18 秒) - 25.9 万行 ÷ 1 万行/页 = 26 次网络往返
- 每次
buildRows全量转换再写入,pageData 和 rows 双驻留内存
优化二:去掉 COUNT 查询(V3.0)
分析日志发现:分页查询中 page.getTotal() 的 COUNT(*) 查询耗时 18 秒,占了总时间的 20% 以上。
对于导出场景,我们根本不需要知道总共有多少条数据,只需要「查到没有数据为止」。
java
// V3.0 - MyBatis Cursor 游标流式查询(不需要 COUNT)
@Select("SELECT ... FROM ... WHERE ... ORDER BY ...")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 10000)
Cursor<SomeVO> selectForExport(@Param(Constants.WRAPPER) QueryWrapper queryWrapper);
借助 MyBatis Cursor,一次查询只排序一次,JDBC 驱动逐批从数据库拉取数据,无需 COUNT,无需分页号计算。
效果:去掉 18 秒 COUNT 查询,总耗时降至 60-70 秒。
但仍有痛点:Cursor 模式要求整个方法在 @Transactional 范围内执行,且单次查询的结果集必须全部处理完才能释放连接。
优化三:SXSSFWorkbook + 子批次交替写入(V4.0 核心)
3.1 SXSSFWorkbook:磁盘缓冲替代内存驻留
这是最关键的一步。EasyExcel 默认使用 XSSFWorkbook ,所有行数据在 finish() 之前都驻留在内存中。对于 30 万行数据,这意味着一张巨大的 XML DOM 树。
解决方案:强制启用 SXSSFWorkbook,数据写入磁盘临时文件,内存只保留滑动窗口。
java
// V4.0 - 必须显式指定 inMemory(false) 启用 SXSSFWorkbook
EasyExcel.write(outputStream)
.inMemory(false) // ← 关键:false = SXSSFWorkbook,true = XSSFWorkbook
.head(headers)
.build();
坑 :
.inMemory(false)默认就是 false,但很多人以为「默认 false 就不用写」------ 错!EasyExcel 的inMemory()默认值虽然是 false,但在某些环境下会因为类路径上的 POI 版本或配置继承导致退化为 XSSFWorkbook。显式写出.inMemory(false)是最稳妥的做法。
3.2 子批次交替写入:消除双驻留
即使有了分页查询,还有一个隐藏的内存问题:
java
// 问题代码:pageData 和 rows 同时存在
List<Map<String, Object>> pageData = dataProvider.provide(pageNum, 30000);
List<List<Object>> rows = buildRowsFast(pageData, plan); // ← 全量转换
excelWriter.write(rows, writeSheet); // ← 全量写入
// ↑ 此时 pageData(30K) + rows(30K) 双驻留,峰值 ≈ 60K 行
修复方案:子批次拆分,构建一批、写入一批、释放一批。
java
// V4.0 - 子批次交替写入
for (int offset = 0; offset < pageSize; offset += WRITE_SUB_BATCH_SIZE) {
int end = Math.min(offset + WRITE_SUB_BATCH_SIZE, pageSize);
List<Map<String, Object>> subData = pageData.subList(offset, end);
// 构建子批次行数据(仅 SUB_BATCH_SIZE 行)
List<List<Object>> subRows = buildRowsFast(subData, plan);
// 立即写入 Excel(SXSSFWorkbook 刷入磁盘)
excelWriter.write(subRows, writeSheet);
// subRows 离开作用域 → GC 回收
}
// pageData.clear() → GC 回收
内存效果 :峰值从 pageData(30K) + rows(30K) 降为 pageData(30K) + subRows(5K),降低约 83%。
优化四:零反射------ConcurrentHashMap 缓存字段映射
原实现中,每一行数据转换都通过反射获取字段值:
java
// 反射方式:每行每列都反射一次
Field field = excelClass.getDeclaredField(fieldName); // 25.9万×30列 = 777万次
Object value = field.get(dataItem);
优化思路:一个类的字段映射是固定的,没必要每行都反射。
java
// V4.0 - 元数据缓存:ConcurrentHashMap<Class<?>, Map<String, Field>>
private static final Map<Class<?>, Map<String, Field>> CLASS_META_CACHE = new ConcurrentHashMap<>();
private static Map<String, Field> getClassFieldMap(Class<?> clazz) {
return CLASS_META_CACHE.computeIfAbsent(clazz, c -> {
Map<String, Field> map = new HashMap<>();
for (Field f : c.getDeclaredFields()) {
f.setAccessible(true);
map.put(f.getName(), f);
}
// 也缓存父类字段(支持继承体系)
Class<?> superClass = c.getSuperclass();
while (superClass != null && superClass != Object.class) {
for (Field f : superClass.getDeclaredFields()) {
f.setAccessible(true);
map.putIfAbsent(f.getName(), f);
}
superClass = superClass.getSuperclass();
}
return Collections.unmodifiableMap(map);
});
}
效果:反射调用从 777 万次降至 0 次(仅在首次加载时反射一次),数据转换阶段耗时降低 60%。
优化五:HorizontalCellStyleStrategy 替代逐格回调
原样式处理器 ExportStyleHandler 是一个 CellWriteHandler,它每写一个单元格都会回调 afterCellDispose:
java
// 逐格回调:30万行×30列 = 900万次回调
@Override
public void afterCellDispose(...) {
// 判断是否表头 → 设置样式
// 判断是否汇总行 → 设置红色样式
// 判断奇偶行 → 设置隔行变色样式
}
对于 30 万行 × 30 列 = 900 万次回调,光方法调用和判断的开销就超过 40 秒。
优化方案:使用 EasyExcel 内置的 HorizontalCellStyleStrategy 。它在写入前一次性定义好表头样式和数据样式,写入过程中通过策略模式直接应用,零回调。
java
// V4.0 - 高性能样式策略(零回调)
private static CellWriteHandler buildFastStyleStrategy() {
// 表头样式
WriteCellStyle headStyle = new WriteCellStyle();
headStyle.setFillForegroundColor(IndexedColors.PALE_BLUE.getIndex());
// ... 其他样式设置
// 数据行样式(奇数行/偶数行分别设置)
WriteCellStyle evenStyle = new WriteCellStyle();
WriteCellStyle oddStyle = new WriteCellStyle();
return new HorizontalCellStyleStrategy(headStyle,
List.of(evenStyle, oddStyle));
}
当然,HorizontalCellStyleStrategy 不支持「小计/总计行红色标记」这种动态判断。对于需要汇总行标识的场景,我们实现了一个 OptimizedStyleHandler:
java
// 优化版:缓存 CellStyle,减少判断,保留汇总行检测
private static class OptimizedStyleHandler implements CellWriteHandler {
// 缓存已识别的汇总行号
private final Set<Integer> summaryRows = new HashSet<>();
// 缓存 CellStyle 对象,避免重复创建
private CellStyle headerStyle, evenRowStyle, oddRowStyle, summaryStyle;
@Override
public void afterCellDispose(...) {
// 1. 缓存命中直接返回
// 2. 检测汇总行(仅第一次命中时回溯)
// 3. 按奇偶行分配样式
}
}
效果:样式处理耗时从 40 秒降至几乎可忽略。
优化六:DateTimeFormatter 替代 SimpleDateFormat
小优化,但积少成多:
java
// ❌ 旧:每行数据转换时创建 SimpleDateFormat(线程不安全+频繁创建)
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String formatted = sdf.format(date);
// ✅ 新:线程安全的 DateTimeFormatter 静态常量
private static final DateTimeFormatter LOCAL_DATE_TIME_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter LOCAL_DATE_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
效果:避免每次格式化的对象创建开销,30 万行 × 3 个日期字段 = 减少 90 万次对象分配。
最终架构图
┌─────────────────────────────────────────────────────────┐
│ Controller Layer │
│ exportStreamMap(response, dataProvider, class, tid, fn) │
└──────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ EnhancedExportUtil (V4.0) │
│ │
│ ① precomputeLookupKeys(plan) 元数据缓存+查找key预计算 │
│ ② firstPageData = provider(1, N) 首贞查询判断数据量 │
│ ③ EasyExcel.write().inMemory(false) SXSSFWorkbook │
│ ④ 分页循环 { │
│ pageData = provider(pageNum, 30000) │
│ for (offset; offset<size; offset+=5000) { │
│ subRows = buildRowsFast(subData) 零反射 │
│ excelWriter.write(subRows) 磁盘缓冲写入 │
│ } │
│ pageData.clear() │
│ } │
│ ⑤ excelWriter.finish() │
└─────────────────────────────────────────────────────────┘
📊 性能对比总表
| 指标 | V1.0(全量内存) | V2.0(分页写入) | V3.0(去掉COUNT) | V4.0(终极优化) | 提升 |
|---|---|---|---|---|---|
| 导出耗时 | >120s(OOM) | 90-120s | 60-70s | 12-18s | 6.7× |
| 峰值内存 | 4GB+(OOM) | ~500MB | ~400MB | ~50MB | 80× |
| COUNT 查询 | ✅(18s) | ✅(18s) | ❌ | ❌ | 省18s |
| 网络往返 | 1次(OOM) | 26次 | 1次(Cursor) | 10次(3万/页) | - |
| 反射调用 | 777万次 | 777万次 | 777万次 | 0次(缓存) | ∞ |
| 样式回调 | 900万次 | 900万次 | 900万次 | 0次(策略模式) | ∞ |
| Workbook类型 | XSSF(全量内存) | XSSF | XSSF | SXSSF(磁盘缓冲) | - |
| 用户体验 | ❌ 崩溃 | ⚠️ 慢 | ⚠️ 较慢 | ✅ 快 | - |
关键经验总结
1. 内存优化「三板斧」
| 策略 | 解决的问题 | 实现方式 |
|---|---|---|
| 数据不进内存 | 全量结果集撑爆堆 | SXSSFWorkbook + MyBatis Cursor |
| 数据不攒批 | 全量转换造成的双驻留 | 子批次 5K 交替构建+写入 |
| 数据不反射 | 反射调用和临时对象过多 | ConcurrentHashMap 元数据缓存 |
2. .inMemory(false) 必须显式写出
这是最容易忽视的坑。虽然 EasyExcel 的默认值是 inMemory(false),但:
- 某些环境因为 POI 版本兼容性问题可能退化为 XSSFWorkbook
- 代码审查时显式的配置更容易被关注
- 即使现在默认正确,未来升级也可能改变
3. 导出场景与分页查询的矛盾
MyBatis-Plus 的分页查询是为「页面表格」设计的------默认 maxLimit=500,强制 SELECT COUNT(*)。导出场景应该:
- 使用 MyBatis Cursor 或 JDBC 游标
- 自己控制 fetchSize
- 不需要 COUNT
- 不需要分页号计算
4. 性能优化一定要量化
========== 导出完成统计 ==========
总行数: 291437
总耗时: 18523ms (18.5秒)
--- 时间分布 ---
数据库查询总计: 9482ms (51.2%)
构建行数据总计: 4516ms (24.4%)
Excel写入总计: 3810ms (20.6%)
Writer初始化+finish: 715ms (3.9%)
没有量化就没有优化方向。每次优化前后对比这组数据,瓶颈一目了然。
写在最后
从一个 "OOM 导出" 到 "12 秒完成 30 万行",核心思想只有四个字:减少驻留。
- 数据不要在 Java 堆里驻留 → 磁盘缓冲(SXSSF)
- 转换结果不要在内存里驻留 → 子批次交替(5K 粒度)
- 元数据不要在运行时重复计算 → 静态缓存(零反射)
- 样式判断不要在单元格级别执行 → 策略模式(零回调)
这些思路不仅适用于 Excel 导出,在处理任何大数据量场景时都值得借鉴。