☰
EasyExcel实战:从POI迁移到内存友好的导入导出方案
2026/9/26 6:55:18 网站建设 项目流程

做过后台管理系统的 Java 开发,Excel 导入导出这事十有八九躲不掉。早几年我用 Apache POI 硬啃,一个 10 万行的导出就能把 JVM 堆内存顶到告警,导入再碰上合并单元格、多行表头,解析代码能写到怀疑人生。后来项目里全面换了 EasyExcel,这批“老生常谈”的需求才算真正沉淀成了一套可复用的方案。

EasyExcel 是阿里开源的一款基于 SAX 模式的 Excel 处理框架,核心卖点就四个字:省内存。它在解析和写入时采用流式操作,不走 DOM 全量加载,所以 10 万行数据导出的内存峰值可能只有 POI 的十分之一。本文不聊官方文档里已经有的大白话,而是把我自己在导入导出这条路上趟过的坑、总结出的套路,包括复杂表头读取、样式控制、下拉校验、工作表保护、大数据量分批写入这些高频场景,一次讲清楚。适合正在用 EasyExcel 或者准备从 POI 迁移过来的后端开发同学。

1. 为什么选 EasyExcel:POI 太重,自研太傻

1.1 EasyExcel 到底解决了什么问题

先说结论:POI 不是不能用,而是大部分项目用错了姿势。POI 的 XSSFWorkbook 在读取时会把整个 Excel 解析成一个内存中的树形结构,每个单元格、样式、合并区域都占据对象,一个 5 万行的文件轻松吃掉几百 MB 堆内存。而 EasyExcel 底层用的是 SAX 事件模型,逐行触发解析事件,读一行处理一行,写一行刷一行,内存占用自然就降下来了。

我做过一次很直观的对比测试:同样导出一份 10 万行、20 列的报表,POI 的 XSSFWorkbook 方式下 JVM 堆内存飙到 800MB 以上,还伴随频繁 Full GC;换成 EasyExcel 后,峰值内存稳定在 50MB 左右,耗时也几乎只有 POI 的一半。这个差距在线上环境就是“能不能撑过月底报表高峰”的区别。

1.2 和其他方案比一比

表格对比更直观:

方案内存占用上手成本复杂表头样式控制社区活跃度
Apache POI(XSSF)高较高支持但代码量大强,底层直接操作高
Apache POI(SXSSF)中较高支持但代码量大弱,依赖低层 API高
Hutool Excel中低一般一般中
FastExcel低低支持支持中
EasyExcel低低支持支持高

EasyExcel 最舒服的一点是注解驱动。你只要在实体类上标好@ExcelProperty、@ColumnWidth、@DateTimeFormat,读写逻辑基本就完成了一大半。POI 当然也能做,但同样的效果,POI 的代码量至少是 EasyExcel 的三倍,而且每个字段都要手动 getRow、getCell、getValue,枯燥且容易下标越界。

还有一点必须提醒:EasyExcel 依赖 POI,所以项目里如果已经有 POI 依赖,一定要统一版本。EasyExcel 3.x 默认依赖 POI 5.x,如果你的项目还锁着 POI 4.x,运行时大概率会报NoSuchMethodError这类诡异异常。另外,从 EasyExcel 3.1.0 开始,包名统一为com.alibaba.excel,如果你在网上看到的旧教程还是org.apache.poi混着写,要注意做适配。

2. 导入功能:读 Excel,从 Listener 说起

2.1 最基本的一张表读取

EasyExcel 读文件有两种姿势:监听器模式和同步读模式。监听器模式适合大文件,因为是流式逐行回调;同步读适合小文件,一把梭返回 List,简单直接。

监听器模式的核心是一个继承AnalysisEventListener<T>的类:

@Slf4j public class UserImportListener extends AnalysisEventListener<UserBO> { private static final int BATCH_COUNT = 500; private final List<UserBO> batchList = new ArrayList<>(); private final BatchSaveService batchSaveService; public UserImportListener(BatchSaveService batchSaveService) { this.batchSaveService = batchSaveService; } @Override public void invoke(UserBO data, AnalysisContext context) { batchList.add(data); if (batchList.size() >= BATCH_COUNT) { batchSaveService.batchSave(batchList); batchList.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { if (!batchList.isEmpty()) { batchSaveService.batchSave(batchList); } } }

看到了吗?invoke方法每读一行就会回调一次,所以我们用 BATCH_COUNT 做批量攒批,避免一条一条地打数据库。这是导入性能的关键,在线导入场景下,一万行数据单条 insert 和 500 条批量 insert 的耗时差距能到几十倍。

调用的时候:

EasyExcel.read(inputStream, UserBO.class, new UserImportListener(batchSaveService)) .sheet() .doRead();

同步读则更简单,适合不那么在乎性能的配置文件导入:

List<UserBO> list = EasyExcel.read(fileName) .head(UserBO.class) .sheet() .doReadSync();

注意一个细节:同步读会一次性把所有行加载进内存,文件很大的时候该用监听器还是用监听器,不要偷懒。

2.2 复杂表头导入:headRowNumber 怎么用

关键词“easyexcel 复杂的表头导入”是真实业务里高频出现的痛。很多线下表格不是规规矩矩一行表头,而是两行、三行,甚至前两行还是合并单元格,第三行才是真正的列名。这时候如果直接用head(UserBO.class),EasyExcel 会把第一行当成表头,最后解析出来的数据全部对不上。

解决办法有两个:指定表头行数,或者使用动态表头。

指定表头行数是在read后追加.headRowNumber(3),告诉框架跳过前面 3 行,从第 4 行开始读数据:

EasyExcel.read(inputStream, UserBO.class, listener) .headRowNumber(3) .sheet() .doRead();

这个参数在实际业务里特别重要。比如某银行的客户导入模板,第一行是大标题“某某银行客户信息批量导入模板”,第二行是“制表日期:2024-XX-XX”,第三行才是“姓名 身份证号 手机号”,你解析时 headRowNumber 必须设为 3。

还有一种更头疼的情况:表头列顺序和实体字段顺序不一致,或者模板升级后列顺序变了。这时候可以在实体类头上用@ExcelProperty(value = "身份证号", index = 1)明确指定列索引,让字段和列强绑定,而不是依赖顺序。我在项目里通常建议所有导入实体都加 index,因为业务模板一旦对外发布,列顺序不能随便改,但代码可能重构,显式 index 最保险。

2.3 校验与错误处理:别让用户只拿到一句“导入失败”

导入功能最容易被吐槽的点就是错误提示太模糊。用户传了个 Excel,你告诉他“第 3 行数据格式错误”,他得自己数半天。真正好用的导入应该是:解析完所有行,把所有错误行号、字段、原因收集起来,要么返回一个错误明细 List,要么直接把错误标记写回 Excel 里返给用户下载。

我的做法是这样:监听器里维护一个List<ExcelImportError>,每解析一行就先做规则校验,不通过就记录错误,不做中断。等doAfterAllAnalysed时把正确数据批量入库,错误数据原样返回。

校验规则尽量下沉到字段注解,框架内置了对@NotNull、@Pattern等 JSR-303 注解的支持,前提是你引入javax.validation并配置好全局校验器。但 JSR-303 只能做单字段校验,跨字段的逻辑(比如“开始日期不能晚于结束日期”)还是要自己写。

更实用的是把错误标记直接做到导出模板里:解析完成后,把原始 Excel 读成表头,在下一行按行号写入错误原因,颜色标红,再通过接口下载给业务方。这样就完成了从“导入失败”到“修改后再传”的正向闭环,业务方体验会好非常多。

3. 导出功能:从数据到表格的完整套路

3.1 基础导出:注解 + 多 Sheet

导出相比导入要简单一些,核心是实体注解的準确性。一个典型的导出模型:

@Data public class OrderExportBO { @ExcelProperty(value = "订单号", order = 0) private String orderNo; @ExcelProperty(value = "下单时间", order = 1) @DateTimeFormat("yyyy-MM-dd HH:mm:ss") private Date orderTime; @ExcelProperty(value = "金额", order = 2) @NumberFormat("0.00") private BigDecimal amount; @ExcelProperty(value = "状态", order = 3) private String status; @ColumnWidth(20) private String remark; }

写入:

EasyExcel.write(response.getOutputStream(), OrderExportBO.class) .sheet("订单明细") .doWrite(orderList);

多 Sheet 导出时要改用ExcelWriter,避免一个doWrite只能写一张 sheet 的局限:

try (ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build()) { WriteSheet sheet1 = EasyExcel.writerSheet("上海订单").head(OrderExportBO.class).build(); WriteSheet sheet2 = EasyExcel.writerSheet("北京订单").head(OrderExportBO.class).build(); writer.write(shanghaiList, sheet1); writer.write(beijingList, sheet2); }

这里有个小坑:EasyExcel.write里传OutputStream时,如果后续代码有异常,流必须被正确关闭,否则用户下载下来的 Excel 文件会损坏。用 try-with-resources 配合实现了Closeable的ExcelWriter,是最省心的做法。

3.2 复杂表头导出:合并单元格的终极解法

热搜词里一直有人搜“easyexcel 复杂的表头导入”,其实导出同样逃不掉复杂表头。需求往往是这样的:统计报表第一行有“部门”和“成员”两个大分类,下面再细分“姓名、工号、职位、入职时间”。注解里写死字段名可以做到,但是合并单元格要靠自定义策略。

EasyExcel 提供了一个CellWriteHandler接口,可以在写入单元格时干预样式和合并行为。实现一个通用合并策略:

public class ExcelMergeWriteHandler implements CellWriteHandler { private final int[] mergeColumnIndex; private final int mergeRowIndex; public ExcelMergeWriteHandler(int mergeRowIndex, int[] mergeColumnIndex) { this.mergeRowIndex = mergeRowIndex; this.mergeColumnIndex = mergeColumnIndex; } @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, List<WriteCellData<?>> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (isHead) { return; } Sheet sheet = writeSheetHolder.getSheet(); int currentRow = cell.getRowIndex(); int currentCol = cell.getColumnIndex(); // 根据当前值和上一行值判断是否需要合并 String currentValue = cell.getStringCellValue(); Cell upCell = sheet.getRow(currentRow - 1) == null ? null : sheet.getRow(currentRow - 1).getCell(currentCol); String upValue = upCell == null ? "" : upCell.getStringCellValue(); if (currentValue != null && currentValue.equals(upValue)) { // 合并当前单元格和上一行同列单元格 sheet.getMergedRegions().stream() .filter(region -> region.containsRow(currentRow - 1) && region.containsColumn(currentCol)) .findFirst() .ifPresent(region -> { sheet.removeMergedRegion(sheet.getMergedRegions().indexOf(region)); sheet.addMergedRegion(new CellRangeAddress( region.getFirstRow(), currentRow, region.getFirstColumn(), region.getLastColumn())); }); } } }

这段代码的逻辑是:相邻行同列值相同时,把当前单元格和上一行已合并的区域合并起来。实际使用时要注意,合并后单元格的值只保留左上角单元格的值,所以千万不能对每一行都去 setCellValue,否则会错乱。

如果你觉得自定义 handler 太难维护,还有一个折中方案:用动态表头 List 写两行表头,然后对表头行做一次固定合并。这种方式写死合并区域,代码简单,但灵活性差一点。我的建议是:报表导出用自定义 MergeHandler,因为列值合并的规则每个月都可能变,模板写死反而更痛苦。

3.3 导出时的高频细节:冻结、序号、下拉、锁定,一个都不能少

这一节基本把热搜词里那几个高频问题全部覆盖了,也都是我在真实项目中被业务方反复要求过的功能点。

3.3.1 冻结列:表头不滚,前几列不丢

冻结功能用 POI 底层 API 就能做。EasyExcel 在WriteSheet建立后可以通过writeSheet.getSheet()拿到底层 sheet 对象,然后调用createFreezePane。

ExcelWriter writer = EasyExcel.write(fileName).build(); WriteSheet writeSheet = EasyExcel.writerSheet("数据").build(); writer.write(dataList, writeSheet); // 写完后获取底层sheet做冻结 Sheet sheet = writeSheet.getSheet(); sheet.createFreezePane(1, 0); // 冻结第一列 writer.finish();

createFreezePane(1, 0)的第一个参数是冻结左侧列数,第二个是冻结顶部行数。业务方常说“要冻结前两列和表头”,那就是createFreezePane(2, 1)。注意这个方法必须在writer.finish()之前调用,否则文件已经写出,再冻结就无效了。

3.3.2 序号列:别往数据库塞序号

导出时经常要加一列“序号”,最直接的做法是在实体类加一个rownum字段,把数据 List 遍历一遍设置i + 1。但既然用了 EasyExcel,更优雅的方式是写一个简单的CellWriteHandler:

public class SequenceWriteHandler implements CellWriteHandler { @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (isHead) { return; } if (cell.getColumnIndex() == 0) { cell.setCellValue(relativeRowIndex + 1); } } }

然后在write时注册:

EasyExcel.write(response.getOutputStream(), OrderExportBO.class) .registerWriteHandler(new SequenceWriteHandler()) .sheet("订单") .doWrite(orderList);

只要第一列是序号,不管数据量多大,相对行号加一就是正确的序号,完全避免在内存里再生成一套带序号的数据副本。

3.3.3 下拉框:单选可以,复选别想了

“easyexcel 支持下拉框复选吗”——不支持。Excel 原生下拉(数据验证)本身就是单选,EasyExcel 没有封装下拉框注解,POI 的DataValidation也只能做单选。所以如果你看到有人问复选,答案很明确:要么用前端交互做多选,要么放弃下拉框。

单值下拉还是经常要做的。实现思路是:先用 EasyExcel 把数据写到ByteArrayOutputStream,拿到底层 Workbook 后,再用 POI 的DataValidationHelper添加下拉数据验证:

ByteArrayOutputStream out = new ByteArrayOutputStream(); ExcelWriter writer = EasyExcel.write(out).build(); WriteSheet writeSheet = EasyExcel.writerSheet("数据").build(); writer.write(dataList, writeSheet); writer.finish(); Workbook workbook = writeSheet.getSheet().getWorkbook(); Sheet sheet = workbook.getSheetAt(0); DataValidationHelper helper = sheet.getDataValidationHelper(); DataValidationConstraint constraint = helper.createExplicitListConstraint( new String[]{"启用", "停用", "待审核"}); CellRangeAddressList addressList = new CellRangeAddressList(1, 500, 5, 5); DataValidation validation = helper.createValidation(constraint, addressList); validation.setShowErrorBox(true); sheet.addValidationData(validation);

这里有几个关键细节:

  • 行范围要留足余量。比如模板数据可能涨到几百行,你就给1-500范围,宁宽勿窄,否则后面新增的行没有下拉。
  • setShowErrorBox(true)能保证用户输入不在选项内时 Excel 弹提示,这是业务方的硬性要求。
  • 下拉项如果超过几十个,直接用createExplicitListConstraint会把公式撑爆,Excel 的限制是 255 个字符。这时候要改用隐藏 Sheet + 名称引用,把选项放在隐藏 sheet 里,再通过名称管理器引用,这也是最后一步再提的方案。
3.3.4 工作表保护与部分列锁定

这个需求我在做财务类报表时碰到过很多次:表里大部分列是计算好的公式,不允许用户随便改,但有两三列需要填写“备注”“确认人”,必须开放编辑。对应的就是热搜词里说的 “sheet.protectSheet 设置了就锁定了全局,style.setLocked(false) 实现部分列可编辑”。

原理是:Excel 里一个单元格是否可编辑,由两个条件共同决定,一是工作表处于保护状态protectSheet,二是单元格样式里的locked属性。默认情况下所有单元格 locked 都是 true,所以只要一protectSheet("密码"),整张表全锁死。要实现部分列可编辑,必须先把要开放的列设置为 locked=false。

在 EasyExcel 里可以通过自定义CellWriteHandler实现:

public class UnlockColumnHandler implements CellWriteHandler { private final int[] editableColumns; public UnlockColumnHandler(int[] editableColumns) { this.editableColumns = editableColumns; } @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { Workbook workbook = writeSheetHolder.getSheet().getWorkbook(); CellStyle unlockStyle = workbook.createCellStyle(); unlockStyle.setLocked(false); for (int col : editableColumns) { if (cell.getColumnIndex() == col) { cell.setCellStyle(unlockStyle); } } } }

然后在写完数据后加上:

Sheet sheet = writeSheet.getSheet(); sheet.protectSheet("pwd123");

这样用户打开文件时,未设置 unlock 的列全部只能看不能点,只有指定列可以编辑。要注意unlockStyle会覆盖原有样式,所以如果那列本来有背景色、边框,需要把原样式复制过来再改 locked 属性,不要让样式丢失。

3.3.5 列宽、日期格式、数字格式

这些属于看着不起眼、用户体验影响很大的细节。@ColumnWidth标注在实体类上对导出全局生效,但导入场景下对模板生成也有效果。日期格式用@DateTimeFormat,数字格式用@NumberFormat。我特别提醒一句:金额字段千万别用Double,一旦超过 16 位精度就出问题,导出给财务的东西必须用BigDecimal,然后用@NumberFormat("0.00")固定两位小数。否则用户看到 1.999999999,第一反应就是你们的系统有 bug。

3.4 大数据量导出:别一把梭

前面说 EasyExcel 省内存,那是相对于 POI 的 DOM 模式而言。但是如果你把数据库十万条记录一次性select出来放进 List,再传给 EasyExcel,那内存照样扛不住。大数据量导出的正确姿势是分批查询 + 分批写入。

ExcelWriter writer = EasyExcel.write(response.getOutputStream()) .head(OrderExportBO.class) .build(); WriteSheet writeSheet = EasyExcel.writerSheet("订单数据").build(); int pageSize = 5000; int pageNum = 1; while (true) { List<OrderExportBO> pageData = orderService.queryPage(pageNum, pageSize); if (pageData.isEmpty()) { break; } writer.write(pageData, writeSheet); pageData.clear(); pageNum++; } writer.finish();

这里每次只查 5000 条,写入后立刻清空列表,内存占用就是这 5000 条记录的量级。如果单 Sheet 行数超过 100 万,Excel 打开会卡,所以更稳妥的做法是按 50 万行切分 Sheet,逻辑大同小异,无非是EasyExcel.writerSheet("第1部分")、EasyExcel.writerSheet("第2部分")。

还有一个常见坑:导出接口要用response.getOutputStream()时,需要提前设置Content-Disposition,并且doWrite或writer.finish()后才能 close 流。顺序写反,比如先关了 response 流再 finish,文件会不完整。我的习惯是全部交给 try-with-resources 管理,不手动去关。

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

4.1 空行和一列都为空带来的噩梦

导入时如果 Excel 中有整行空白,EasyExcel 默认ignoreEmptyRow为 true,会跳过处理。但有时候数据中间夹着空行,你又设置ignoreEmptyRow(false)去强制解析,就会遇到AnalysisException或者读出来一列都是 null。解决思路是:

  • 模板层面禁用空行,Excel 里的“数据验证”配合条件格式提示。
  • 代码层面在invoke里做全员非空判断,如果整行字段都为 null,直接 return,不进批量 List。

我用得最多的还是后者,入侵最小,也不影响业务方填表习惯。

4.2 日期、数字、金额的格式转换坑

导入场景里最常见的 bug 是“日期读出来变成一串数字”或者“金额读出来精度少了两位”。前者多半是因为没加@DateTimeFormat注解,EasyExcel 把日期列按默认数字方式处理了。后者是因为实体字段写了Double而不是BigDecimal。建议:

  • 日期字段统一用Date+@DateTimeFormat,就算模板里是文本日期也能解析。
  • 金额字段统一用BigDecimal+@NumberFormat。
  • 单元格是文本格式时,BigDecimal字段会读取失败,提示NumberFormatException,可以在模板中把该列设置为“文本”或统一走自定义转换器,这属于进阶方案,有空单独写一篇。

4.3 POI 版本冲突 / NoClassDefFoundError

前面提过,EasyExcel 3.x 依赖 POI 5.x。项目里如果引了 POI 4.x 或者更老的poi-ooxml,运行时经常爆NoSuchMethodError。排查方法很简单:看报错栈顶是否在org.apache.poi.ss.usermodel或者org.apache.poi.xssf包下,十有八九是版本问题。解决办法是用 Maven 的dependencyManagement把 POI 版本统一到 EasyExcel 需要的版本,或者干脆去掉项目里显式声明的 POI 依赖,EasyExcel 传递依赖会自动引入。

4.4 临时文件清理引发的 FileNotFoundException

EasyExcel 在写大文件时会在临时目录下生成缓存文件。有时导出接口跑到一半报FileNotFoundException,不是数据问题,而是 Linux 服务器清理临时文件,或者程序重启导致临时文件被删。解决建议:

  • 导出时指定可靠的临时目录:System.setProperty("java.io.tmpdir", "/data/tmp")
  • 或者在EasyExcel.write时直接指定ExcelTypeEnum.XLSX(xls 格式不支持流式写入,大文件只能 xlsx)。
  • 异常发生时调用writer.finish()/writer.close()尽快释放临时文件资源。

4.5 复杂表头读取时第一行数据消失

接 2.2 的场景,不少同学设置headRowNumber(3)后,第一行数据还是丢了。原因是模板里表头真正占的行数少于 3,或者表头区域存在合并单元格导致行列结构发生变化。我的排查经验是:先不加 headRowNumber 试读,把 sheet 内容打印出来,确认“数据第一行实际在第几行”,再回填这个数字。用模板下载功能时,也要在模板里固定写好合并单元格格式,别让业务方自己改动表头。

4.6 常见问题速查表

症状大概率原因解决办法
导出文件损坏,Excel 提示修复OutputStream 提前关闭,或未 finishtry-with-resources 管理 ExcelWriter
读取日期变成数字缺少@DateTimeFormat实体字段加注解
读取金额精度丢失字段类型是 Double改用 BigDecimal +@NumberFormat
NoSuchMethodErrorPOI 版本冲突统一 POI 版本
写入时临时文件被占用tmp 目录被清理指定固定 tmp dir
复杂表头数据错位headRowNumber 配置不对打印解析内容实际确认行数
下拉框选项不生效下拉范围写错扩大 CellRangeAddressList 范围
设置了 protectSheet 但所有列还可编辑忘了给单元格应用 locked 样式先设置 locked,再保护

说实话,我在项目里最深的体会是:导入导出这件事,技术栈选型只占三成,剩下七成都在细节里。同样是 EasyExcel,有人写出来就是一套异常处理、格式控制、错误反馈都齐全的完整模块,有人写出来就是能用但一上线就被业务方吐槽的资源黑洞。差别就在于你是否认真处理了空行、格式、版本、临时文件这些边缘情况。

最后分享一个小技巧:如果你们公司有多个系统都在做导入导出,建议抽一个excel-common模块,把监听器、合并策略、解锁策略、模板下载、错误返回这些公共能力沉淀下来。不同系统的业务字段千差万别,但导入导出的骨架永远是那一套。把这些通用逻辑封装好后,新系统接导入导出功能,开发时间基本能从两三天压缩到半天,而且稳定性会高很多。这也是 EasyExcel 这类工具带给我们的最大价值——它把底层复杂性挡住了,剩下的就看你能不能把上层套路做扎实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询