1. 为什么选择EasyExcel处理Excel文件
第一次接触Excel导入导出功能时,我尝试过Apache POI。当时为了处理一个20MB的Excel文件,内存直接飙到2GB,服务器差点崩溃。后来发现了EasyExcel这个神器,同样的文件内存占用只有200MB左右,处理速度还快了三倍。这就是为什么现在Java开发者都在用EasyExcel来处理Excel文件。
EasyExcel是阿里巴巴开源的一个基于Java的Excel处理工具,底层还是用的POI,但通过创新的设计解决了POI最致命的内存问题。它采用逐行解析的模式,不像POI那样需要把整个文件加载到内存。我做过测试,处理10万行数据时,EasyExcel的内存占用只有POI的1/10。
在实际项目中,EasyExcel特别适合这些场景:
- 需要处理大数据量Excel文件(10万行以上)
- 对内存敏感的服务端应用
- 需要频繁导入导出Excel的业务系统
- 对Excel格式有特殊要求的场景
2. 环境准备与基础配置
2.1 依赖引入与版本选择
我建议使用Maven管理依赖,在pom.xml中添加以下配置:
<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.2</version> </dependency>这里有个坑要注意:EasyExcel 2.x和3.x的API有不兼容的改动。我刚开始升级时踩过坑,比如3.x版本的WriteSheet和WriteTable需要分开构建。如果是从旧项目迁移,建议先看下官方升级指南。
2.2 基础实体类设计
实体类是与Excel映射的核心,设计时要注意这些点:
@Data public class UserData { @ExcelProperty("用户ID") private Long userId; @ExcelProperty(value = "用户名", index = 1) private String username; @ExcelProperty(value = "注册时间", converter = LocalDateTimeConverter.class) private LocalDateTime registerTime; @ExcelIgnore private String secretField; }几个实用技巧:
@ExcelProperty的index属性可以明确指定列顺序,避免因字段顺序变动导致问题- 使用
@ExcelIgnore标注不需要映射到Excel的字段 - 日期等特殊类型建议配合自定义转换器使用(后面会详细讲)
3. 高级导入功能实战
3.1 大数据量导入的内存优化
处理10万行以上的数据时,直接全量读取会OOM。正确的做法是使用监听器模式:
public class UserDataListener extends AnalysisEventListener<UserData> { private static final int BATCH_SIZE = 1000; private List<UserData> cachedList = new ArrayList<>(BATCH_SIZE); @Override public void invoke(UserData data, AnalysisContext context) { cachedList.add(data); if (cachedList.size() >= BATCH_SIZE) { saveData(); cachedList.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { if (!cachedList.isEmpty()) { saveData(); } } private void saveData() { // 批量入库逻辑 userRepository.saveAll(cachedList); } }使用时这样调用:
String fileName = "large_file.xlsx"; EasyExcel.read(fileName, UserData.class, new UserDataListener()) .sheet() .doRead();这种模式下,内存中最多只保留BATCH_SIZE条数据,完美解决内存问题。我在处理50万行数据时,内存占用稳定在200MB以内。
3.2 复杂表头与多级表头处理
遇到合并单元格等复杂表头时,可以这样处理:
@Getter @Setter public class MultiHeaderData { @ExcelProperty({"主分类", "子分类", "字段名"}) private String field1; @ExcelProperty({"主分类", "子分类", "另一个字段"}) private String field2; }读取时指定头行数:
EasyExcel.read(fileName, MultiHeaderData.class, listener) .headRowNumber(3) // 三级表头 .sheet() .doRead();4. 高级导出技巧
4.1 动态列导出
有时需要根据条件动态决定导出哪些列。我的实现方案:
public void dynamicExport(HttpServletResponse response) { Set<String> includeFields = getFieldsToExport(); // 动态获取需要导出的字段 ExcelWriter writer = EasyExcel.write(response.getOutputStream()) .registerWriteHandler(new AbstractColumnWidthStyleStrategy() { @Override protected void setColumnWidth(WriteSheetHolder writeSheetHolder, List<CellData> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { // 动态设置列宽 sheet.setColumnWidth(cell.getColumnIndex(), 20 * 256); } }) .build(); WriteSheet sheet = EasyExcel.writerSheet("数据") .includeColumnFiledNames(includeFields) .build(); writer.write(queryData(), sheet); writer.finish(); }4.2 百万级数据导出优化
导出超大数据量时,我推荐使用分页查询+多次写入的方式:
try (ExcelWriter writer = EasyExcel.write(fileName).build()) { WriteSheet sheet = EasyExcel.writerSheet("数据").build(); int page = 1; while (true) { Page<UserData> pageData = userService.getByPage(page, 5000); if (pageData.isEmpty()) { break; } writer.write(pageData.getContent(), sheet); page++; } }配合web下载时,记得设置响应头:
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));5. 性能调优实战
5.1 缓存与复用优化
创建ExcelWriter的开销较大,在高并发场景下可以这样优化:
// 使用对象池复用Writer private final GenericObjectPool<ExcelWriter> writerPool; public void initPool() { writerPool = new GenericObjectPool<>(new BasePooledObjectFactory<ExcelWriter>() { @Override public ExcelWriter create() { return EasyExcel.write().build(); } }); } public void exportData(List<Data> list) { ExcelWriter writer = writerPool.borrowObject(); try { writer.write(list, EasyExcel.writerSheet("Sheet1").build()); } finally { writer.reset(); // 重置状态 writerPool.returnObject(writer); } }5.2 多线程处理技巧
对于CPU密集型的转换操作,可以使用并行流:
List<Data> processedData = rawData.parallelStream() .map(data -> { // 复杂转换逻辑 return convertData(data); }) .collect(Collectors.toList());但要注意:
- 写Excel时不要用多线程,EasyExcel本身不是线程安全的
- 线程数不要超过CPU核心数
- 对于IO密集型操作效果不明显
6. 常见问题解决方案
6.1 日期格式混乱问题
我遇到最常见的坑就是日期格式问题。推荐这样统一处理:
public class LocalDateTimeConverter implements Converter<LocalDateTime> { @Override public LocalDateTime convertToJavaData(CellData cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { if (cellData.getType() == CellDataTypeEnum.NUMBER) { return LocalDateTime.ofInstant( Instant.ofEpochMilli((long)(cellData.getNumberValue().doubleValue() * 24 * 3600 * 1000)), ZoneId.systemDefault()); } return LocalDateTime.parse(cellData.getStringValue(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } }6.2 大数据量导出内存溢出
如果必须全量数据在内存中处理,可以调整JVM参数:
-XX:+UseG1GC -Xms512m -Xmx2g -XX:MaxGCPauseMillis=200但更好的办法还是采用前面提到的分批次处理方案。
7. 企业级应用实践
7.1 与Spring Boot深度集成
在我的项目中是这样封装的:
@RestController @RequestMapping("/excel") public class ExcelController { @GetMapping("/export") public void export(HttpServletResponse response, @RequestParam MultiValueMap<String, String> queryParams) { // 1. 设置响应头 ExcelResponseUtil.prepareResponse(response, "导出数据.xlsx"); // 2. 查询数据 List<Data> data = dataService.queryByParams(queryParams); // 3. 导出 EasyExcel.write(response.getOutputStream(), Data.class) .registerConverter(new CustomConverter()) .sheet("数据") .doWrite(data); } }7.2 分布式环境下的导出方案
对于超大数据量(千万级)的导出,我的架构方案是:
- 前端触发导出请求
- 后端生成任务ID,提交到消息队列
- 异步任务处理数据,上传到OSS
- 前端轮询或接收通知下载文件
核心代码片段:
@Async public void asyncExport(Long taskId, ExportParams params) { String tempFile = "/tmp/" + UUID.randomUUID() + ".xlsx"; try { // 分页查询写入 try (ExcelWriter writer = EasyExcel.write(tempFile).build()) { int page = 1; while (true) { Page<Data> pageData = dataRepository.findByParams(params, PageRequest.of(page, 5000)); if (pageData.isEmpty()) break; writer.write(pageData.getContent(), EasyExcel.writerSheet("数据").build()); page++; } } // 上传到OSS String ossUrl = ossClient.upload(tempFile); taskRepository.updateStatus(taskId, SUCCESS, ossUrl); } catch (Exception e) { taskRepository.updateStatus(taskId, FAILED, e.getMessage()); } finally { Files.deleteIfExists(Paths.get(tempFile)); } }