Excel 导出 OOM 预防实战:30 万行从堆溢出到 50MB 的演进

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 导出,在处理任何大数据量场景时都值得借鉴。

相关推荐
宸丶一1 小时前
Day 10:LangGraph - Agent 的图执行引擎
java·windows·python
风味蘑菇干1 小时前
WTomcat服务器
java·服务器
燕-孑2 小时前
tomcat详解(基础到高级生产)
java·tomcat
码不停蹄的玄黓2 小时前
Spring Bean 生命周期
java·后端·spring
西安邮电大学2 小时前
分治算法详细讲解
java·后端·其他·算法·面试
摇滚侠2 小时前
Mybatis 入门到项目实战 搭建 MyBatis 框架 01-14
java·tomcat·mybatis
码不停蹄的玄黓3 小时前
SpringBoot 全局异常处理器实现
java·spring boot·后端
小高学习java3 小时前
事务的边界问题,如何判断数据回滚时机。
java·数据库·后端
何极光3 小时前
Maven安装与配置
java·maven