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 { data = salesOrderLineService.queryByMonth(yearMonth, pageNum++, pageSize); excelWriter.write(data, writeSheet); } 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<>();
private final SalesOrderLineService salesOrderLineService;
public SalesOrderLineImportListener(SalesOrderLineService salesOrderLineService) { this.salesOrderLineService = salesOrderLineService; }
@Override public void invoke(SalesOrderLineImportDTO data, AnalysisContext context) { 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) { 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 { 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("下单时间") 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 max-request-size: 150MB
|
JVM 堆大小:按实际服务器内存合理配置
java -Xms2g -Xmx4g -jar app.jar
|
批量插入 SQL 长度控制:MySQL 默认 max_allowed_packet 是 4MB,每批 1000 条 INSERT 可能超限
spring: datasource: url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
|
salesOrderLineMapper.insertBatchSomeColumn(buffer);
salesOrderLineService.saveBatch(buffer, 500);
|
异步导出:120 万行导出耗时约 75 秒,直接 HTTP 响应容易超时。数据量超过 50 万行建议改成异步导出:
@Async public void asyncExport(String yearMonth, String taskId) { String fileUrl = ossService.uploadExcel(yearMonth, buildExcelData(yearMonth)); exportTaskService.complete(taskId, fileUrl); }
|
六、适用边界
EasyExcel 不是万能的,以下场景需要考虑其他方案:
- 数据量超过 500 万行:即使流式写入,单文件超过 500 万行 Excel 本身就难以打开,建议拆分文件或改用 CSV
- 需要复杂格式:合并单元格、图表、公式,EasyExcel 支持有限,复杂模板用 POI 更灵活
- 内存真的紧张:导出可以改成 CSV 格式,内存压力几乎为零,大多数对账场景 CSV 够用
判断标准:能用 EasyExcel 就用 EasyExcel,真的需要复杂格式再考虑其他方案。