大数据量 Excel 导出优化—— 内存、速度与架构的全方位指南
如何优化Excel导出性能?大数据量导出为 excel 如何防止频繁的
Full GC/OMM、如何提升导出速度?本文共计7603字,预计阅读时间 12min
整理不易,点个免费的赞吧 (*❦ω❦)👍
如何优化 Excel 导出性能?
优化方式概述:
-
内存优化
内存优化是前提,若无法避免 OOM,速度优化毫无意义
- 分页处理,避免全量加载
- 流式写入
-
速度优化
- 优化数据查询速度
- 异步导出
- 减少样式计算
- 模版复用
1. 准备数据阶段
优化数据生成过程
- 保证数据查询是高效的——优化SQL语句、加索引等等
- 对数据分页处理(特别大数据量)
后台处理的时候分页从数据库中读取数据
先查一页,将其写入到输出目标中,然后继续查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发送给浏览器,用户下载磁盘临时文件 经过 先写到服务器磁盘,再读取发送给客户端
(如ApachePOI的SXSSFWorkbook)对象存储 不经过 直接上传到 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时间和内存、也会让最终文件体积更大
建议:
- 如果业务可以接受简洁的样式,就尽量保持简单/使用默认样式
文件压缩
针对超大文件,能解决用户下载问题、也节省服务器存储和带宽
时机:不要等文件生成完再压缩,应该在流式写入过程中同步压缩。
两种实现思路:
-
直接写入压缩流:用
GZIPOutputStream或ZipOutputStream包装输出流,边写 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)); } -
分片写入 + 分片压缩后再合并:当并行导出多个分片时,每个分片先压缩为
.gz文件,最后由专门线程合并 stream。压缩和查询可并行,进一步节省时间。注意:合并压缩文件与合并原始 Excel 是两回事——前者只是二进制拼接,不存在内存问题。
文件压缩详解
为什么导出 Excel 要压缩?
Excel 文件(xlsx)本质是一个 zip 压缩包,内部存储了多个 XML 文件。当导出数据量很大时:
- 文件体积大:上百万行数据可能达到几百 MB
- 磁盘 I/O 慢:先写大文件再压缩,磁盘压力大
- 用户下载慢:大文件占带宽
压缩可以显著减小文件传输体积(压缩到原来的 10%~20%),节省网络带宽,显著降低网络传输时间。
三种压缩时机对比
方案一:生成完再压缩(最简单)
查询数据 → 生成大Excel文件(500MB) → 压缩 → 小zip文件(50MB) → 用户下载
// 先生成 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 的临时文件,磁盘占用高,且用户要等生成完才能开始下载。
方案二:流式写入时同步压缩(推荐)
查询数据 → 边生成边压缩 → 直接输出压缩流 → 用户下载
(内存中只有压缩缓冲区)
// 方式 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 出去压缩成最终字节。内存中永远只保留一小部分数据。
方案三:分片压缩后合并(适用超大文件 + 需要并行)
分片1: 查询 → 生成Excel → 压缩 → shard1.gz
分片2: 查询 → 生成Excel → 压缩 → shard2.gz (并行)
分片3: 查询 → 生成Excel → 压缩 → shard3.gz
↓
合并为一个大 zip(只是二进制拼接,不解压)
// 每个分片并行压缩
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:初始堆内存
// 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.javaMyBatis 实现 - 基于 MyBatis-Plus 的**游标分页查询(避免一次性加载全部数据)**实现 StreamingExcelExporter.java**流式导出器** - 核心组件,支持分页查询 + 流式写入(避免在内存中构建完整 Excel) + GZIP压缩(显著减少网络带宽占用) + 样式池 调用链示例:
javaexcel.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
评论 (0)
暂无评论,快来抢沙发吧!