Spring Boot 大数据量 Excel 导出性能优化实战指南
一、问题背景
企业系统中,"按条件导出 Excel"是刚需功能。当数据量从几百条增长到数万条时,未优化的导出接口会面临:
- HTTP 请求超时(网关通常 30-120s)
- JVM 内存溢出(OOM)
- 用户体验极差(长时间无响应)
本文针对"查询数据 → 补充远程信息 → 生成 Excel → 上传 OSS → 返回 URL"这一典型导出模式,逐步拆解优化方案。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、典型导出流程与瓶颈分析
2.1 未优化的导出流程
SQL 全量查询(1次)
↓
循环每条数据 {
Feign 远程调用补充信息(N次HTTP)
}
↓
POI XSSFWorkbook 全量内存构建 Excel
↓
写临时文件 → 上传OSS → 返回URL
2.2 各环节耗时分析(以5万条为例)
| 环节 | 实现方式 | 耗时 | 内存占用 |
|---|---|---|---|
| SQL 查询 | 一次性查全量 | 3-5s | ~50MB |
| 远程数据补充 | 逐条 Feign 调用 | ~250-500s | 低 |
| Excel 生成 | XSSFWorkbook(全量内存) | 10-15s | ~500MB+ |
| 文件上传 | OSS | 5-10s | - |
| 合计 | ~270-530s | ~550MB+ |
两大瓶颈:
- 远程调用 N 次 HTTP(时间瓶颈)
- XSSFWorkbook 全量驻留内存(内存瓶颈)
三、优化方案详解
3.1 方案一:批量 Feign 替代逐条调用
核心思想
将 N 次单条远程调用压缩为 N/batchSize 次批量调用。
优化前
java
// N 条数据 = N 次 HTTP 请求
for (ExportDto dto : dataList) {
MemberInfo info = feign.getByCode(dto.getCode()); // 每次 5-10ms
dto.setName(info.getName());
}
优化后
java
// 1. 收集所有 code 去重
List<String> allCodes = dataList.stream()
.map(ExportDto::getCode).distinct().collect(toList());
// 2. 分批查询(每批500),结果转 Map
Map<String, MemberInfo> infoMap = new HashMap<>();
for (int i = 0; i < allCodes.size(); i += 500) {
List<String> batch = allCodes.subList(i, Math.min(i + 500, allCodes.size()));
List<MemberInfo> batchResult = feign.batchGetByCodes(batch);
batchResult.forEach(info -> infoMap.putIfAbsent(info.getCode(), info));
}
// 3. 内存 O(1) 填充
for (ExportDto dto : dataList) {
MemberInfo info = infoMap.get(dto.getCode());
if (info != null) { dto.setName(info.getName()); }
}
性能对比
| 数据量 | 逐条调用(@5ms/次) | 批量调用(每批500) | 提升 |
|---|---|---|---|
| 1万 | ~50s | ~1s(20次批量) | 50x |
| 5万 | ~250s | ~5s(100次批量) | 50x |
分批大小选择
| 批大小 | 优点 | 缺点 |
|---|---|---|
| 100 | 单次请求小,稳定 | 调用次数多 |
| 500 | 平衡选择 | - |
| 1000 | 调用次数最少 | 单次请求体大,可能超限 |
| 2000+ | - | 可能触发 HTTP Body 大小限制 |
推荐 500,兼顾稳定性和效率。
3.2 方案二:SXSSFWorkbook 流式写入
XSSFWorkbook vs SXSSFWorkbook
| 维度 | XSSFWorkbook | SXSSFWorkbook |
|---|---|---|
| 全称 | XML Spreadsheet Format | Streaming XSSF |
| 内存模型 | 全量行驻留内存 | 滑动窗口(仅保留最近 N 行) |
| 5万行内存 | ~500MB+ | ~10-20MB |
| 写入方式 | 随机读写(可修改任意行) | 仅追加写入(已刷出的行不可访问) |
| autoSizeColumn | ✅ 支持 | ❌ 不支持(需手动设置列宽) |
| 临时文件 | 无 | 磁盘临时文件(已刷出的行存磁盘) |
| 适用场景 | 小数据量(<1万行) | 大数据量导出 |
核心 API
java
// 创建流式 Workbook,窗口大小200行(内存中最多保留200行)
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
// 开启临时文件压缩(减少磁盘占用)
workbook.setCompressTempFiles(true);
// 创建 Sheet 和写入数据(与 XSSFWorkbook 完全一致)
Sheet sheet = workbook.createSheet("数据");
Row row = sheet.createRow(0);
row.createCell(0).setCellValue("内容");
// 写入文件
FileOutputStream fos = new FileOutputStream(file);
workbook.write(fos);
fos.close();
// ★ 必须调用 dispose() 清理磁盘临时文件
workbook.dispose();
窗口大小选择
| 窗口大小 | 内存占用 | 适用场景 |
|---|---|---|
| 100 | ~5MB | 纯数据导出,无复杂格式 |
| 200 | ~10MB | 带样式的导出(推荐) |
| 500 | ~25MB | 行高/复杂格式需要参考前面行 |
| -1 | 无限 | 等同于 XSSFWorkbook(不建议) |
SXSSFWorkbook 的限制
| 限制 | 说明 | 解决方案 |
|---|---|---|
不支持 autoSizeColumn |
已刷出的行数据不可读 | 手动设置固定列宽 |
不支持 sheet.getRow(n) |
超出窗口的行不可访问 | 设计时只做追加写入 |
| 磁盘临时文件 | 数据先写磁盘再合并 | dispose() 清理 + setCompressTempFiles(true) |
| 样式对象需提前创建 | 不能边写边创建新样式 | 在写数据前统一创建所有 CellStyle |
3.3 两个方案组合后的性能
| 数据量 | 优化前(总耗时/内存) | 优化后(总耗时/内存) |
|---|---|---|
| 1万 | 60-110s / 150MB | 8-15s / 30MB |
| 5万 | 270-530s / 550MB+ | 30-60s / 50MB |
四、资源清理与异常处理
4.1 SXSSFWorkbook 的正确关闭模式
java
SXSSFWorkbook workbook = null;
try {
workbook = new SXSSFWorkbook(200);
workbook.setCompressTempFiles(true);
// ... 写入逻辑 ...
workbook.write(outputStream);
} catch (Exception e) {
throw new BizException("导出失败");
} finally {
// ★ 必须 dispose,否则临时文件不会被删除
if (workbook != null) {
workbook.dispose();
}
}
4.2 临时文件清理
java
File tempFile = new File(tempFilePath);
try {
// 写入 + 上传
workbook.write(new FileOutputStream(tempFile));
String url = ossTemplate.upload(tempFile);
return url;
} finally {
// 确保临时文件被删除
if (tempFile.exists()) {
tempFile.delete();
}
}
五、性能测试方法
5.1 测试数据生成器
java
@SpringBootTest
@ActiveProfiles("dev")
public class ExportTestDataGenerator {
private static final String TEST_PREFIX = "TEST";
private static final int DATA_COUNT = 10000;
private static final int BATCH_SIZE = 500;
@Resource
private DataRepository repository;
/**
* 插入测试数据.
*/
@Test
public void insertTestData() {
long start = System.currentTimeMillis();
int inserted = 0;
List<DataEntity> batch = new ArrayList<>(BATCH_SIZE);
for (int i = 1; i <= DATA_COUNT; i++) {
DataEntity entity = new DataEntity();
entity.setCode(TEST_PREFIX + String.format("%06d", i));
entity.setName("测试数据-" + i);
entity.setCreateTime(new Date());
batch.add(entity);
if (batch.size() >= BATCH_SIZE) {
repository.saveAll(batch);
inserted += batch.size();
batch.clear();
}
}
if (!batch.isEmpty()) {
repository.saveAll(batch);
inserted += batch.size();
}
System.out.println("插入完成: " + inserted + "条, 耗时: "
+ (System.currentTimeMillis() - start) + "ms");
}
/**
* 清理测试数据.
*/
@Test
public void deleteTestData() {
List<String> codes = new ArrayList<>();
for (int i = 1; i <= DATA_COUNT; i++) {
codes.add(TEST_PREFIX + String.format("%06d", i));
}
int deleted = 0;
for (int i = 0; i < codes.size(); i += BATCH_SIZE) {
List<String> batch = codes.subList(i, Math.min(i + BATCH_SIZE, codes.size()));
List<DataEntity> records = repository.findByCodeIn(batch);
if (records != null && !records.isEmpty()) {
repository.deleteAll(records);
deleted += records.size();
}
}
System.out.println("删除完成: " + deleted + "条");
}
}
5.2 性能对比测试
java
@SpringBootTest
@ActiveProfiles("dev")
public class ExportPerformanceTest {
@Resource
private ExportService exportService;
/**
* 导出性能测试.
*/
@Test
public void testExportPerformance() {
// 记录初始内存
Runtime runtime = Runtime.getRuntime();
long memBefore = runtime.totalMemory() - runtime.freeMemory();
// 执行导出
long start = System.currentTimeMillis();
String url = exportService.export(new ExportParamsDto());
long cost = System.currentTimeMillis() - start;
// 记录峰值内存
long memAfter = runtime.totalMemory() - runtime.freeMemory();
long memUsed = (memAfter - memBefore) / 1024 / 1024;
System.out.println("========== 导出性能报告 ==========");
System.out.println("导出耗时: " + cost + "ms");
System.out.println("内存增量: ~" + memUsed + "MB");
System.out.println("导出URL: " + url);
System.out.println("==================================");
}
}
5.3 测试结果对比模板
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 1万条导出耗时 | ___ms | ___ms | ___x |
| 5万条导出耗时 | ___ms | ___ms | ___x |
| 1万条内存峰值 | ___MB | ___MB | ___x |
| 5万条内存峰值 | ___MB | ___MB | ___x |
六、完整示例代码(订单导出场景)
6.1 Service 实现
java
@Slf4j
@Service
public class OrderExportServiceImpl implements OrderExportService {
@Resource
private OrderMapper orderMapper;
@Resource
private CustomerFeign customerFeign;
@Resource
private AliOssTemplate aliOssTemplate;
/** 批量查询每批大小. */
private static final int FEIGN_BATCH_SIZE = 500;
/** SXSSFWorkbook滑动窗口大小. */
private static final int EXCEL_WINDOW_SIZE = 200;
@Override
public String exportOrders(OrderExportParamsDto paramsDto) {
// 1. SQL 查询全量数据
List<OrderExportResultDto> orderList = orderMapper.listOrdersForExport(paramsDto);
if (orderList == null || orderList.isEmpty()) {
throw new BizException(-1, null, "没有符合条件的数据");
}
// 2. 批量 Feign 补充客户信息
enrichCustomerInfo(orderList);
// 3. 流式生成 Excel + 上传 OSS
return generateAndUploadExcel(orderList);
}
/**
* 批量补充客户详细信息.
*/
private void enrichCustomerInfo(List<OrderExportResultDto> orderList) {
// 2.1 收集所有客户编码并去重
List<String> allCustomerCodes = orderList.stream()
.map(OrderExportResultDto::getCustomerCode)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (allCustomerCodes.isEmpty()) {
return;
}
// 2.2 分批批量查询
Map<String, CustomerInfoDto> customerMap = new HashMap<>();
for (int i = 0; i < allCustomerCodes.size(); i += FEIGN_BATCH_SIZE) {
List<String> batch = allCustomerCodes.subList(i,
Math.min(i + FEIGN_BATCH_SIZE, allCustomerCodes.size()));
try {
BatchQueryCustomerParamsDto queryParam = new BatchQueryCustomerParamsDto();
queryParam.setCustomerCodes(batch);
queryParam.setPageSize(batch.size());
RestResult<List<CustomerInfoDto>> result =
customerFeign.batchQueryCustomerInfo(queryParam);
if (result != null && result.isSuccess() && result.getData() != null) {
for (CustomerInfoDto info : result.getData()) {
customerMap.putIfAbsent(info.getCustomerCode(), info);
}
}
} catch (Exception e) {
log.warn("批量查询客户信息失败, batch={}", i / FEIGN_BATCH_SIZE, e);
}
}
// 2.3 内存中填充
for (OrderExportResultDto dto : orderList) {
CustomerInfoDto customer = customerMap.get(dto.getCustomerCode());
if (customer != null) {
dto.setCustomerName(customer.getCustomerName());
dto.setRegion(customer.getRegion());
dto.setContactPhone(customer.getContactPhone());
}
}
}
/**
* 流式生成Excel并上传OSS.
*/
private String generateAndUploadExcel(List<OrderExportResultDto> orderList) {
SXSSFWorkbook workbook = null;
File tempFile = null;
try {
// 3.1 创建流式 Workbook
workbook = new SXSSFWorkbook(EXCEL_WINDOW_SIZE);
workbook.setCompressTempFiles(true);
Sheet sheet = workbook.createSheet("订单明细");
// 3.2 创建样式(必须在写数据前创建)
CellStyle headerStyle = createHeaderStyle(workbook);
CellStyle dataStyle = createDataStyle(workbook);
// 3.3 写入表头
String[] headers = {"订单号", "客户编码", "客户名称", "所属区域",
"联系电话", "订单金额", "下单时间", "状态"};
Row headerRow = sheet.createRow(0);
headerRow.setHeightInPoints(22);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
// 3.4 设置固定列宽(SXSSFWorkbook不支持autoSizeColumn)
int[] widths = {5000, 4000, 8000, 4000, 4500, 4000, 6000, 3000};
for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i]);
}
// 3.5 流式写入数据行
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
for (int i = 0; i < orderList.size(); i++) {
OrderExportResultDto dto = orderList.get(i);
Row row = sheet.createRow(i + 1);
row.setHeightInPoints(18);
createCell(row, 0, dto.getOrderNo(), dataStyle);
createCell(row, 1, dto.getCustomerCode(), dataStyle);
createCell(row, 2, dto.getCustomerName(), dataStyle);
createCell(row, 3, dto.getRegion(), dataStyle);
createCell(row, 4, dto.getContactPhone(), dataStyle);
createCell(row, 5,
dto.getAmount() != null ? dto.getAmount().toString() : "", dataStyle);
createCell(row, 6,
dto.getCreateTime() != null ? sdf.format(dto.getCreateTime()) : "",
dataStyle);
createCell(row, 7, dto.getStatusName(), dataStyle);
}
// 3.6 写入临时文件
String fileName = "订单导出-" + System.currentTimeMillis() + ".xlsx";
tempFile = new File(System.getProperty("java.io.tmpdir"), fileName);
try (FileOutputStream fos = new FileOutputStream(tempFile)) {
workbook.write(fos);
}
// 3.7 上传OSS
return aliOssTemplate.uploadFile(tempFile);
} catch (Exception e) {
log.error("订单导出失败", e);
throw new BizException(-1, null, "导出失败,请稍后重试");
} finally {
// 3.8 资源清理
if (workbook != null) {
workbook.dispose();
}
if (tempFile != null && tempFile.exists()) {
tempFile.delete();
}
}
}
private CellStyle createHeaderStyle(SXSSFWorkbook workbook) {
Font font = workbook.createFont();
font.setBold(true);
font.setColor(IndexedColors.WHITE.getIndex());
font.setFontHeightInPoints((short) 11);
CellStyle style = workbook.createCellStyle();
style.setFont(font);
style.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
return style;
}
private CellStyle createDataStyle(SXSSFWorkbook workbook) {
Font font = workbook.createFont();
font.setFontHeightInPoints((short) 10);
CellStyle style = workbook.createCellStyle();
style.setFont(font);
style.setAlignment(HorizontalAlignment.LEFT);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
return style;
}
private void createCell(Row row, int col, String value, CellStyle style) {
Cell cell = row.createCell(col);
cell.setCellValue(value != null ? value : "");
cell.setCellStyle(style);
}
}
七、进阶:异步导出方案(超大数据量)
当数据量超过 5 万条,即使批量 Feign + SXSSFWorkbook 也可能达到 60s+,触发网关超时。此时需要异步导出:
┌─────────────────────────────────────────┐
│ 同步阶段(接口立即返回 taskId) │
│ 1. 生成 taskId │
│ 2. 保存导出任务记录(状态=进行中) │
│ 3. 提交异步任务(线程池/MQ) │
│ 4. return taskId │
└────────────────────┬────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 异步阶段(后台线程/MQ消费端) │
│ 1. SQL 查询数据 │
│ 2. 批量 Feign 补充 │
│ 3. SXSSFWorkbook 生成 Excel │
│ 4. 上传 OSS │
│ 5. 更新任务记录(状态=完成, URL=xxx) │
└────────────────────┬────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 前端轮询 taskId │
│ 状态=进行中 → 继续等待 │
│ 状态=完成 → 展示下载链接 │
│ 状态=失败 → 提示错误 │
└─────────────────────────────────────────┘
八、总结:优化清单
| 序号 | 优化项 | 解决的问题 | 效果 |
|---|---|---|---|
| 1 | 逐条 Feign → 分批批量 Feign | 时间瓶颈 | 耗时降 50x |
| 2 | XSSFWorkbook → SXSSFWorkbook | 内存瓶颈 | 内存降 20-50x |
| 3 | autoSizeColumn → 固定列宽 | SXSSF 兼容性 | 避免异常 |
| 4 | finally + dispose() | 磁盘临时文件泄漏 | 资源安全释放 |
| 5 | 分批大小 500 | IN 查询性能与稳定性平衡 | 最佳实践 |
| 6 | 异步导出(进阶) | HTTP 超时 | 接口 <1s 响应 |