Spring Boot 大数据量 Excel 导出性能优化实战指南

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+

两大瓶颈

  1. 远程调用 N 次 HTTP(时间瓶颈)
  2. 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 响应