百万级数据导出 Excel,怎么性能优化避免OOM(内存溢出)
禁用 ORM 框架的全量缓存:
百万级数据导出时,核心问题是内存占用过高导致的 OOM(内存溢出),本质原因是传统 Excel 导出会将全量数据加载到内存后再写入文件。
优化的思路是:分批次处理数据、流式写入文件、减少内存中间态,同时结合 Excel 格式特性和 JVM 调优辅助。
一、选用工具
传统 POI 的XSSFWorkbook(处理.xlsx)会将整个 Excel 对象加载到内存,百万级数据直接 OOM,必须替换为流式写入的工具:
| 工具 / 方案 | 核心优势 | 适用场景 | 内存占用 |
|---|---|---|---|
| Apache POI SXSSFWorkbook | 官方主流,基于 POI 封装,支持流式 | 需兼容复杂格式(样式 / 公式) | 低(可设置内存窗口) |
| EasyExcel(阿里) | 轻量、API 友好,天然流式,无 OOM | 大部分业务导出(简单格式) | 极低 |
| JExcelApi | 仅支持.xls(最大 65536 行) | 老系统兼容,数据量 < 6 万 | 中 |
| CSV 格式(伪 Excel) | 纯文本,无格式,读写最快 | 无格式要求、超大数据量 | 几乎可忽略 |
二、优化方案
数据库分页查询:使用limit offset(注意大 offset 性能问题,建议用 “游标分页”:基于主键 / 时间戳)
// 游标分页(避免offset越大数据越慢)
Long lastId = 0L;
int batchSize = 1000; // 每批1000行,可根据内存调整
while (true) {
List<Data> dataList = queryByCursor(lastId, batchSize); // where id > lastId limit batchSize
if (CollectionUtils.isEmpty(dataList)) break;
// 写入Excel
writeToExcel(dataList);
lastId = dataList.get(dataList.size()-1).getId(); // 更新游标
}
禁用 ORM 框架的全量缓存:
MyBatis/Hibernate 默认会缓存查询结果,需关闭二级缓存,且设置fetchSize(流式读取 ResultSet):
xml
<!-- MyBatis设置流式查询 -->
<select id="queryByCursor" fetchSize="1000" resultType="Data">
select id, name, ... from table where id > #{lastId} limit #{batchSize}
</select>
方案 1:EasyExcel(推荐,最简实现)
EasyExcel 天然支持流式写入,底层封装了 SXSSF 的优化,API 极简:
// 1. 定义实体(映射Excel列)
@Data
public class DataDTO {
@ExcelProperty("ID")
private Long id;
@ExcelProperty("姓名")
private String name;
// 其他字段...
}
// 2. 流式导出(分批写入)
public void export(HttpServletResponse response) throws IOException {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=data.xlsx");
// 构建ExcelWriter,指定写入流(直接输出到响应,不落地本地)
ExcelWriter writer = EasyExcel.write(response.getOutputStream(), DataDTO.class).build();
WriteSheet sheet = EasyExcel.writerSheet("数据列表").build();
// 分批查询+写入
Long lastId = 0L;
int batchSize = 1000;
while (true) {
List<DataDTO> dataList = queryByCursor(lastId, batchSize);
if (CollectionUtils.isEmpty(dataList)) break;
writer.write(dataList, sheet); // 写入当前批次
lastId = dataList.get(dataList.size()-1).getId();
// 手动释放当前批次内存(可选,JVM会自动回收,但大批次建议显式清空)
dataList.clear();
}
// 关闭writer,完成写入
writer.finish();
}
方案 2:SXSSFWorkbook(复杂格式场景)
POI 的 SXSSFWorkbook 通过 “内存窗口” 机制,只保留最近 N 行在内存,超出部分刷到磁盘临时文件:
public void exportWithSXSSF(HttpServletResponse response) throws IOException {
// 初始化SXSSF,设置内存中保留100行(超出刷盘),关闭时删除临时文件
SXSSFWorkbook workbook = new SXSSFWorkbook(100);
workbook.setCompressTempFiles(true); // 压缩临时文件
SXSSFSheet sheet = workbook.createSheet("数据列表");
// 创建表头
Row headerRow = sheet.createRow(0);
headerRow.createCell(0).setCellValue("ID");
headerRow.createCell(1).setCellValue("姓名");
// 分批写入数据
Long lastId = 0L;
int batchSize = 1000;
int rowNum = 1; // 数据行起始行号
while (true) {
List<Data> dataList = queryByCursor(lastId, batchSize);
if (CollectionUtils.isEmpty(dataList)) break;
for (Data data : dataList) {
Row row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue(data.getId());
row.createCell(1).setCellValue(data.getName());
}
lastId = dataList.get(dataList.size()-1).getId();
dataList.clear(); // 释放内存
}
// 写入响应流
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=data.xlsx");
workbook.write(response.getOutputStream());
// 关闭workbook,清理临时文件
workbook.dispose(); // 必须调用,否则会残留临时文件
}
方案 3:CSV 格式(超大规模,无格式要求)
CSV 是纯文本,无需解析 Excel 结构,内存占用极低,百万级数据秒级导出:
public void exportCsv(HttpServletResponse response) throws IOException {
response.setContentType("text/csv; charset=utf-8");
response.setHeader("Content-Disposition", "attachment; filename=data.csv");
// 用BufferedWriter提升写入效率
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8));
// 写入表头
writer.write("ID,姓名");
writer.newLine();
// 分批写入数据
Long lastId = 0L;
int batchSize = 10000; // CSV可设置更大批次(如1万)
while (true) {
List<Data> dataList = queryByCursor(lastId, batchSize);
if (CollectionUtils.isEmpty(dataList)) break;
for (Data data : dataList) {
// 处理特殊字符(逗号、换行、引号)
writer.write(String.format("%s,%s", data.getId(), escapeCsv(data.getName())));
writer.newLine();
}
lastId = dataList.get(dataList.size()-1).getId();
dataList.clear();
writer.flush(); // 每批刷一次流,避免缓冲区溢出
}
writer.close();
}
// CSV特殊字符转义
private String escapeCsv(String str) {
if (str == null) return "";
// 包含逗号、双引号、换行符需用双引号包裹,双引号转义为两个
if (str.contains(",") || str.contains("\"") || str.contains("\n") || str.contains("\r")) {
return "\"" + str.replace("\"", "\"\"") + "\"";
}
return str;
}
内存与 JVM 优化
即使做了分批,JVM 参数不合理仍可能 OOM,需针对性调整:
- 调整堆内存:根据服务器配置,增大堆内存(如
-Xms4g -Xmx8g),避免堆空间不足; - 设置临时文件目录:SXSSF 的临时文件默认写系统临时目录(如 /tmp),若磁盘不足会报错,需指定:System.setProperty("java.io.tmpdir", "/data/tmp"); // 指向大容量磁盘
- 禁用 POI 的日志冗余:POI 默认日志输出量大,占用内存,可调整日志级别为 ERROR;
- 避免创建大量临时对象:循环中避免重复创建 CellStyle、Font 等对象(可复用):
// 错误:循环内创建CellStyle(百万次创建,内存溢出)
for (Data data : dataList) {
CellStyle style = workbook.createCellStyle(); // 重复创建
cell.setCellStyle(style);
}// 正确:复用CellStyle
CellStyle dataStyle = workbook.createCellStyle();
dataStyle.setAlignment(HorizontalAlignment.CENTER);
for (Data data : dataList) {
cell.setCellStyle(dataStyle); // 复用
}
架构层面优化(超大规模场景)
若数据量超千万级,单节点导出仍有瓶颈,需架构升级:
- 异步导出:前端发起导出请求,后端异步处理(如 MQ + 定时任务),生成文件后存储到 OSS,前端轮询下载链接;
- 分片导出:将数据按范围分片(如按 ID 分段),多线程 / 多节点并行导出多个 Excel 文件,最后合并(或直接分文件下载);
- 文件落地后下载:先将 Excel 写入服务器本地 / OSS,再通过 Nginx/Apache 提供下载,避免直接通过应用服务器输出流传输(减少应用内存占用)。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)