如何优化Excel导出性能?大数据量导出为 excel 如何防止频繁的Full GC / OMM、如何提升导出速度

本文共计7603字,预计阅读时间 12min

整理不易,点个免费的赞吧 (*❦ω❦)👍

如何优化 Excel 导出性能?

优化方式概述:

  • 内存优化

    内存优化是前提,若无法避免 OOM,速度优化毫无意义

    1. 分页处理,避免全量加载
    2. 流式写入
  • 速度优化

    1. 优化数据查询速度
    2. 异步导出
    3. 减少样式计算
    4. 模版复用

1. 准备数据阶段

  • 优化数据生成过程

    1. 保证数据查询是高效的——优化SQL语句、加索引等等
    2. 对数据分页处理(特别大数据量)
      后台处理的时候分页从数据库中读取数据
      先查一页,将其写入到输出目标中,然后继续查

2. 生成 Excel 阶段

程序内部逻辑-使用流式API

外部运行环境-调整 JVM 内存

任务类型分析

导出 Excel 整体是 I/O 密集型,但不同阶段的瓶颈不同。

各阶段分析

阶段 瓶颈类型 说明
数据查询 I/O 密集型 数据库磁盘 I/O、网络 I/O(查询走没走索引是关键)
Excel 内容生成 CPU 密集型 POI/EasyExcel 将数据序列化为 XML 并压缩(ZIP 格式),CPU 在序列化 + 压缩计算
数据写出到响应流 I/O 密集型 最终写入 OutputStream,瓶颈在网络带宽或磁盘写入速度

实际瓶颈分布

  • 小数据量(< 10 万行):CPU 序列化 + 压缩是主要耗时,I/O 占比小
  • 大数据量(> 100 万行):数据查询的 I/O 等待占主导,因为数据库往往是最先被打满的
  • 流式写入时:瓶颈会从「内存构建」转移到「下游 I/O」,即写出速度跟不上生成速度时,流式写入的异步分页机制反而会因为背压(backpressure)而让数据库查询停下来等 I/O

优化策略对应

  • 定位是 CPU 瓶颈 → 减少样式计算、简化格式
  • 定位是 I/O 瓶颈 → 优化 SQL 加索引、减少数据库查询次数、用游标分页避免回表

所以,大多数场景下,I/O 才是真正的瓶颈

内存优化

程序内部逻辑

数据分页处理

对数据分页处理(特别大数据量)
后台处理的时候分页从数据库中读取数据,因为一次查询出来的数据量太大,也可能内存溢出
先查一页,将其写入到输出目标中,然后继续查,而不是全量查询

进一步优化-游标分页

如果使用了自增的主键id,可以使用游标分页进一步优化,不了解游标分页的,可以看这篇文章

流式写入

  • 核心:避免在内存中构建完整 Excel,减少内存占用

    (一次性处理全部数据)

  • 作用:内存优化,防止OOM溢出

传统的 Excel 库就像搭积木,需要把所有的数据内容、格式、样式都在内存里 构建出一个完整的 excel 对象模型,等所有的东西都准备好之后,在把整个模型写到 “输出目标” 中

这里的“输出目标”,具体来说,是从内存输出到:

输出方式 是否经过服务器磁盘 说明
HTTP响应流 不经过 通过 OutputStream 发送给浏览器,用户下载
磁盘临时文件 经过 先写到服务器磁盘,再读取发送给客户端
(如 ApachePOISXSSFWorkbook
对象存储 不经过 直接上传到 OSS/S3 等云存储

多数场景下是直接通过响应流给客户端,不经过服务器磁盘。

支持流式 api 的库 (如Easy Excel),就像边生产边装箱,将读取到的数据一边生成 一边写到 “输出目标” 中

内存中只需要保存正在处理的一小部分数据,写出的数据不用保存

EasyExcel 解析

java
// 流式API调用示例
ExcelWriterBuilder builder = EasyExcel.write(exportStream, clazz);
ExcelWriter writer = builder.build();
   WriteSheet sheet = EasyExcel.writerSheet(sheetName)
           .sheetNo(sheetNo)
           .build();

格式与样式

文件格式

  • xls:二进制格式

  • xlsx:常用,本质是基于XML的压缩包

    在大数据量下更高效更稳定,很多现代库支持该格式的流式写入

  • csv:纯文本,无格式,文件小但功能有限

样式

如单元格颜色、字体、框线、条件格式等

影响:

  • 大数据量下,如果使用了大量不同的复杂样式,生成文件时计算和写入这些样式会消耗大量的CPU时间和内存、也会让最终文件体积更大

建议:

  • 如果业务可以接受简洁的样式,就尽量保持简单/使用默认样式

文件压缩

针对超大文件,能解决用户下载问题、也节省服务器存储和带宽

时机:不要等文件生成完再压缩,应该在流式写入过程中同步压缩。

两种实现思路:

  1. 直接写入压缩流:用 GZIPOutputStreamZipOutputStream 包装输出流,边写 Excel 边压缩,内存中只保留压缩缓冲区,无需落地中间文件。

    java
    // 示例:写入时同步压缩
    try (OutputStream fos = new FileOutputStream("data.xlsx.gz");
         GZIPOutputStream gzos = new GZIPOutputStream(fos)) {
        EasyExcel.write(gzos, DataModel.class)
            .sheet("Sheet1")
            .doWrite(data -> queryAndWrite(data));
    }
    
  2. 分片写入 + 分片压缩后再合并:当并行导出多个分片时,每个分片先压缩为 .gz 文件,最后由专门线程合并 stream。压缩和查询可并行,进一步节省时间。

    注意:合并压缩文件与合并原始 Excel 是两回事——前者只是二进制拼接,不存在内存问题。

文件压缩详解

为什么导出 Excel 要压缩?

Excel 文件(xlsx)本质是一个 zip 压缩包,内部存储了多个 XML 文件。当导出数据量很大时:

  • 文件体积大:上百万行数据可能达到几百 MB
  • 磁盘 I/O 慢:先写大文件再压缩,磁盘压力大
  • 用户下载慢:大文件占带宽

压缩可以显著减小文件传输体积(压缩到原来的 10%~20%),节省网络带宽,显著降低网络传输时间


三种压缩时机对比

方案一:生成完再压缩(最简单)

查询数据 → 生成大Excel文件(500MB) → 压缩 → 小zip文件(50MB) → 用户下载

java
// 先生成 Excel 文件
String excelPath = "/tmp/export.xlsx";
EasyExcel.write(excelPath, DataModel.class).sheet("data").doWrite(list);

// 再压缩为 zip
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream("/tmp/export.zip"))) {
    zos.putNextEntry(new ZipEntry("export.xlsx"));
    Files.copy(new File(excelPath).toPath(), zos);
    zos.closeEntry();
}

// 删除临时文件
new File(excelPath).delete();

缺点:中间态有一个 500MB 的临时文件,磁盘占用高,且用户要等生成完才能开始下载。

方案二:流式写入时同步压缩(推荐)

查询数据 → 边生成边压缩 → 直接输出压缩流 → 用户下载
(内存中只有压缩缓冲区)

java
// 方式 A:直接用 GZIP 压缩(.gz 格式,兼容性好)
String fileName = "export.xlsx.gz";
try (OutputStream out = new FileOutputStream(fileName);
	GZIPOutputStream gz = new GZIPOutputStream(out)) {

	EasyExcel.write(gz, DataModel.class)
        .sheet("Sheet1")
        .doWrite(() -> {
            // 分页查询并写入,边写边压缩
          	int page = 1;
          	List<DataModel> data;
          	while (!(data = queryByPage(page++, 5000)).isEmpty()) {
                return data;
          	}
            return null;
        });
}

// 方式 B:用 ZipOutputStream 输出 zip(zip 内可放多个文件)
  try (OutputStream out = response.getOutputStream();
       ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(out))) {
      
      zos.putNextEntry(new ZipEntry("export.xlsx"));
      // 将 Excel 内容直接写入 zip 条目内部
      ExcelWriter writer = EasyExcel.write(zos, DataModel.class).build();
      WriteSheet sheet = EasyExcel.writerSheet().build();
    
      int page = 1;
      List<DataModel> data;
      while (!(data = queryByPage(page++, 5000)).isEmpty()) {
          writer.write(data, sheet);  // 边查边写,压缩同步进行
      }
      writer.finish();
      zos.closeEntry();

}

核心原理:ZipOutputStream / GZIPOutputStream 内部有一个固定大小的缓冲区(默认 512
字节左右)。数据先写入缓冲区,缓冲区满了自动 flush 出去压缩成最终字节。内存中永远只保留一小部分数据。

方案三:分片压缩后合并(适用超大文件 + 需要并行)

text
  分片1: 查询 → 生成Excel → 压缩 → shard1.gz
  分片2: 查询 → 生成Excel → 压缩 → shard2.gz    (并行)
  分片3: 查询 → 生成Excel → 压缩 → shard3.gz
                      ↓
                合并为一个大 zip(只是二进制拼接,不解压)
java
// 每个分片并行压缩
  List<Path> compressedFiles = IntStream.range(0, shardCount).parallel()
      .mapToObj(i -> {
          String shardFile = "/tmp/shard_" + i + ".xlsx.gz";
          try (OutputStream out = new FileOutputStream(shardFile);
               GZIPOutputStream gz = new GZIPOutputStream(out)) {
              // 分片数据写入压缩流
              writeShardData(gz, i);
          }
          return Paths.get(shardFile);
      })
      .collect(Collectors.toList());

// 最后合并所有 .gz 文件(只是二进制拼接,超级快)
  try (OutputStream out = new FileOutputStream("merged.gz");
       BufferedOutputStream bos = new BufferedOutputStream(out)) {
      for (Path shard : compressedFiles) {
          Files.copy(shard, bos);
      }
  }

▎ 关键区别:gz 文件合并只是二进制拼接,不需要解压再重新压缩。这是分片压缩后合并方案成立的前提。

方案对比总结

生成完再压缩 流式写入时同步压缩 分片压缩后合并
实现难度
磁盘临时空间 需要存大文件 不需要 需要存多个压缩文件
压缩与查询并行 否,串行 否,串行
优点 实现简单 省内存、省磁盘 I/O、用户更快拿到结果 压缩与查询并行,能应对超大文件场景
缺点 大文件占用大量磁盘临时空间,压缩本身耗时,用户等待久 实现稍复杂,需在写入前确定压缩格式 实现复杂,需要额外管理多个临时文件
使用场景 小文件 中等文件 千万行级超大文件

一般业务场景用方案二就够了,实现简单且效果明显。

增量生成

从业务需求考虑:用户可能真正关心的可能只是 最近一个月/某个产品线/某个区域/… 的数据,而不是全部数据、过去几年所有数据

引导用户、提供选项,只导出用户真正需要的那部分数据

外部运行环境

调整 JVM 内存

xmx:最大堆内存
xms:初始堆内存

text
// JVM启动参数示例
-Xmx4g -Xms4g 
-XX:+UseG1GC 
-XX:MaxGCPauseMillis=200
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2
-XX:InitiatingHeapOccupancyPercent=35

适当调大参数,划分更多的内存

  • 内存不能设置太大,否则可能导致 gc 回收时间过长 程序卡顿

分布式处理&负载均衡

如果数据量进一步增大,甚至每天都有几千万、上亿数据导出,可以考虑分布式处理、负载均衡(将用户的导出请求 或 拆分后的小任务分发给服务器)

需要更复杂的系统设计,如大数据处理框架、搭建任务分发和结果合并机制

作用

  • 避免单机负载过高

速度优化

优化数据查询速度

保证数据查询是高效的——优化SQL语句、加索引、通过游标 / 覆盖索引+子查询优化深度分页 等等

异步导出

针对用户体验,不需要用户等待导出完成

用户点击导出之后,系统先将导出请求放入消息队列

处理完成之后通知用户

减少样式计算

见“样式

工具类封装示例:

详见: 这个仓库

核心文件:

文件 作用
CursorPageQuery.java 游标分页接口 - 定义按页查询数据的契约,支持基于主键 ID 的深度分页
CellStylePool.java 样式池 - 复用 CellStyle 对象,减少POI对象的创建,避免为每个单元格创建新样式导致内存溢出
SyncZipOutputStream.java 同步压缩输出流 - 支持 GZIP/ZIP 边写边压缩,无需临时文件
ExportResult.java 导出结果封装 - 包含行数、耗时、内存峰值、压缩率等监控指标
MybatisCursorPageQuery.java MyBatis 实现 - 基于 MyBatis-Plus 的**游标分页查询(避免一次性加载全部数据)**实现
StreamingExcelExporter.java **流式导出器** - 核心组件,支持分页查询 + 流式写入(避免在内存中构建完整 Excel) + GZIP压缩(显著减少网络带宽占用) + 样式池

调用链示例:

java
excel.exportStreaming(...)					// 位置: BreServiceImpl 中
		=>
public <T> ExportResult exportStreaming(...) // 位置: EasyExcelComponent 中
		=>
public <T> ExportResult export(...)			// 位置: StreamingExcelExporter 中

其中第二步可以省略,直接new streamingExcelExporter,然后调用 export() 方法

导出慢,如何定位问题?

监控和分析是优化的前提,如何定位导出慢是数据库查询慢,还是内存不够用,还是其他原因?

性能监控

  • 看CPU使用率
    导出的时候CPU使用率是否飙升?是否持续很长时间?
    ——可能是计算量太大的问题

  • 看内存占用
    内存占用是否飙升、甚至快溢出了?

    用流式写入、调 JVM

  • 看磁盘IO
    硬盘是不是一直在疯狂读写?
    ——这可能意味着写文件本身是瓶颈,或数据库查询太慢

    优化数据查询

  • 网络IO

    如果数据源在其他机器上或需要文件传到别的地方,网络IO也可能是瓶颈

总结

优化是一个系统性问题,要从多个层面分析

  • 数据层
    Sql 语句优化、索引…
  • 程序层 (处理过程)
    流式写入;边写入边压缩
  • 资源层
    • 内存
      Jvm 调优
  • 产出层
    选对格式、简化样式
  • 交互层 (用户交互)
    用异步改善体验
  • 运维层
    做性能监控,找到瓶颈 持续调优
  • 架构层
    分布式处理、负载均衡

从多视角考虑,将这些策略组合起来,才能找到最佳解决方案

参考

https://zhuanlan.zhihu.com/p/1902733894928822830

https://cloud.tencent.com/developer/article/2315583

https://www.cnblogs.com/throwable/p/13285518.html