前阵子项目里有位同事跑来找我,说线上一个Excel导入的功能突然把内存打满了,服务都快被拖垮。一看代码,还是老一套的POI方式:先把整个文件读进内存,再一行行遍历,遇到几个M的大文件就直接撑不住。当时我给他的建议很简单——换EasyExcel。这是我在好几个后台系统里都用过的开源库,专门解决Java读写Excel时内存占用高、代码繁琐的问题。它是基于流式解析实现的,几十万行的数据也能稳定处理,配合注解可以直接映射到实体类,做导入导出基本不用写太多业务无关的代码。如果你也是常年用POI写Excel导入导出、被OOM或者代码量搞得头疼的人,这篇内容应该能帮你少走很多弯路。
1. 为什么我用EasyExcel替代了原生POI:三个核心对比
很多人第一次听到EasyExcel的反应是:POI不是也能读Excel吗?确实能读,但"能读"和"适合在生产环境大批量读写"是两回事。
1.1 数据加载模型:整本影集进场还是按页翻看
原生POI在读写.xlsx时,默认的XSSFWorkbook会把整个文件的XML结构一次性解压并加载到内存里。这就好比你想看某本影集的第100页,结果别人先把整本影集的所有照片全部冲印出来摊在桌上,才告诉你第100页在这里。文件越大,内存占用越夸张。有人测过,一个几十MB的Excel用POI的XSSF方式读取,内存占用轻松到几百MB甚至上GB。
EasyExcel底层虽然也依赖POI的解析组件,但它用的是POI的SAX事件模式,只把当前正在处理的行保留在工作区内,处理完一行就释放一行。同样看一本影集,它直接从第1页翻到第100页,翻过的页不再占用桌面空间。这也就是为什么EasyExcel官方宣传读大文件时内存占用可以控制在几MB到几十MB量级。
1.2 代码量:几十行样板代码和十几行注解模型
用POI写一个带格式的导入功能,常规套路是:获取Workbook、找到Sheet、遍历Row、遍历Cell、判断CellType、处理Date和Number、强转String……光类型判断就够写一大串。读完之后还要自己拼DTO,异常还要逐行处理。
用EasyExcel,大部分工作落在模型和监听器上。模型类用注解标注好列名,监听器里写业务逻辑,核心读取只需要两三行代码。写导出也一样,一个List直接丢进去,自动生成表头和数据列。代码量缩减一半以上不说,结构也清晰很多:哪些字段对应哪一列,一眼就能看懂。
1.3 交付物的完整性:导出模板、填充和事件机制
POI当然也能做复杂的事情,但对普通业务系统来说,EasyExcel提供的模板填充、动态表头、合并单元格、批注等能力已经够用。最方便的是它的Fill模式,可以配合预制的Excel模板,在指定位置填充数据。运营人员要的结算单、统计报表,这些场景用Fill模式特别顺手,不用在代码里手工画表格样式。
很多团队没有深究过底层原理,以为"POI能用就行",直到线上出了内存问题才反过来换。我的建议是:新项目直接用EasyExcel,老项目里POI只保留那些EasyExcel覆盖不了的特殊需求。下面这张表是二者的主要区别:
| 对比维度 | 原生POI(XSSFWorkbook) | EasyExcel |
|---|---|---|
| 读取大文件内存 | 高,文件整体加载 | 低,流式逐行解析 |
| 代码复杂度 | 高,需要大量类型判断 | 低,注解+监听器 |
| 导出效率 | 手动拼样式和单元格 | 按注解自动生成 |
| 模板填充 | 需要自己代码处理 | 内置Fill模式 |
| 底层依赖 | 直接使用POI | 基于POI的SAX模式封装 |
2. 依赖引入与官方文档获取:三个版本坑和一个安全提醒
标题里提到jar包下载,这里必须专门说一下引入方式。直接去第三方下载站找jar是非常不明智的做法,很多站点把老版本甚至改过的包挂在页面上,你根本不知道里面加了什么。我的做法是永远从Maven中央仓库拉取,或者从官方仓库的Release页面找。
2.1 Maven坐标与版本选择速查
EasyExcel的坐标很固定,如下所示:
<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.2</version> </dependency>版本号建议至少选择3.x。1.x和2.x是早期版本,API差异比较大,很多写法到了3.x已经调整了,网上一些老教程里的代码直接拷过来编译都过不了。3.x系列一直在更新,选一个稳定时间较长的版本就行,不用追最新。
2.2 别把写入和读取的流管理搞混
这里先提醒新手一个常见坑:EasyExcel的write方法需要传入一个OutputStream,read方法传入的则是InputStream。经常有人图省事,导入时直接拿上传文件的MultipartFile转InputStream,这没问题;但导出时如果直接new FileOutputStream("xxx.xlsx")却不关流,或者用response.getOutputStream()之后忘记flush,就会出现文件损坏或者下载不完整的情况。这些都是后面要重点排查的点。
2.3 官方文档从哪里找
EasyExcel的官方文档以Markdown形式维护在托管平台仓库里,搜索"EasyExcel 官方文档"可以找到。文档里最有价值的是Quick Start部分和FAQ部分,Quick Start给出了读写最基础的代码,FAQ里全是社区踩出来的典型错误。建议把这两篇反复看两遍。
2.4 不要迷信"带UI的客户端工具"
很多人做Java项目喜欢顺手下一个可视化Excel操作工具,作者还宣称"免安装"。但从安全角度讲,一个能直接读写你系统文件的桌面程序,来源不明的情况下风险是可控但不可忽略的。能用代码库解决的问题,就别往生产环境引额外的二进制程序,这条规矩不仅适用于Excel场景。
3. 基础读写四板斧:同步读、监听器读、普通写、模板填充
EasyExcel的API看起来多,但日常业务无非四件事:小文件同步读、大文件异步读、列表导出、模板填充。把这四件事的写法固定下来,基本够用。
3.1 小文件同步读:readSync一行搞定
如果你的文件不大,比如几千行到几万行,用同步读取最省事:
List<UserImportDTO> list = EasyExcel.read(inputStream) .head(UserImportDTO.class) .sheet() .doReadSync();注意这个doReadSync()是3.x才有的写法,早期版本叫readSync()。它直接把所有数据解析成DTO列表返回,适合一次性加载到内存不心疼的场景。但文件一旦上了几十万行,就别用这个了。
3.2 大文件监听器读:流式处理的核心用法
大文件推荐定义监听器,EasyExcel会在解析过程中逐行回调invoke方法,你可以在这里做校验、转换、批量入库。关键是控制批量大小,攒够一批再提交事务,避免一批数据过多导致事务时间太长。
public class UserDataListener implements AnalysisEventListener<UserImportDTO> { private static final int BATCH_COUNT = 3000; private final List<UserImportDTO> cache = new ArrayList<>(); @Override public void invoke(UserImportDTO data, AnalysisContext context) { cache.add(data); if (cache.size() >= BATCH_COUNT) { saveBatch(cache); cache.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { if (!cache.isEmpty()) { saveBatch(cache); } } }3.3 普通导出:注解模型直接doWrite
导出的核心也是注解模型。把要导出的字段用@ExcelProperty标好,然后直接把List交给EasyExcel:
EasyExcel.write(response.getOutputStream(), UserExportDTO.class) .sheet("用户数据") .doWrite(userList);这里有个容易被忽略的点:导出的DTO和导入的DTO最好拆开。导出的DTO可能需要冗余一些关联字段或者格式化后的文本,导入的DTO则只关心Excel里真实存在的列。两者混用一个类,日子久了必出奇奇怪怪的兼容问题。
3.4 模板填充:适合做固定格式报表
Fill模式的用法和传统的"代码画表格"完全不同。你预先在Excel模板里定义好变量占位符,比如{name}、{.items},然后在Java代码里填充实际数据:
ExcelWriter writer = EasyExcel.write(out).withTemplate(templateStream).build(); FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); WriteSheet sheet = EasyExcel.writerSheet().build(); writer.fill(dataList, fillConfig, sheet); writer.finish();这套组合在做月报、对账单、合同打印时特别省力。运营同事在Excel里画好样式和公式,你只管喂数据,完全不用关心单元格的边框和颜色。
4. 注解模型与动态操作:从"能跑"到"能上生产"
把所有列都写死当然简单,但真实业务往往要处理动态列头、日期格式、数值精度这些问题。这里聊几个我实际项目里用到的处理方式。
4.1 @ExcelProperty的使用逻辑:name和index
@ExcelProperty有两个常用属性:value指定表头名称,index指定列索引。当Excel列顺序固定时,用value按表头匹配就够了;当Excel列顺序不固定、但列头名称固定时,也建议用value。只有当表头名称本身不可靠但你确定列的顺序时,才用index。不要混用,混用会让EasyExcel的匹配逻辑变得不可预测。
public class UserImportDTO { @ExcelProperty("用户名") private String name; @ExcelProperty(value = "年龄", index = 2) private Integer age; @ExcelProperty("创建时间") private Date createTime; }4.2 动态列头的处理思路
有些报表场景,列头是根据查询条件动态生成的,比如"1月销售额""2月销售额"这种。EasyExcel对这类需求的官方方案是使用动态Head列表,但这一块的文档写得比较散,容易让人看懵。我的做法通常是:先用List<List<String>>拼好表头,再传入EasyExcel.write().head(headList),数据行用List<List<Object>>组织。虽然丢掉了注解模型的类型转换能力,但胜在灵活,任何列都可以动态拼。
List<List<String>> head = new ArrayList<>(); head.add(Collections.singletonList("姓名")); for (String month : monthList) { head.add(Collections.singletonList(month + "销售额")); } EasyExcel.write(outputStream) .head(head) .sheet("月度统计") .doWrite(dataRows);4.3 日期、数字、枚举与精度处理
日期列默认读出来是Date对象,但Excel里的日期有时会被源系统写成字符串,比如"2024-01-01 10:00:00"。这时建议在DTO对应字段上直接声明为String,后续再按业务需要解析,否则可能遇到类型转换异常。
数字列最大的坑是:大数值Long会被转成科学计数法,或者精度丢失。Excel单元格存储数字的精度有限,超过15位的数字本身就可能失真,比如订单号、身份证号这种应该用字符串。处理方式是在写入时对字段使用@NumberFormat,或者更简单——干脆把这类字段的类型定义成String,EasyExcel会按字符串写入Excel,避免精度问题。
枚举字段的建议是:不要直接在实体类字段上做转换,在DTO里加一个中间字段,读取时把原始字符串映射成枚举,写入时把枚举映射成字符串。保持DTO纯净,业务逻辑和转换逻辑分开,维护成本最低。
5. 压测数据与读写参数调优:大文件场景的经验值
用EasyExcel能防OOM是共识,但"不OOM"和"性能好"是两码事。我专门在一台8G内存的开发机上做过一次读写压测,这里给出一组可供参考的数据。
5.1 一个简单的压测基准
数据规模:40万行、20列,字段包括字符串、日期、数字、布尔值。测试文件大小约80MB。读取时使用监听器方式,每条数据只做简单的对象组装和计数,不做入库。结果大概是这样:
| 操作类型 | 耗时(参考值) | 内存占用 |
|---|---|---|
| EasyExcel监听器读取 | 40~60秒 | 稳定在几十MB量级 |
| 同步readSync读取 | 35~50秒 | 明显高于监听器方式 |
| 导出40万行 | 50~80秒 | 取决于每行字段复杂度 |
这个数据只是一个量级参考,机器性能不同差异会很大。真正想说明的是:处理40万行级别的Excel,监听器方式是可行且稳定的,而如果换成POI的XSSFWorkbook,内存占用通常会高出一个数量级。
5.2 读取方向的调优参数
EasyExcel.read()里有几个参数值得调:
invokeOnce:默认每个单元格都会回调一次,如果不需要逐单元格处理,保持默认即可。ignoreEmptyRow:默认false,意味着空行也会触发invoke。如果业务上允许完全空行出现,建议忽略空行,减少无意义的回调。bufferSize:底层输入流缓冲,默认8192。针对大文件适当调高到16KB或32KB,能减少I/O次数。
EasyExcel.read(inputStream, UserImportDTO.class, listener) .ignoreEmptyRow(true) .sheet() .headRowNumber(1) .doRead();headRowNumber也很关键。如果表头有两行(比如合并单元格的复杂表头),必须显式指定从第几行开始算数据,否则会把第二行表头也当成数据处理。
5.3 写入方向的调优思路
写入性能主要卡在数据组装和对象转换上。有两点经验:第一,导出的DTO字段越少越好,不要图省事把整个实体类丢进去,没加的字段才最省事。第二,大数据量下可以拆分成多个Sheet,每个Sheet写10万行左右,既控制单文件体积,也方便按维度阅读。
5.4 并发场景的注意点
EasyExcel的ExcelWriter实例并不是线程安全的。如果多个请求同时往同一个ExcelWriter写数据,会出现数据错乱甚至文件损坏。常规做法是每个请求创建一个writer,在finally里finish()关闭。如果确实要在一个文件里写多个Sheet,而且Sheet之间数据彼此独立,建议用同步方式串行写完,不要试图多线程写同一个文件。
6. 生产环境里我踩过的六个坑及完整排查链路
工具本身不难,难的是把它嵌入真实业务时不踩暗坑。下面这六个问题我都实际遇到过,每个都会给出一条排查思路,不直接跳结论。
6.1 导出的数字在Excel里变成科学计数法
现象:导出后打开Excel,长数字显示成1.23E+14。排查链路是这样走的:先在导出DTO里确认字段类型,发现是Long;然后单独用EasyExcel写一个只有该字段的文件,现象依旧;再查官方文档和社区,定位到Excel对超过11位的数字默认显示为科学计数法。解决办法不是改代码类型,而是在字段上加@NumberFormat("0"),让EasyExcel按字符串形式写入单元格。
@ExcelProperty("订单号") @NumberFormat("0") private String orderNo;6.2 Long类型精度丢失
现象更隐蔽:导出文件里看到的订单号末几位变成0。走完排查链路发现,不是EasyExcel写错了,而是Excel单元格本身把大数字按浮点处理导致精度丢失。根治办法是这类字段在DTO里直接用String,并且源数据在查询时就转成字符串。这个坑在订单类系统里几乎必踩,第一次遇到的人容易误判为"MyBatis映射问题"。
6.3 读取时少读了一行数据
现象:明明Excel有1000行,读出来只有998行。排查时先确认headRowNumber,发现表头占了一行,没问题;再看ignoreEmptyRow,默认false,空行也会触发回调;最后发现文件里有两行是"看起来空但实际有空格或不可见字符"的行,EasyExcel解析时可能单独处理导致计数不对。这个问题没有统一的代码解法,建议在测试阶段就用一个包含空行、空格行、特殊符号的样表做回归。
6.4 日期字段读取后变成Date但时间全为0
现象:Excel里显示2024-06-05 15:30:00,读出来后只有2024-06-05 00:00:00。排查链路的关键在于源文件的单元格格式:如果单元格设置的是"日期"而不是"自定义 yyyy-mm-dd hh:mm:ss",POI解析时只能拿到日期部分。解决办法有两种:要么要求上游导出的Excel统一使用文本格式的日期列,要么在DTO里把日期字段声明为String自己解析。从工程稳定性上讲,声明为String最省心。
6.5 导出文件下载后打不开
现象:接口返回了文件,但Excel提示文件损坏。排查链路一般是:先看response.getOutputStream()有没有flush和关闭,再看writer.finish()是否在finally中执行。常见错误是调用doWrite()后直接返回,没有调finish(),导致文件末尾缺少写入器收尾信息。另外,如果在ServletOutputStream之外又手动close()了输入模板流,也可能干扰当前响应。
6.6 jar包冲突:本地正常,部署到服务器报ClassNotFoundException
现象:本地跑一切正常,打包部署后运行报POI相关类找不到。排查链路:先看mvn dependency:tree,确认引入的EasyExcel自动带了POI的3.17版本,而项目其他地方引用了另一个POI版本,Maven仲裁后保留了低版本或错误版本。解决办法是在项目里显式声明一个和EasyExcel兼容的POI版本,而不是依赖传递。这种东西不会每个项目都遇到,一旦遇到却非常消耗排查时间,提前知道能省半天。
最后再分享一点实际体会
做Java这么多年,Excel导入导出这类功能看着不起眼,但它是几乎所有后台系统都跑不掉的需求。EasyExcel最大的价值不是"省几行代码",而是把内存问题、类型转换问题、模板填充问题这些原本散落在各个项目里的通用痛点收口了。我现在的习惯是:新项目里只要有Excel处理需求,第一时间就引入它,并且约束团队成员只使用规定的几种读写模式;老项目里如果还在用POI直接怼大文件,审查时基本都会建议迁移。很多人一上来就背API,我建议反过来,先用一个真实的业务文件把读写跑通,然后去读一遍官方文档的Quick Start和FAQ,再回来处理自己项目里的边界情况,这样效率最高。