ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

EasyExcel核心原理与实战:流式读写解决Java大数据量Excel导入导出OOM

EasyExcel核心原理与实战:流式读写解决Java大数据量Excel导入导出OOM

做Java后端这几年,Excel导入导出几乎是每个项目都躲不开的活儿。早年间用POI写代码,内存动不动就溢出,后来换了EasyExcel,才算是把这块的体验彻底拉满了。这篇文章我会从选型思路、底层原理、实操代码到疑难杂症排查,把EasyExcel这个工具一次讲透,帮你少踩点坑。

1. 为什么Java处理Excel都绕不开EasyExcel

先聊个最常见的场景。某天产品经理跑过来说要把用户导出的数据量加大到几十万行,你心里咯噔一下,因为手头那套基于POI的老代码,早就因为内存溢出被吐槽过三轮了。

这不是个别现象。传统POI之所以在数据量上来之后撑不住,核心在于它把整个工作簿、所有Sheet、所有单元格都当成一个整体对象树加载到JVM堆内存里。30万行、每行20列的数据,光行对象和单元格对象就有六百多万个,一个单元格对象在内存里可能占几百字节甚至更多,这一来一去堆内存就爆了。我见过一个实际案例,某报表系统用POI导出8万行数据,JVM堆开到了4G还是报OOM,最后只能走分批写入临时文件的歪路。

EasyExcel能解决这个问题,核心在于它的设计思路和POI完全不同。它不把整张表完整读进内存,而是采用流式读写加异步处理的方式,读数据时逐行解析、逐行交给用户处理,写数据时逐行生成、逐行写出到文件,整个过程中内存里最多只保留一小批数据。官方给出的数据很惊人:64MB内存就能完成百万级数据的读写,这在实际生产环境里确实是能落地的指标,不是宣传口号。

再补一个技术背景。EasyExcel底层其实还是依赖POI的SAX模式在做解析,但它把POI那套繁琐的事件处理封装成了注解和回调接口,让你用很少的代码就能完成复杂的Excel解析和生成。正因为既保留了POI的底层能力,又做了极致的封装和内存优化,EasyExcel在GitHub上的热度这些年一直很高,也成了国内Java技术栈做Excel操作的事实标准。

这篇文章我按自己的实战经验来写,适合的人群很明确:正被POI内存问题折磨的后端开发、要在项目里快速集成Excel导入导出功能的新手、以及想把大数据量Excel处理做得更稳的资深工程师。看完你能获得一套可以立刻落地的方案,不需要再去翻一堆零散文档。

2. 深入理解EasyExcel的核心机制

用工具之前,搞懂它为什么快、为什么省内存,能帮你避免很多“看着API没问题,一跑就出事”的尴尬。

2.1 流式读写:一次只处理一行

EasyExcel最重要的设计就是流式处理。读文件的时候,它不是把整个Excel文件解析成一个内存里的对象树,而是按文件格式的物理结构从头到尾扫一遍,每读取到一行完整的数据,就触发一次回调,把这行数据交给你处理,处理完这行,内存里的这行对象就可以被回收了。

写文件也是一样的逻辑。你逐行向EasyExcel填充数据模型,它内部把每行转换成Excel物理存储格式需要的字节,直接写入输出流,而不是先把所有行攒成一个巨型List再一次性写入。这个设计带来的好处非常直接:内存占用曲线是平缓的,不随数据量线性增长。

我打个比方。POI处理Excel像是搬家,把所有家具(单元格数据)先搬到新家客厅堆放整齐,再慢慢归位;EasyExcel则是直接请了一个流水线搬运团队,一件一件搬、一件一件摆,客厅里永远只有正在搬的那几件。所以哪怕数据量再大翻几倍,内存压力也几乎不变。

2.2 模型映射:注解驱动Java对象和Excel列的关系

EasyExcel对开发者最友好的设计,是用注解完成Java对象字段和Excel列的映射。你不需要像POI那样写一堆循环去遍历Cell,然后手动getCell(0)、getCell(1)再set到对象的各个属性上,而是在实体类字段上直接标注。

public class UserData { @ExcelProperty("用户ID") private Long userId; @ExcelProperty("姓名") private String name; @ExcelProperty("年龄") private Integer age; @ExcelProperty("创建时间") private Date createTime; }

用@ExcelProperty指定表头名称,EasyExcel在读取时会自动按表头名称匹配列并完成类型转换;在写入时会自动把字段值填充到对应列并生成表头。省掉的不只是代码量,更重要的是维护成本——Excel列顺序变了,你只需要调整注解对应的实体类,处理逻辑完全不用动。

注解里还有一个常用属性index,用于明确指定列的位置。当你的Excel文件没有表头、或者表头不固定只能用列索引来定位数据时,index就派得上用场:

@ExcelProperty(index = 0) private Long userId;

2.3 监听器模式:读取数据时的核心扩展点

EasyExcel读数据时要求你传入一个AnalysisEventListener的子类,它内部有invoke和doAfterAllAnalysed两个核心回调方法。前者每解析完一行就会调用一次,后者在整个文件解析完成时调用一次。

这个设计让大数据量读取变得非常灵活。你可以每读一行就做一次数据库批量写入,也可以先攒够1000行再批量插入,避免频繁的数据库连接开销。下面会给出完整的代码示例,这里先记住关键点:监听器是处理每行数据的唯一入口,业务逻辑都应该写在这里,不要指望在调用入口的地方拿到所有数据。

2.4 加密、模板填充与多Sheet支持

除了基础的读写,EasyExcel还内置了一些实用能力:

  • 密码加密的Excel文件读写,构造ReadWorkbook或WriteWorkbook时可以设置密码参数;
  • 基于模板填充的报表导出,可以提前设计好Excel模板,把动态数据填到指定区域;
  • 多Sheet读写,一个文件里可以分Sheet组织不同业务数据。

这些功能在实际项目里用到的不算高频,但关键时刻确实能救急,后面我会结合案例提到模板填充的具体用法。

3. 从零搭建项目:环境准备与关键配置

正式动手之前,先把基础工作做好。我用的是Spring Boot项目,Java版本8+,Spring Boot版本2.x,构建工具是Maven。EasyExcel目前主流的坐标是3.x版本,我用的是3.3.x,API稳定且文档丰富。

3.1 Maven依赖引入

在pom.xml中加入EasyExcel依赖:

<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.4</version> </dependency>

这里有个细节要注意。EasyExcel本身会传递依赖POI相关库,你如果项目里已经有其他POI引入,可能出现版本冲突。我的建议是,用Maven的依赖树排查一下,把项目中显式引入的POI依赖版本调整到与EasyExcel传递依赖一致,或者直接删掉冗余的POI依赖,以EasyExcel的传递依赖为准。

3.2 定义数据模型和监听器

先定义一个用户数据的实体类,对应Excel里的列:

public class UserData { @ExcelProperty("用户ID") private Long userId; @ExcelProperty("姓名") private String name; @ExcelProperty("年龄") private Integer age; @ExcelProperty("所在城市") private String city; public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } // 其余getter/setter省略,实际代码中要补全 }

再写一个监听器类,继承AnalysisEventListener<UserData>:

public class UserDataListener extends AnalysisEventListener<UserData> { private static final int BATCH_COUNT = 1000; private final List<UserData> cachedDataList = new ArrayList<>(); private final UserService userService; public UserDataListener(UserService userService) { this.userService = userService; } @Override public void invoke(UserData data, AnalysisContext context) { cachedDataList.add(data); if (cachedDataList.size() >= BATCH_COUNT) { saveData(); cachedDataList.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { saveData(); } private void saveData() { if (!cachedDataList.isEmpty()) { userService.batchSave(cachedDataList); } } }

这个监听器的设计有几个很关键的实践点。第一,invoke每来一行数据就触发一次,你不能在这里逐行去写数据库,否则大批量数据会把数据库拖垮,一定要攒批。第二,cachedDataList用List承载批数据,1000行是一个比较合理的批大小,既不会让内存有压力,也能充分利用批量插入的性能。第三,doAfterAllAnalysed里必须再调用一次saveData,因为最后不足一批的数据不会再触发invoke里的保存逻辑。

3.3 通用Excel工具类封装

实际项目里不可能只处理一种数据,我会建议封装一个通用工具类,把EasyExcel的读写逻辑集中起来,业务侧只传参数和监听器:

public class ExcelUtils { public static <T> void writeExcel(HttpServletResponse response, List<T> dataList, String fileName, Class<T> headClass) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), headClass) .sheet("数据") .doWrite(dataList); } public static <T> void readExcel(MultipartFile file, Class<T> headClass, AnalysisEventListener<T> listener) { try { EasyExcel.read(file.getInputStream(), headClass, listener) .sheet() .doRead(); } catch (IOException e) { throw new RuntimeException("Excel读取失败", e); } } }

这里有几个值得展开说的细节。

Web导出时,Content-Disposition响应头里文件名要做URL编码,尤其文件名含中文时,若不编码会出现乱码或下载失败。我最初踩过这个坑,后来固定用URLEncoder.encode处理,并且用filename*=utf-8''这个格式,这是目前浏览器兼容性最好的方案。

.sheet("数据")指定了生成的Sheet名称,不传时默认是“Sheet1”。如果业务有特殊要求,灵活设置就行。

.doWrite(dataList)是同步写接口,数据量小时完全够用。数据量大时应该改用EasyExcel.write(输出流, 实体类).sheet().doWrite(分批数据提供者)的方式,配合读取一批写一批的方式,避免一次性把全部数据装载在List里。这块后面单独说。

4. 核心实战:导入和导出完整落地

理论说再多,不如代码跑一遍。我根据自己手头一个真实项目的需求来写这部分:用户信息管理模块需要支持Excel批量导入和导出,导出数据量最大到50万行。这个场景非常典型,几乎能覆盖EasyExcel的主要用法。

4.1 单Sheet数据导入:从上传到入库

Controller层接收上传文件,调用Service处理:

@PostMapping("/import") public void importUsers(MultipartFile file) { userService.importUsers(file); }

Service层的实现:

public void importUsers(MultipartFile file) { try { EasyExcel.read(file.getInputStream(), UserData.class, new UserDataListener(userMapper)) .sheet() .doRead(); } catch (IOException e) { throw new RuntimeException("文件读取失败"); } }

整个导入流程这样就走完了。你可能觉得这代码也太少了,但EasyExcel的价值就在这里——解析Excel的复杂工作都被框架消化掉了,业务侧只需要关心数据入库逻辑。

实际生产中导入场景往往比这复杂,常见的有这些变体:

  • 文件里第一行不是表头,而是业务标题。这种情况可以用EasyExcel.read(...).sheet().headRowNumber(2).doRead(),headRowNumber(2)告诉EasyExcel前两行是表头区域,数据从第三行开始解析;
  • 只需要读取某几列数据,其他列忽略。@ExcelProperty用index指定列位置就好,没映射的列会自动跳过;
  • 文件上传超过Spring默认的1MB限制。要在application.yml里调整spring.servlet.multipart.max-file-size,比如设为50MB。

从实际经验来说,导入功能最重要的不是解析能力,而是数据校验和异常处理。我建议在监听器里增加校验逻辑,比如年龄字段如果为负数,就把错误信息记录到一个独立的List里,解析完成后统一返回给前端。这样用户一次性能看到所有问题行,而不是改一行传一次。

4.2 单Sheet数据导出:50万行数据不OOM

导出代码在工具类的基础上做一点加强,改成批次读取业务数据并逐批写入Excel:

public void exportUsers(HttpServletResponse response) { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = "用户数据"; try { String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName + ".xlsx"); ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), UserData.class).build(); WriteSheet writeSheet = EasyExcel.writerSheet("用户数据").build(); int pageSize = 10000; int pageNum = 1; List<UserData> pageData; do { pageData = userMapper.selectPage(pageNum, pageSize); excelWriter.write(pageData, writeSheet); pageNum++; } while (!pageData.isEmpty()); excelWriter.finish(); } catch (IOException e) { throw new RuntimeException("导出失败"); } }

这段代码的核心逻辑在循环里。每次从数据库查1万条数据写入Excel,写完这批数据,这批数据就可以被GC回收,内存里最多同时存在1万条记录。相比一次查50万条到内存再全部写入,内存占用降低了一个数量级。

这里我特别说明一下为什么不用更小的批次。比如每批1000条,内存更省,但数据库查询次数会从50次变成500次,总耗时可能翻倍。1万条这个值是我在实践里反复测试出来的平衡点,既保证了写入速度,又不会给JVM堆造成明显压力。如果你的服务器内存只有512MB,建议把这个值调低到5000。

分页查询本身也有讲究。我踩过一个坑:用limit offset做深分页时,offset越大查询越慢。50万条数据,前1万条很快,到40万条以后查询能慢好几倍。更优的做法是使用游标分页,即每次查询条件带上上一次查询得到的最大ID,用where id > lastId order by id limit 10000的方式。这对MyBatis或者MyBatis-Plus都很容易实现,强烈建议你在数据量大时改用游标分页。

4.3 模板填充导出:基于Excel模板生成报表

有些报表格式是固定的,比如财务对账单、成绩单,表头是设计好的样式,只需要往里填数据。EasyExcel的模板填充功能适合这种场景。先在resource目录放一个模板文件,数据填充区域用{}占位:

订单编号:{orderId} 客户名称:{customerName} 下单时间:{orderTime} 商品名称 单价 数量 金额 {list[0].productName} {list[0].price} {list[0].quantity} {list[0].amount} {list[1].productName} {list[1].price} {list[1].quantity} {list[1].amount}

填充代码:

public void fillReport(HttpServletResponse response) throws IOException { ClassPathResource templateFile = new ClassPathResource("templates/order_template.xlsx"); Map<String, Object> dataMap = new HashMap<>(); dataMap.put("orderId", "A20241001"); dataMap.put("customerName", "某公司"); dataMap.put("orderTime", "2024-10-01 12:00:00"); dataMap.put("list", orderList); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); response.setHeader("Content-disposition", "attachment;filename=order_report.xlsx"); EasyExcel.write(response.getOutputStream()) .withTemplate(templateFile.getInputStream()) .sheet() .doFill(dataMap); }

模板填充省去了在代码里拼表头样式的工作,尤其适合那种格式要求严格、样式复杂的报表。模板交给熟悉Excel的同事去设计,开发只需要定义填充占位符,两边解耦,效率高很多。

5. 高级用法与实际场景中的扩展实践

基础读写覆盖了绝大多数需求,但我在真实项目中还遇到不少“再进一步”的场景,这里挑几个有代表性的展开。

5.1 动态表头导出

有些业务场景的表头是动态的。比如做销售数据透视,列名是月份,1月、2月、3月是动态变化的,实体类没法提前写死属性。

EasyExcel支持用List<List<String>>动态定义表头,配合List<List<Object>>动态定义数据行:

public void dynamicHeaderExport(HttpServletResponse response) throws IOException { List<List<String>> head = new ArrayList<>(); head.add(Collections.singletonList("维度")); for (int month = 1; month <= 12; month++) { head.add(Collections.singletonList(month + "月")); } List<List<Object>> rows = new ArrayList<>(); List<Object> row1 = new ArrayList<>(); row1.add("华东"); for (int month = 1; month <= 12; month++) { row1.add(100 + month * 10); } rows.add(row1); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); response.setHeader("Content-disposition", "attachment;filename=dynamic.xlsx"); EasyExcel.write(response.getOutputStream()) .head(head) .sheet("动态数据") .doWrite(rows); }

动态表头本质上绕过了注解映射,把所有数据都当成字符串或者数值直接写入单元格。这种方式的优点是灵活,缺点是失去了类型转换能力,日期、数字格式要自己控制好。实际使用中,我会先在内存里把数据统一转换成字符串,避免出现浮点误差之类的问题。

5.2 多Sheet写入

当业务需要把多个维度的数据放进同一个Excel文件的不同Sheet时,可以用ExcelWriter多次写:

public void multiSheetExport(HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); response.setHeader("Content-disposition", "attachment;filename=multi_sheet.xlsx"); ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).build(); WriteSheet userSheet = EasyExcel.writerSheet(0, "用户").head(UserData.class).build(); excelWriter.write(userList, userSheet); WriteSheet orderSheet = EasyExcel.writerSheet(1, "订单").head(OrderData.class).build(); excelWriter.write(orderList, orderSheet); excelWriter.finish(); }

多Sheet导出的坑主要在样式上。如果你每个Sheet的表头实体类不同,列宽和样式都是框架默认值,跟业务线对齐需求有出入时,只能再加样式策略处理。我一般建议非必要不折腾样式,先把数据准确性保证好,样式问题放到模板填充方案里去解决。

5.3 大数据量读取的并发优化

单纯的流式读取已经是IO密集操作,在性能敏感的模块里可以更进一步:用多线程并发读取多个Sheet或者多个文件。但我要泼一盆冷水,并发读取Excel不能简单地用线程池包一层就完事。Excel解析本身是CPU密集任务,线程数开太多反而会因为上下文切换拖慢速度。

我实测下来,单文件单Sheet的解析,2到4个线程基本到瓶颈,再增加线程数收益趋近于零。而且EasyExcel的SAX解析依赖文件指针顺序读取,多个线程同时处理同一个文件流,会直接破坏解析状态。真要高并发,应该是多文件并行,或者单文件内多Sheet并行,每个线程持有独立文件流。

6. 常见问题与排查技巧实录

技术文章光讲理想用法不够,我把这几年用EasyExcel踩过的坑、排查过的问题整理成一份速查表,按出现频率排序,有具体的排查思路和解决手段。

6.1 报错“Excel 文件无法解析”或“Invalid header signature”

这是最常见的启动级报错,原因通常是文件本身不是真正的Excel文件。常见情况有两种:文件后缀是.xlsx,但内容其实是一个HTML网页(比如某些系统导出的假Excel);文件已损坏,头部字节不对。

排查方法很简单:用压缩工具打开这个.xlsx文件,如果能正常看到xl/worksheets目录结构,说明是合法文件;如果解压报错或者打开后是HTML源码,就可以判断是伪Excel文件。解决思路是在代码入口增加文件头校验,检查文件内容的前几字节如果是PK开头才是合法xlsx。

6.2 读取时数据类型转换异常

EasyExcel做类型转换时,要求单元格内容和实体字段类型匹配。日期单元格拷到String字段偶尔能成功,但数字单元格映射到Integer字段如果格式不对就会抛异常。

实际项目里Excel文件往往不是开发者自己控制的,用户随便填了个"3.14"到年龄列,转换直接炸掉。我的处理方案是:实体类字段用String接收,进入业务层后再做严格校验和转换。虽然牺牲了一点便利性,但换来的是导入流程的稳定性,出了格式问题也能精确反馈是哪一行哪一列的值不对。

6.3 大数据量导出时响应超时或文件下载中断

50万数据导出不是瞬间完成的,耗时可能到十几秒甚至几十秒。这个时间已经超过了网关、Nginx等中间件默认的响应超时时间,用户那边就会看到下载失败。

我的处理方案是让导出任务异步化。前端发起导出请求后,后端立刻返回一个任务ID,后台线程池执行导出,完成后把文件写到临时目录或者上传到OSS,再通过通知机制告诉前端文件地址。这个方案在数据量特别大的时候几乎是必须的,否则一次请求干等着几十秒,体验很差也容易出问题。

6.4 列顺序对不上,数据串行

如果实体类里用@ExcelProperty("姓名")按表头名称匹配,理论上列顺序怎么变都不影响。但如果你用了index方式指定列位置,Excel文件里列顺序一调整,数据就会串行错位。

这是设计取舍问题。表头名称匹配的缺点是表头不能随意改动文字;index方式的缺点是列顺序不能变。我的实践建议是:如果是内部系统对接,列顺序是约定好的,用index性能更好;如果文件来自外部用户上传,表头名称匹配更稳健,用户即使调整了列顺序也能正确识别。

6.5 模板填充时数字精度丢失

Excel模板里如果预填了计算公式,或者单元格格式被Excel自动识别为科学计数法,填充上来的超长数字(比如身份证号)可能展示异常。常规做法是模板里预先将该列单元格格式设为文本,然后在代码里用String类型填充。

6.6 常见问题速查表

问题现象根本原因解决方案
读取抛Invalid header signature伪Excel或文件损坏校验文件头,确认PK开头
类型转换异常单元格格式与字段类型不匹配字段用String接收,业务层再转换
导出OOM一次性装入全部数据用分批查询+分批doWrite
列数据串行index方式列顺序变化改用表头名称匹配,或约定固定列顺序
下载文件名中文乱码未做URL编码用URLEncoder.encode处理文件名
大数据导出超时同步接口耗时过长改异步任务,后台生成文件
身份证号变科学计数法Excel数字格式模板设文本格式,代码用String填充

7. 写在最后的实操心得

EasyExcel上手确实不难,官方文档也写得清楚,但真正用好在生产环境,还是得注意几个方面。我这几年用下来,感受最深的是:流式读写带来的内存红利一定要配合分批处理才能真正兑现。很多人把EasyExcel当成POI的简单替代品,数据还是先全查出来塞List再一次性写入,那内存问题照样炸。

关于版本选择,我建议优先用3.x稳定版,不要追新到4.x的早期快照。EasyExcel的API在3.x已经非常成熟,社区资料也最多,遇到问题搜一下都有现成答案。另外提醒一点,EasyExcel底层依赖POI,如果你项目中还引用了POI去做其他复杂操作,尽量让POI版本听EasyExcel的,不要自己强行指定高版本,否则容易踩到依赖冲突的坑。

最后再分享一个小技巧。如果你需要导出的Excel里有自定义样式,比如特定列要变色、特定单元格要加粗,不要用代码去逐格设置样式,那是性能和开发效率的双重浪费。先用Excel做好一个模板文件,把样式固定下来,再用withTemplate填充数据,既快又稳。我在实际项目中用这个方案应对过无数次“表头要加logo”“列宽要调整”的需求,基本五分钟就能改完交付。

EasyExcel的潜力远不止导入导出这种基础场景,数据迁移、报表生成、定时任务批量导出,底层都是同一套机制。把这篇文章里的内容吃透,后面无论遇到什么Excel处理需求,你都能很快给出一个内存友好、性能可控的方案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进