大数据量 Excel 导出性能优化:SXSSFWorkbook 流式写入实战

大数据量 Excel 导出性能优化:SXSSFWorkbook 流式写入实战

一、问题背景

导出10万+行数据到 Excel 时,常见的性能问题:

问题 原因 后果
内存溢出(OOM) 所有行对象同时存在堆内存中 服务崩溃
导出慢(20秒+) 大对象创建 + GC 频繁 + ZIP 压缩 用户等待超时
并发导出打垮服务 多个请求同时占用大量内存 整个服务不可用
数据库压力 一次性查询全部数据 慢查询、连接池耗尽

注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

二、POI 的三种 Workbook 对比

2.1 HSSFWorkbook(.xls 格式)

  • 文件格式:Excel 97-2003(.xls)
  • 行数上限:65536 行
  • 内存模型:全部在内存
  • 适用:小数据量、兼容旧系统

2.2 XSSFWorkbook(.xlsx 格式)

  • 文件格式:Excel 2007+(.xlsx,本质是 ZIP 包裹的 XML)

  • 行数上限:1048576 行

  • 内存模型:所有行的 DOM 对象全部在堆内存中

  • 内存公式:行数 × 列数 × 每个Cell对象大小(约200~500字节)

    12万行 × 15列 × 300字节 ≈ 540MB 堆内存

2.3 SXSSFWorkbook(流式 .xlsx)

  • 文件格式:同 .xlsx

  • 行数上限:同 1048576 行

  • 内存模型:滑动窗口,只保留最近 N 行在内存,超出的自动刷到磁盘临时文件

  • 内存公式:窗口大小 × 列数 × 每个Cell大小

    窗口200行 × 15列 × 300字节 ≈ 900KB 堆内存(几乎忽略不计)

2.4 对比表

指标 XSSFWorkbook SXSSFWorkbook
12万行内存占用 300~500MB 5~20MB
12万行生成耗时 8~15秒 3~6秒
并发5个导出 OOM 风险 正常
是否支持读取已写的行 ✅ 可以随机访问 ❌ 已刷出的行不可再访问
是否支持单元格样式 ✅ 完整支持 ⚠️ 有限支持(窗口内可设)
是否支持合并单元格 ⚠️ 需在窗口内操作
资源清理 自动(GC) 需手动调用 dispose()

三、SXSSFWorkbook 工作原理

复制代码
创建 SXSSFWorkbook(windowSize=200)
    │
    ├── 写入第 1 行 → 内存中保留
    ├── 写入第 2 行 → 内存中保留
    ├── ...
    ├── 写入第 200 行 → 内存中保留(窗口已满)
    ├── 写入第 201 行 → 第 1 行从内存刷到磁盘临时文件
    ├── 写入第 202 行 → 第 2 行从内存刷到磁盘临时文件
    ├── ...
    ├── 写入第 120000 行 → 第 119800 行刷出
    │
    │   此时内存中只有第 119801~120000 行(200行)
    │   磁盘临时文件中有第 1~119800 行
    │
    ├── workbook.write(outputStream)
    │   → 将内存中的行 + 临时文件合并
    │   → 压缩为 ZIP(.xlsx)
    │   → 输出到 OutputStream
    │
    └── workbook.dispose()
        → 删除磁盘临时文件

3.1 临时文件位置

默认在 java.io.tmpdir(通常是 /tmpC:\Users\xxx\AppData\Local\Temp)。

可以自定义:

java 复制代码
SXSSFWorkbook workbook = new SXSSFWorkbook(
    new XSSFWorkbook(), 200, true, true  // compressTmpFiles=true 压缩临时文件
);

3.2 窗口大小选择

窗口大小 内存占用 适用场景
100 极小 纯数据导出,无需回溯
200~500 一般业务导出
1000+ 中等 需要在近期行内做合并、样式等操作
-1 无限(等同 XSSFWorkbook) 不推荐

四、分页查询的必要性

即使用了 SXSSFWorkbook 解决了写入端的内存问题,如果一次性从数据库查出12万条数据,这些 Java 对象仍然全部在堆内存中:

复制代码
12万条 × 每条约1KB = 120MB 堆内存(仅数据对象)

分页查询将这 120MB 分摊到多次查询中,每次只有 5000 条(~5MB)在内存中:

复制代码
第1次查询:5000条 → 写入 Excel → 被 GC 回收
第2次查询:5000条 → 写入 Excel → 被 GC 回收
...
第24次查询:5000条 → 写入 Excel → 被 GC 回收

总内存峰值 = 5000条数据对象 + SXSSFWorkbook 窗口 ≈ 10~20MB


五、完整示例

5.1 实体

java 复制代码
package com.example.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;

@Data
@TableName("order_record")
public class OrderRecord {
  @TableId(type = IdType.AUTO)
  private Long id;
  private String orderCode;
  private String productName;
  private String customerName;
  private Integer quantity;
  private Double amount;
  private String status;
  private Date createTime;
}

5.2 Mapper

java 复制代码
package com.example.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.OrderRecord;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface OrderRecordMapper extends BaseMapper<OrderRecord> {
}

5.3 导出 Service(优化版)

java 复制代码
package com.example.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.entity.OrderRecord;
import com.example.mapper.OrderRecordMapper;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;

/**
 * 大数据量导出服务.
 */
@Slf4j
@Service
public class ExportService {

  /** 流式写入窗口大小. */
  private static final int SXSSF_WINDOW_SIZE = 200;

  /** 每次分页查询的条数. */
  private static final int EXPORT_PAGE_SIZE = 5000;

  /** 表头. */
  private static final String[] HEADERS = {
      "订单编号", "商品名称", "客户名称", "数量", "金额", "状态", "创建时间"
  };

  @Resource
  private OrderRecordMapper orderRecordMapper;

  /**
   * 导出订单数据(流式写入 + 分页查询).
   *
   * @param status   筛选状态(可选)
   * @param response HTTP响应
   */
  public void exportOrders(String status, HttpServletResponse response) {
    long startTime = System.currentTimeMillis();

    // 创建流式 Workbook
    SXSSFWorkbook workbook = new SXSSFWorkbook(SXSSF_WINDOW_SIZE);
    try {
      Sheet sheet = workbook.createSheet("订单数据");

      // ========== 写入表头 ==========
      CellStyle headerStyle = createHeaderStyle(workbook);
      Row headerRow = sheet.createRow(0);
      for (int i = 0; i < HEADERS.length; i++) {
        Cell cell = headerRow.createCell(i);
        cell.setCellValue(HEADERS[i]);
        cell.setCellStyle(headerStyle);
      }

      // ========== 分页查询并逐批写入 ==========
      SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
      int pageNum = 1;
      int rowIndex = 1;
      int totalExported = 0;

      while (true) {
        // 构造查询条件
        LambdaQueryWrapper<OrderRecord> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(status != null, OrderRecord::getStatus, status);
        wrapper.orderByDesc(OrderRecord::getCreateTime);

        // 分页查询
        Page<OrderRecord> page = new Page<>(pageNum, EXPORT_PAGE_SIZE);
        Page<OrderRecord> pageResult = orderRecordMapper.selectPage(page, wrapper);
        List<OrderRecord> records = pageResult.getRecords();

        // 无数据则结束
        if (records.isEmpty()) {
          break;
        }

        // 写入当前批次的数据
        for (OrderRecord record : records) {
          Row row = sheet.createRow(rowIndex++);
          row.createCell(0).setCellValue(record.getOrderCode());
          row.createCell(1).setCellValue(record.getProductName());
          row.createCell(2).setCellValue(record.getCustomerName());
          row.createCell(3).setCellValue(record.getQuantity() != null ? record.getQuantity() : 0);
          row.createCell(4).setCellValue(record.getAmount() != null ? record.getAmount() : 0);
          row.createCell(5).setCellValue(record.getStatus());
          row.createCell(6).setCellValue(
              record.getCreateTime() != null ? sdf.format(record.getCreateTime()) : "");
        }

        totalExported += records.size();

        // 最后一页(不足一页)则结束
        if (records.size() < EXPORT_PAGE_SIZE) {
          break;
        }
        pageNum++;
      }

      // ========== 设置响应头并输出 ==========
      String dateStr = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
      String fileName = "订单导出-" + dateStr + ".xlsx";

      response.setContentType(
          "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
      response.setHeader("Content-Disposition",
          "attachment;filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));

      OutputStream os = response.getOutputStream();
      workbook.write(os);
      os.flush();

      long cost = System.currentTimeMillis() - startTime;
      log.info("导出完成,共{}条数据,耗时{}ms", totalExported, cost);

    } catch (Exception e) {
      log.error("导出异常", e);
      throw new RuntimeException("导出失败");
    } finally {
      // 必须调用 dispose() 清理临时文件
      workbook.dispose();
    }
  }

  /**
   * 创建表头样式.
   */
  private CellStyle createHeaderStyle(SXSSFWorkbook workbook) {
    CellStyle style = workbook.createCellStyle();
    Font font = workbook.createFont();
    font.setBold(true);
    style.setFont(font);
    return style;
  }
}

5.4 Controller

java 复制代码
package com.example.controller;

import com.example.service.ExportService;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/order")
public class OrderController {

  @Resource
  private ExportService exportService;

  @GetMapping("/export")
  public void export(
      @RequestParam(required = false) String status,
      HttpServletResponse response) {
    exportService.exportOrders(status, response);
  }
}

六、关键注意事项

6.1 必须调用 dispose()

SXSSFWorkbook 在磁盘上创建了临时文件,如果不调用 dispose(),临时文件会一直留在磁盘上,最终耗尽磁盘空间。

java 复制代码
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
try {
    // ... 写入和输出
} finally {
    workbook.dispose(); // 删除临时文件
}

不能用 try-with-resourcesSXSSFWorkbook.close() 不会自动调用 dispose(),需要显式调用。

6.2 不能回溯已刷出的行

java 复制代码
// 错误!已超出窗口的行不能再访问
Row row0 = sheet.getRow(0);      // 如果第0行已被刷出,返回 null
row0.createCell(5).setCellValue("修改");  // NullPointerException

如果需要修改表头样式,确保在写数据之前完成(表头在窗口内)。

6.3 合并单元格的限制

java 复制代码
// 合并单元格需要在窗口范围内操作
// 如果跨度超过窗口大小(如合并第1行到第300行),不可行
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 5)); // 只合并表头列,没问题

6.4 分页查询的边界条件

java 复制代码
// 判断是否还有下一页
if (records.size() < EXPORT_PAGE_SIZE) {
    break; // 最后一页,不满一页说明没有更多数据了
}

不要用 pageResult.getTotal() 来判断,因为每次都执行 COUNT 会额外增加查询开销。如果确实不想每次都 COUNT:

java 复制代码
// 禁用 COUNT 查询,提升分页查询性能
Page<OrderRecord> page = new Page<>(pageNum, EXPORT_PAGE_SIZE, false);

6.5 导出过程中数据变化

分页导出期间如果数据被修改(新增/删除),可能出现:

  • 重复数据(新插入的行被后续分页查到)
  • 遗漏数据(删除的行导致分页偏移)

解决方案:按主键 ID 范围查询而非 OFFSET 分页:

java 复制代码
Long lastId = 0L;
while (true) {
    wrapper.gt(OrderRecord::getId, lastId);
    wrapper.last("LIMIT " + EXPORT_PAGE_SIZE);
    List<OrderRecord> records = orderRecordMapper.selectList(wrapper);
    if (records.isEmpty()) break;
    lastId = records.get(records.size() - 1).getId();
    // 写入...
}

七、性能基准测试参考

数据量 XSSFWorkbook SXSSFWorkbook + 分页 提升倍数
1万行 2s / 50MB内存 1s / 5MB内存 2x / 10x
5万行 7s / 200MB 3s / 15MB 2.3x / 13x
12万行 15s / 450MB 5s / 20MB 3x / 22x
50万行 OOM 18s / 25MB
100万行 OOM 35s / 30MB

八、总结

复制代码
优化策略 = SXSSFWorkbook(解决写入端内存) + 分页查询(解决查询端内存)

            ┌── 写入端 ──┐     ┌── 查询端 ──┐
优化前:     │ 全在内存    │  +  │ 一次全查  │  = 600MB+ 内存
优化后:     │ 窗口200行  │  +  │ 每次5000条│  = 20MB 内存

关键代码:
  new SXSSFWorkbook(200)           → 限制写入端内存
  new Page<>(pageNum, 5000)        → 限制查询端内存
  workbook.dispose()               → 清理临时文件(必须)