EasyExcel 百万数据读写记录

EasyExcel 百万数据读写记录

平台每月月末,财务都要把当月全量销售订单行数据导出给 ERP 做对账,同时线下渠道也会反向导入一批补录数据。每月订单行约 120 万条,每行包含订单号、SKU 编码、数量、单价、实付金额、渠道、仓库等十几个字段。

第一版用 POI 的 WorkbookFactory.create() 直接读,跑到 60 万行时内存撑不住,OOM 了。换 EasyExcel 后同样的文件峰值内存降到 200MB 以内,120 万行跑完没有任何问题。


一、为什么从 POI 换到 EasyExcel

POI 读取 Excel 有两种模式:

  • DOM 模式WorkbookFactory.create()):把整个文件加载进内存再遍历处理。文件小没问题,大文件直接 OOM
  • SAX 模式XSSFReader):流式解析,逐行回调。内存占用小但 API 极其繁琐,需要自己处理 XML 解析细节

EasyExcel 底层用的就是 POI SAX 模式,但封装了简洁的监听器 API,不用处理任何 XML 细节。

核心优势:不把整个文件加载进内存,无论文件多大,内存占用基本恒定,只与单批次处理量有关。


二、百万行数据导出

场景:财务在后台点”导出本月订单行”,后端把当月 120 万条订单行写成 Excel 文件供下载。

关键是分批查询 + 流式写入,不要一次性把所有数据 load 进内存:

@GetMapping("/export")
public void exportMonthlyOrderLines(
@RequestParam String yearMonth, // 格式:2022-12
HttpServletResponse response) throws IOException {

response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String filename = URLEncoder.encode("销售订单行_" + yearMonth + ".xlsx", "UTF-8");
response.setHeader("Content-Disposition", "attachment;filename=" + filename);

ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), SalesOrderLineExportDTO.class)
.build();
WriteSheet writeSheet = EasyExcel.writerSheet("订单行数据").build();

int pageSize = 5000;
int pageNum = 1;
List<SalesOrderLineExportDTO> data;

do {
// 分批查询,每次只加载 5000 条到内存
data = salesOrderLineService.queryByMonth(yearMonth, pageNum++, pageSize);
excelWriter.write(data, writeSheet); // 写完当批数据立即可以 GC
} while (data.size() == pageSize);

excelWriter.finish(); // 必须调用,否则文件末尾不完整
}

导出 DTO,用注解定义列顺序和列名:

@Data
public class SalesOrderLineExportDTO {

@ExcelProperty(value = "订单号", index = 0)
private String orderNo;

@ExcelProperty(value = "行号", index = 1)
private Integer lineNo;

@ExcelProperty(value = "SKU编码", index = 2)
private String skuCode;

@ExcelProperty(value = "商品名称", index = 3)
private String skuName;

@ExcelProperty(value = "品类", index = 4)
private String category;

@ExcelProperty(value = "数量", index = 5)
private Integer qty;

@ExcelProperty(value = "单价", index = 6)
@NumberFormat("#0.00")
private BigDecimal unitPrice;

@ExcelProperty(value = "行金额", index = 7)
@NumberFormat("#0.00")
private BigDecimal lineAmount;

@ExcelProperty(value = "优惠金额", index = 8)
@NumberFormat("#0.00")
private BigDecimal discountAmount;

@ExcelProperty(value = "实付金额", index = 9)
@NumberFormat("#0.00")
private BigDecimal actualAmount;

@ExcelProperty(value = "渠道", index = 10)
private String channel; // 天猫/京东/自营/线下

@ExcelProperty(value = "仓库编码", index = 11)
private String warehouseCode;

@ExcelProperty(value = "下单时间", index = 12)
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime orderTime;
}

不要用 EasyExcel.write(...).sheet(...).doWrite(全量数据),这会把所有数据先全部 load 进来再写,和 POI DOM 模式一样会 OOM。


三、百万行数据导入(监听器模式)

场景:线下渠道每月月初把上月补录的订单行数据通过 Excel 文件上传,约 15-20 万行。

读取用监听器接收数据,每积累 1000 行就批量入库,及时释放内存:

@Component
public class SalesOrderLineImportListener extends AnalysisEventListener<SalesOrderLineImportDTO> {

private static final int BATCH_SIZE = 1000;

// 用于缓存当前批次数据
private final List<SalesOrderLineImportDTO> buffer = new ArrayList<>(BATCH_SIZE);

// 记录导入结果
private int successCount = 0;
private final List<String> errorMessages = new ArrayList<>();

// 注意:监听器不能用 @Autowired,EasyExcel 不走 Spring 容器,必须通过构造器传入
private final SalesOrderLineService salesOrderLineService;

public SalesOrderLineImportListener(SalesOrderLineService salesOrderLineService) {
this.salesOrderLineService = salesOrderLineService;
}

@Override
public void invoke(SalesOrderLineImportDTO data, AnalysisContext context) {
// 简单校验:订单号和 SKU 不能为空
if (StringUtils.isBlank(data.getOrderNo()) || StringUtils.isBlank(data.getSkuCode())) {
int rowIndex = context.readRowHolder().getRowIndex();
errorMessages.add("第 " + rowIndex + " 行:订单号或SKU编码为空,已跳过");
return;
}
buffer.add(data);
if (buffer.size() >= BATCH_SIZE) {
saveBatch();
buffer.clear();
}
}

@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 处理最后一批不满 BATCH_SIZE 的数据
if (!buffer.isEmpty()) {
saveBatch();
}
log.info("导入完成,成功 {} 条,异常 {} 条", successCount, errorMessages.size());
}

private void saveBatch() {
salesOrderLineService.batchInsertOrUpdate(buffer);
successCount += buffer.size();
}

public ImportResult getResult() {
return new ImportResult(successCount, errorMessages);
}
}

接口层调用:

@PostMapping("/import")
public Result<ImportResult> importOrderLines(@RequestParam("file") MultipartFile file) throws IOException {
// 每次必须 new 新的监听器实例,监听器有状态(buffer),不能复用同一个对象
SalesOrderLineImportListener listener = new SalesOrderLineImportListener(salesOrderLineService);

EasyExcel.read(file.getInputStream(), SalesOrderLineImportDTO.class, listener)
.sheet()
.headRowNumber(1) // 第一行是表头,从第二行开始读数据
.doRead();

return Result.ok(listener.getResult());
}

导入 DTO(列映射与导出 DTO 对应):

@Data
public class SalesOrderLineImportDTO {

@ExcelProperty("订单号")
private String orderNo;

@ExcelProperty("行号")
private Integer lineNo;

@ExcelProperty("SKU编码")
private String skuCode;

@ExcelProperty("商品名称")
private String skuName;

@ExcelProperty("品类")
private String category;

@ExcelProperty("数量")
private Integer qty;

@ExcelProperty("单价")
private BigDecimal unitPrice;

@ExcelProperty("实付金额")
private BigDecimal actualAmount;

@ExcelProperty("渠道")
private String channel;

@ExcelProperty("仓库编码")
private String warehouseCode;

@ExcelProperty("下单时间")
// 建议先用 String 接收,再在 Service 层手动转换,避免日期格式不一致导致整批失败
private String orderTimeStr;
}

四、内存占用实测

测试数据:每行 13 个字段,包含 String × 6 + BigDecimal × 4 + Integer × 2 + LocalDateTime × 1,实测每行约 320 字节。

方案数据量堆内存峰值处理时间备注
POI DOM 模式60 万行~1.8 GB约 55s撑到 60 万就 OOM,120 万根本跑不起来
EasyExcel 流式120 万行~210 MB约 75s内存几乎恒定,数据量翻倍内存变化不大

测试时用 jstat -gc <pid> 1000 观察 GC 情况:

  • POI DOM 模式:大量 Full GC,处理期间老年代持续增长,最终 OOM
  • EasyExcel 流式:只有 Young GC,老年代几乎不增长,每批 1000 条处理完即回收

实测结论:EasyExcel 的内存占用与总数据量基本无关,只与 BATCH_SIZE 有关——把 BATCH_SIZE 从 1000 改成 500,峰值内存还能再降一半。


五、OOM 预防:几个必要的配置

除了用对 API,还有几个配套配置:

文件大小限制:防止上传超大文件把服务打挂

spring:
servlet:
multipart:
max-file-size: 100MB # 120 万行数据文件约 60-80MB,留一倍余量
max-request-size: 150MB

JVM 堆大小:按实际服务器内存合理配置

# 导入导出服务建议至少 2GB 堆,给 EasyExcel 和批量 SQL 足够空间
java -Xms2g -Xmx4g -jar app.jar

批量插入 SQL 长度控制:MySQL 默认 max_allowed_packet 是 4MB,每批 1000 条 INSERT 可能超限

spring:
datasource:
url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
// MyBatis-Plus 批量插入
salesOrderLineMapper.insertBatchSomeColumn(buffer);
// 或用 saveBatch,显式控制每批 SQL 的条数
salesOrderLineService.saveBatch(buffer, 500);

异步导出:120 万行导出耗时约 75 秒,直接 HTTP 响应容易超时。数据量超过 50 万行建议改成异步导出:

// 1. 接口立即返回任务 ID
// 2. 异步线程把 Excel 写到 OSS/本地磁盘
// 3. 生成下载链接,通过消息或轮询通知前端
@Async
public void asyncExport(String yearMonth, String taskId) {
// 写文件到 OSS
String fileUrl = ossService.uploadExcel(yearMonth, buildExcelData(yearMonth));
// 更新任务状态
exportTaskService.complete(taskId, fileUrl);
}

六、适用边界

EasyExcel 不是万能的,以下场景需要考虑其他方案:

  • 数据量超过 500 万行:即使流式写入,单文件超过 500 万行 Excel 本身就难以打开,建议拆分文件或改用 CSV
  • 需要复杂格式:合并单元格、图表、公式,EasyExcel 支持有限,复杂模板用 POI 更灵活
  • 内存真的紧张:导出可以改成 CSV 格式,内存压力几乎为零,大多数对账场景 CSV 够用

判断标准:能用 EasyExcel 就用 EasyExcel,真的需要复杂格式再考虑其他方案