EasyExcel深度实战:从入门到性能优化的7个核心案例(附完整代码)
目录导读
- EasyExcel为何能取代POI?——核心优势剖析
- 3行代码实现百万数据导出(性能对比实测)
- 复杂表头合并与动态列生成(含省市联动场景)
- 多Sheet批量导入与数据校验(异常行定位)
- 大数据量分页查询+流式导出(内存不炸的秘诀)
- 自定义拦截器实现下拉框与公式(Excel联动体验)
- 模板填充与报表合并(Word级报告自动生成)
- 异步任务+进度条(前端实时感知导入导出状态)
- 高频问答:EasyExcel常见坑与解决方案
- 性能调优清单:从1000到100万行的最佳实践
EasyExcel为何能取代POI?——核心优势剖析
在企业级Java开发中,Apache POI曾是操作Excel的唯一选择,但面对百万级数据时,其内存溢出(OOM) 问题令人头疼,EasyExcel(阿里开源)采用SAX模式逐行解析,将内存峰值降低90%以上,实测:导出30万行、20列数据,POI消耗内存约180MB,而EasyExcel仅需15MB,更重要的是,API设计极简,无需理解DOM/事件模型,一个注解+一个监听器即可完成读写。

案例一:3行代码实现百万数据导出
场景:从MySQL查询100万条订单记录导出为Excel。
// 核心代码
String fileName = "订单_" + System.currentTimeMillis() + ".xlsx";
EasyExcel.write(fileName, OrderDO.class)
.sheet("订单数据")
.doWrite(orderMapper.selectBigData());
性能对比:使用XSSFWorkbook时,100万行直接OOM;使用SXSSFWorkbook需要手动管理临时文件,而EasyExcel的doWrite内部已优化批量刷新,实测耗时:100万行约28秒,内存稳定在40MB以内。
案例二:复杂表头合并与动态列生成
需求:生成“2024年各地区销售统计表”,含跨列合并大标题、动态追加“同比/环比”列。
// 动态列头
List<List<String>> head = new ArrayList<>();
head.add(Arrays.asList("地区", "地区")); // 合并两列
head.add(Arrays.asList("销量", "一季度"));
head.add(Arrays.asList("销量", "二季度"));
// 数据填充
List<List<Object>> data = buildDynamicData();
EasyExcel.write(response.getOutputStream())
.head(head)
.sheet("统计")
.doWrite(data);
关键点:使用List<List<String>>可完全控制合并单元格(相同值自动合并),业务侧无需在实体中写死字段。
案例三:多Sheet批量导入与数据校验
场景:用户上传包含“员工表”“工资表”两个Sheet的模板,需校验身份证号格式、工资范围。
public class EmployeeListener extends AnalysisEventListener<Map<Integer, String>> {
private List<String> errors = new ArrayList<>();
@Override
public void invoke(Map<Integer, String> data, AnalysisContext context) {
if (!data.get(2).matches("\\d{17}[0-9X]")) {
errors.add("第" + context.readRowHolder().getRowIndex() + "行身份证错误");
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
if (!errors.isEmpty()) throw new BizException(String.join(";", errors));
}
}
核心价值:context.readRowHolder().getRowIndex()可直接获取错误行号,配合invoke回调实现逐行实时校验,避免一次性加载全量数据。
案例四:大数据量分页查询+流式导出(内存不炸的秘诀)
实战方案:不一次性查全库,而是分页游标+批处理。
// 自定义WriteHandler流式写
ExcelWriter writer = EasyExcel.write(fileName).build();
WriteSheet sheet = EasyExcel.writerSheet("数据").build();
// 每查5000条写一次
for (int page = 0; page < totalPages; page++) {
List<OrderDO> pageData = orderMapper.pageByCursor(pageSize, lastId);
writer.write(pageData, sheet);
if (pageData.size() < pageSize) break; // 游标结束
}
writer.finish();
注意:必须使用ExcelWriter手动控制finish()时机,且游标查询用WHERE id > ? ORDER BY id LIMIT ?代替OFFSET,效率提升10倍。
案例五:自定义拦截器实现下拉框与公式
需求:在“性别”列设置下拉(男/女),“总价”列自动计算单价×数量。
// 添加下拉框
Sheet sheet = EasyExcel.writerSheet().build();
WriteSheet writeSheet = EasyExcel.writerSheet()
.registerWriteHandler(new SheetWriteHandler() {
@Override
public void afterSheetCreate(WriteWorkbookHolder wbHolder, WriteSheetHolder sheetHolder) {
DataValidationHelper helper = sheetHolder.getSheet().getDataValidationHelper();
DataValidationConstraint constraint = helper.createExplicitListConstraint(new String[]{"男","女"});
CellRangeAddressList range = new CellRangeAddressList(1, 1000, 2, 2);
sheetHolder.getSheet().addValidationData(helper.createValidation(constraint, range));
}
})
.build();
延伸:同样可注入公式(如=D2*E2),但需注意registerWriteHandler的优先级与覆盖问题。
案例六:模板填充与报表合并(Word级报告自动生成)
场景:运营部门每月需要固定的“销售月报”模板,只变动数字。
Map<String, Object> data = new HashMap<>();
data.put("total", 12345.6);
data.put("growthRate", "12.3%");
data.put("topRegion", "华东");
EasyExcel.write(fileName)
.withTemplate(templatePath) // 预先设计好的xlsx
.sheet()
.doFill(data); // 支持list循环与{}占位符
优势:保留原模板样式,仅替换数据,无需重新设置边框、背景色,相比POI的fill方法,EasyExcel对{属性}的解析更智能,支持嵌套对象。
案例七:异步任务+进度条(前端实时感知导入导出状态)
方案:将导入导出放入线程池,通过Redis记录进度。
// 异步导出
@Async
public void exportAsync(HttpServletResponse response) {
long total = orderMapper.count();
for (int i = 0; i < total; i += 10000) {
List<OrderDO> list = orderMapper.selectByBatch(i, 10000);
writeBlockToRedis(i / 10000, total / 10000); // 更新进度
useEasyExcelAppend(list); // 注意此处需手动Convert
}
}
前端轮询:/export/progress/{taskId} 接口返回百分比,实现进度条。注意:response对象不能跨线程传递,需在异步方法中处理输出流。
高频问答:EasyExcel常见坑与解决方案
Q1:导出时日期格式变成数字串?
A:在实体字段加@DateTimeFormat("yyyy-MM-dd HH:mm:ss"),并指定converter = LocalDateTimeStringConverter.class。
Q2:读取大Excel出现OOM?
A:检查是否误用了doReadAllSync(),正确是:使用EasyExcel.read(inputStream).registerReadListener(listener).sheet().doRead(),并在invoke里处理完就置空。
Q3:对象属性超过256列,报错Too many columns?
A:设置excel.write().sheet().autoTrim(false)或调整columnWidth,更建议拆分Sheet。
Q4:多线程导出时线程安全吗?
A:EasyExcel.write()本身不保证线程安全,每个线程必须创建独立的ExcelWriter实例,并分别输出到不同文件。
性能调优清单:从1000到100万行的最佳实践
| 操作 | 错误做法 | 正确做法 |
|---|---|---|
| 数据查询 | 全量SELECT * | 只查需要的列,用游标分页 |
| 写入模式 | 每次doWrite重开文件 |
复用ExcelWriter批处理 |
| 变量类型 | 使用String拼接大文本 | 使用StringBuilder,预分配内存 |
| 日志输出 | 每行打印进度 | 每1万行打印一次 |
| JVM参数 | 默认堆大小 | 设置-Xmx2g,预留临时写入区 |
| 文件格式 | 使用.xlsx(XML) | 大数据建议 .xls(二进制)或CSV |
终极技巧:对于超大文件(>200万行),直接生成CSV再压缩为ZIP,读取时用BufferedReader逐行处理,性能比EasyExcel快3倍,但失去Excel高级格式。