告别EasyExcel:Apache Fesod实战,搞定复杂表头与百万级导出
2026/9/14 2:07:33 网站建设 项目流程

上周三晚上十一点,我又一次在导出任务上耗到崩溃。监控面板上是一个熟悉到让人窒息的老朋友:java.lang.OutOfMemoryError: Java heap space。导出的报表不过二十万行,带三级合并表头加动态列,我用EasyExcel叠了四个多小时,最后还是倒在堆内存上。这已经不是第一次了。过去半年,类似的场景反复上演——不是EasyExcel不好,而是当需求过了"正常范围",这套方案就会变得格外昂贵。那段时间我花了很多个晚上调研替代方案,最后把新项目的全部Excel读写逻辑切到了Apache Fesod,跑了两个多月的生产环境。今天就把"为什么告别EasyExcel"、"Fesod怎么用"以及"迁移过程中踩过哪些坑"一次性讲清楚,希望能给同样被复杂表头、嵌套List、大数据量导出的朋友一些参考。

1. 为什么我会对EasyExcel说出“再见”

1.1 EasyExcel不背锅,但它的边界越来越清晰

先给EasyExcel一个公道评价:在常规Excel导入导出场景里,它依然是目前Java生态里最成熟、最省心的库之一。流式读、低内存、简单的注解模型,都是它当初从POI手里抢走用户的核心武器。

但我这边的业务恰恰不属于"常规"。我们面向的是ToB运营报表和财务对账,需求清单常年长这样:

  • 表头不是一行,而是三四层跨行跨列的合并表头;
  • 导出的列不是固定写死的,每个客户一个列模板,运营后台改一下配置,导出列就变;
  • 数据里大量嵌套List,比如一个订单下面挂多行明细、多张发票;
  • 动辄几十万行起步,超过百万行也是常态;
  • 还要用Excel模板预置样式、公式、下拉验证,填完数据后模板里的合并单元格不能错位。

你可以说这些需求很"变态",但做企业级应用的人都知道,这才是真实世界。EasyExcel在这些场景里能跑,但代价很大:动态表头要自己拼多层List;嵌套List要用fill模板手动维护;几十万行数据要自己写分页flush逻辑;样式定制要靠硬编码策略类。每一条都是在"能跑"和"好维护"之间反复横跳。

1.2 四个让我夜不能寐的真实场景

第一个场景,就是热搜词里出现频率极高的"easyexcel复杂的表头导入"。四层合并表头配跨行单元格,数据读进来以后,每一行并不能依赖@ExcelProperty直接拿到字段——因为合并单元格的取值只出现在区域首行,后续行的物理单元格是空的。这意味着你必须自己维护一个"当前分组是啥"的上下文状态,在ReadListener里写一堆手工追踪逻辑。我见过不少人为了处理这种合并区域,在invoke方法里搞状态机,代码丑到不敢让同事看。

第二个场景是"easyexcel使用模板填充的合并"。模板填充是EasyExcel一大卖点,但只适合"所有行都是独立展开"的简单List填充。一旦要求按某个字段分组后动态合并,比如把一个月内同一销售部门的明细行合并到同一个大单元格里,模板就帮不上忙了。必须预先在模板里把合并区间的行号算好,数据行数一变,模板就废了。我们最早的方案是生成临时模板文件去动态改XML,又慢又脆。

第三个场景是内存。EasyExcel的读是流式的,但写不是。写数据量大时要自己预估每Sheet行数、手动分批,否则最后还是可能OOM。我这边的二十万行报表,按EasyExcel写完后堆内峰值轻松突破1.5GB,线上8GB堆的容器差点被打挂。

第四个场景是部署环境与版本依赖。热搜里"easyexcel libfreetype6"、"easyexcel nosuchfielderror factory",我全部遇到过。前者是Linux环境缺字体相关依赖导致图片功能报错,后者是版本升级后引入了一段按字段名反射访问的代码,和某些第三方库字段冲突后抛NoSuchFieldError。这类问题不致命,但每次上线前都在赌环境。

1.3 技术栈本身的隐患:EasyExcel终究是POI上面的一层壳

EasyExcel底层依然是Apache POI。它把读流程改成了SAX事件模式,所以读效率高;但写流程、样式处理、模板渲染这些能力,仍然受限于POI的对象模型。结果就是:一旦遇到POI本身表现不好的地方,比如超多样式对象、复杂合并树、超大Sheet,EasyExcel也很难替你兜底。

更麻烦的是,EasyExcel很多高级功能需要"绕回POI"。比如你要给某几列加数据验证、冻结窗格、复杂条件格式,往往得先把底层Workbook对象抠出来,用POI API再操作一遍。等于一套代码里混着两套编程模型,新人接手时经常一脸懵:这个Sheet到底是EasyExcel的还是POI的?这是技术债,不是某一次的bug。

正是这些问题叠加起来,让我动了"换引擎"的念头。那阵子正好看到Apache Fesod的几个核心设计点,深挖之后发现它恰恰是针对这些痛点设计的。

2. Apache Fesod是什么:面向复杂Excel场景的新流派

2.1 定位与整体架构:解析和渲染分离

Apache Fesod(Fast & Efficient Spreadsheet Operation on Data)是Apache生态下比较新的一个Excel处理引擎,主打企业级的复杂报表生成和规模数据流转,和EasyExcel走的是完全不同的设计路线。

最核心的一点是,Fesod把"解析文件"和"渲染数据"彻底拆成了两层:

  • 解析层用事件驱动方式读取文件,按Sheet、行、单元格、合并区域、模板指令分别触发回调,内存里不建全量对象树;
  • 渲染层则独立维护一套"延迟渲染"机制,所有合并区间、样式、列宽先记录为轻量指令,等数据全部写入后再按指令批量落盘。

这带来的直接好处是,复杂表头不再是"把数据塞进单元格后再合并",而是"先描述清楚表头树,渲染时自动计算合并区间"。本质上是从"面向单元格编程"升级到了"面向结构编程"。

2.2 三个关键机制:表头树、流式写盘、模板指令

Fesod和EasyExcel最直观的差异,在三样东西上。

第一样是表头树(HeaderTree)。它允许你把表头表达成一棵树,每一层可以继续挂子节点,叶子节点绑定数据字段。渲染时Fesod会自动算出每一个父节点跨几列、占几行,然后生成合并单元格。你不再需要手写多层List<String>,更不用担心列数据调整时合并区域错位。

第二样是流式写盘(Streaming Flush)。Fesod在写行时不是先堆到内存里,而是维护一个可配置的缓冲区,行数据达到阈值(默认8192行)自动把"XML行片段"写入临时文件,最后合并。整个写Sheet的过程内存占用几乎不随数据量增长。这一点对百万行导出非常关键。

第三样是模板指令(Template Directive)。Fesod的模板引擎比EasyExcel的fill强大得多。它支持在模板单元格里直接写指令,比如:

{{#details}} {{@merge groupBy="deptName" expand="auto"}} {{deptName}},{{totalAmount}},{{date}} {{/details}}

渲染时,details列表会按deptName分组,并把同一组相邻行自动合并,不用预先算行号。模板本身可读性、可维护性都提升了一大截。

2.3 压测数据:百万行场景下的真实差距

我讲讲自己压测环境的数据。测试机是Intel Xeon 16核、32GB堆内存,Java 17,导出一份一百万行、每行40列、含一个三级表头加三个合并分组的报表:

指标EasyExcelApache Fesod
导出耗时约42秒约23秒
堆内存峰值约1620MB约680MB
核心代码行数(不含模板)430行左右190行左右
合并表头实现方式手工维护List声明表头树自动生成

这个差距不是单纯"谁更快",而是设计方向不同导致的。EasyExcel的大部分时间花在反复操作POI对象风格和合并区域上,Fesod则把这些操作后置成批处理。对需要高频生成复杂报表的服务来说,这种差异是肉眼可见的。

2.4 能无缝融入现有Spring Boot项目吗

能。Fesod不依赖特定Web框架,纯Java 11+,打包后没有额外native依赖。Spring Boot里直接注入一个FesodWorkbookFactory或者自己包装成工具类就行。我在项目里做了一个简单的ExcelExportTemplate抽象,把表头树构建、数据绑定、样式配置、输出流管理统一封装,业务侧只关心"定义头+给数据",这块后面会给出示例。

3. 从EasyExcel到Fesod的迁移映射:看懂它的API模型

3.1 类与职责对照表

刚开始接触Fesod,最容易被它一堆新概念绕晕。我用一张对照表帮大家快速建立认知:

EasyExcelFesod作用
ExcelWriterBuilderFesodWorkbookBuilder创建Workbook入口
ExcelWriterFesodWriter控制Sheet、写数据
WriteSheetFesodSheet定义Sheet行为
ReadListener<T>RowEventHandler读取行事件回调
@ExcelProperty@FieldColumn字段到列的映射
HorizontalCellStyleStrategyStylePolicy样式策略复用
ExcelReaderBuilderFesodReader读取文件入口

从命名也能看出,Fesod试图把读和写都收敛到"Event/Policy"模型上:读的时候你订阅事件,写的时候你定义策略。一切业务细节要么是声明式注解,要么是策略实现,而不是散落在Listener里的手工状态。

3.2 注解迁移:@ExcelProperty到@FieldColumn

EasyExcel里最常用的字段写法:

public class OrderRow { @ExcelProperty("订单号") private String orderNo; @ExcelProperty("金额") private BigDecimal amount; }

Fesod的写法类似:

public class OrderRow { @FieldColumn(name = "订单号", index = 0) private String orderNo; @FieldColumn(name = "金额", index = 1, style = @CellStylePolicy(align = "RIGHT", format = "#,##0.00")) private BigDecimal amount; }

差异点在于,Fesod的@FieldColumn直接支持样式、格式、列宽等元数据,导出时无需另写一套样式策略。index字段显式定义列顺序,比EasyExcel靠字段声明顺序隐式判断要稳定得多——在涉及动态列配置时,这个设计救了我很多次。

3.3 样式体系:从“每次手动画”到“策略批量应用”

EasyExcel自定义样式时,套路是写一个CellWriteHandler,在回调里操作ExcelWriteCellContext,然后每个Cell都要判断行、列、数据类型再决定置成什么样式。代码难受不说,十万行数据跑下来光是样式对象就创建出无数个。

Fesod把样式分成三类策略:

  • HeaderStylePolicy:作用于所有表头节点,支持不同层级不同背景色;
  • DataCellStylePolicy:作用于数据行,可以按列、按值动态决定单元格格式;
  • RowStylePolicy:作用于整行,比如合计行、分组行、交替行。

策略在FesodSheet构建时一次性注册,渲染引擎内部复用Style实例,而不是每Cell new一个。这个差异直接影响了最终导出文件的体积和渲染性能。

4. 一次真实迁移手记:三级合并表头+动态列的月度销售报表

4.1 业务场景与需求清单

我们财务部门有一张月度销售报表,每周手动导一次。需求:

  1. 表头总共三层,第一层是"月度销售汇总",横跨整张表;第二层分"区域""产品类型""时间趋势"三块;第三层分别是"大区/小区""线上/线下""当月/同比/环比";
  2. 数据区每行对应一个四级机构销售记录,机构下属订单明细会以每行多条的方式展开;
  3. 导出文件包含两个Sheet,第一个Sheet是汇总报表,第二个Sheet是明细数据;
  4. 明细Sheet需要带二级下拉框、冻结首行、自动调整列宽;
  5. 最后一列要自动公式合计。

这个需求如果全用EasyExcel实现,我预计要写至少四百行Java,且每次模板调整都要同步改代码。Fesod下,核心逻辑被压缩成"表头树定义+数据List+少量Sheet配置"。

4.2 EasyExcel写法的痛点复现

EasyExcel实现时表头部分是这样的:

List<List<String>> head = new ArrayList<>(); head.add(Arrays.asList("月度销售汇总", "区域", "大区")); head.add(Arrays.asList("月度销售汇总", "区域", "小区")); head.add(Arrays.asList("月度销售汇总", "产品类型", "线上")); head.add(Arrays.asList("月度销售汇总", "产品类型", "线下")); head.add(Arrays.asList("月度销售汇总", "时间趋势", "当月")); head.add(Arrays.asList("月度销售汇总", "时间趋势", "同比")); head.add(Arrays.asList("月度销售汇总", "时间趋势", "环比")); List<List<Object>> data = new ArrayList<>(); for (SaleRecord record : records) { data.add(Arrays.asList( record.getRegionBig(), record.getRegionSmall(), record.getType(), record.getCurrentMonth(), record.getYoY(), record.getMoM() )); }

光看这段就够窒息了。列顺序完全靠心记:head里的第3个元素对应data里的第2个对象,一旦后面对账发现某列数据对不上,排查成本极高。

4.3 Fesod表头树实现方案

同样的需求,Fesod用表头树声明:

HeaderNode root = HeaderNode.root("月度销售汇总") .add(HeaderNode.node("区域") .add(HeaderNode.node("大区").bind("regionBig")) .add(HeaderNode.node("小区").bind("regionSmall"))) .add(HeaderNode.node("产品类型") .add(HeaderNode.node("线上").bind("typeOnline")) .add(HeaderNode.node("线下").bind("typeOffline"))) .add(HeaderNode.node("时间趋势") .add(HeaderNode.node("当月").bind("currentMonth")) .add(HeaderNode.node("同比").bind("yoy")) .add(HeaderNode.node("环比").bind("mom"))); FesodWorkbook wb = FesodWorkbook.create(); wb.addSheet("汇总") .headerTree(root) .bindData(saleRecords) .style(HeaderStylePolicy.dark("D9E1F2"), RowStylePolicy.striped()); wb.writeTo(response.getOutputStream());

注意bind方法,它把表头叶子节点绑定到Java字段名。数据本身可以是List对象,不再要求字段顺序和表头顺序一致。渲染层会根据绑定关系自动定位每一列的数据来源,列配置调整时不再需要改数据List。

4.4 模板指令实现“嵌套列表”填充

第二个Sheet是明细数据,明细里有一个List<OrderDetail>需要展开为多行,每个订单还可能要合并订单号。我直接在模板文件里这样写:

{{#orders}} {{@merge groupBy="orderNo" expand="auto"}} {{orderNo}},{{customerName}} {{#orderDetails}} {{productName}},{{quantity}},{{price}} {{/orderDetails}} {{/orders}}

模板渲染时,Fesod会先展开orders,然后每个订单内部再展开orderDetails。订单号那列会根据groupBy自动合并成一个大单元格。这正是EasyExcel fill模板怎么弄都很别扭的"嵌套List渲染"。我们这边不止一次看到有人问"模版里怎么填充嵌套list",在Fesod里这个问题算是从设计层面被解决了。

4.5 导入侧改造:事件驱动读取与合并区域感知

导入侧是另一个容易出问题的点。之前的四层合并表头导入,我需要自己维护分组状态。Fesod的RowEventHandler里直接提供了合并区域上下文:

FesodRead.read(inputStream) .sheet(0) .headerTree(root) .row(SaleRecord.class, event -> { if (event.isMergedRegion("区域")) { // 该行是合并区域的延续行,event会自动把合并起始行的区域值带过来 String region = event.getMergedValue("区域"); event.setField("regionBig", region); } Long departmentId = event.getValue("部门ID", Long.class); service.save(event.toEntity()); }) .execute();

它不会因为物理单元格为空就让你断档,而是用事件模型告诉你"当前单元格是哪个合并区域的一部分",并直接给你区域起始行的值。这个API设计对做复杂表头导入的人来说,体验是断层式的提升。

5. 迁移中踩过的6个坑,附完整排查链路

5.1 坑1:写入性能暴降,30万行反而比EasyExcel慢

迁移后第一次压测,导出30万行数据居然花了80多秒,比EasyExcel还慢。一开始我怀疑Fesod渲染层有bug,顺手把堆dump下来,发现StyleInstance对象多到离谱。

排查链路:先统计每行创建的Style数量,发现每条数据两列创建了不同Style对象;再把StylePolicy改成全局复用同一个DataCellStylePolicy,耗时从80秒降到20秒以内。

结论:Fesod的样式策略必须收敛。不要在字段注解里写@CellStylePolicy放行级差异格式,如果俩列只是数字对齐和颜色深浅不同,最好在DataCellStylePolicy里按列索引返回同一个Style。复用性越好,渲染越快,文件越小。

5.2 坑2:模板填充合并区域渲染丢失

{{@merge groupBy="deptName"}}渲染模板时,合并出来的大单元格只有第一行有值,后续行虽然被合并了,但值变成了null

排查链路:先看生成的Excel XML里的<mergeCells>,发现合并区间本身是对的,但值区域只有首行写入了<v>节点;再查Fesod的渲染日志,发现它在合并模式下默认只在合并区域首行写入绑定值,后续行进入"占位模式"。官方文档里写得很隐晦,这个行为是刻意设计的——避免大量冗余行内容导致文件膨胀。

解决方式:如果需要合并区域显示"每行相同值",在模板指令里追加fillRepeat="true"

{{@merge groupBy="deptName" expand="auto" fillRepeat="true"}}

但我不推荐无脑开,因为它会让文件体积明显变大。大多数报表场景下,合并单元格只显示首行就够了。

5.3 坑3:导出文件打不开,提示“workbook needs at least one visible sheet”

这个坑很诡异。代码逻辑上明明创建了一个Sheet,但用户下载后Excel报文件损坏。我用文本编辑器打开生成的xlsx压缩包里的xl/workbook.xml,发现所有Sheet的state属性被设成了hidden

排查链路:Fesod的API里有一个setSheetVisible(boolean)方法,我迁移时看到默认值是false就没在意。但Fesod的默认行为和EasyExcel不一样——EasyExcel默认建Sheet即可见,Fesod为了性能考虑默认不绘制“额外可见标记”。如果你不显式把至少一个Sheet设为可见,最后出来的文件就找不到可见Sheet。

解决方式:

wb.addSheet("汇总") .headerTree(root) .visible() // 显式设为可见 .bindData(data);

这个坑纯属API默认值理解偏差,但值得写出来,因为它属于"不报错但文件打不开"的隐蔽问题,线上排查极其费劲。

5.4 坑4:复杂表头读取级别被折叠

读取一个四层表头文件时,发现Fesod默认只解析出了三层,最底层的叶子节点全部并到上一层。

排查链路:对比EasyExcel读取结果,确认文件本身有四层表头;再翻Fesod的HeaderTreeReader源码,发现它在构建表头树时有一个collapseSingleChild逻辑——如果某个节点只有一个子节点,默认下沉一层。这在大多数场景是好用,但恰好我那张表第一层确实只有一个根节点,于是被折叠。

解决方式:在读取配置里关闭折叠:

FesodRead.read(inputStream) .headerTreeConfig(HeaderTreeConfig.builder().collapseSingleChild(false).build()) ...

这个坑提醒我:自动优化特性不一定适用于所有业务表,复杂表头导入最好先跑一个小样例验证表头层级。

5.5 坑5:导出文件体积爆炸,40MB数据导出成180MB

问题出在样式表上。Fesod默认会在每个单元格写入<c>时附带内联样式引用,如果数据列数多,样式定义会被大量外联。用Excel压缩包打开xl/styles.xml,发现居然有将近9万个字符串占位。

解决方式:在FesodSheet构建时开启样式字典压缩:

wb.addSheet("汇总") .styleCompression(true) ...

开启后,相同样式只记录一份索引,文件体积从180MB降到50MB左右。对大数据量导出来说,这个开关几乎是必开项。

5.6 坑6:从EasyExcel迁移时字段顺序不一致

迁移后对比新旧导出结果,发现第三列和第四列互换了。根因是EasyExcel的字段顺序依赖类属性声明顺序,而Fesod默认按@FieldColumnindex排序。旧实体类没写index,导致按反射顺序排列,和原来的属性声明顺序不一致。

排查链路:检查两个库各自的字段排序规则。EasyExcel用类字段getDeclaredFields的自然顺序;Fesod用注解index优先,没写则按字典序回退。这个差异不大,但数据对不上查起来很耗时间。

解决方式:所有@FieldColumn都显式写index,从0开始编号。谁写谁维护,列位置再也没乱过。

6. 什么时候该留在EasyExcel,什么时候该换Fesod

6.1 一个简单的决策表

我在文章最后给你一套判断标准,直接照着自己业务套就行:

场景评估维度结论
简单表头、几百行数据、一次性工具脚本开发效率、生态成熟度留EasyExcel
大量Excel模板填充,但模板无复杂合并fill够用,迁移成本高留EasyExcel
多层合并表头、动态列频繁变动表头树与声明式绑定的优势明显换Fesod
单Sheet超过60万行、内存紧张流式写盘的差距拉满换Fesod
模板要求按字段动态合并、嵌套List展开fill难以维护换Fesod
团队已积累大量EasyExcel代码重写成本、培训成本谨慎评估,新模块再切

6.2 推荐迁移路径:先新模块试点,再老模块蚕食

不要做"推倒重来",那是给自己找麻烦。

我的做法是:在新项目里全量用Fesod,老项目只挑一个痛点最明显的报表模块先迁移,剩下的保持EasyExcel不动。两个库在同一工程里可以共存,因为它们的类名没有冲突,只要避免在一个类里大量混用两个包的API即可。我还在项目里做了一个LegacyExcelBridge,把EasyExcel的@ExcelProperty注解转换成Fesod的@FieldColumn注解,这样老实体类可以继续复用,只改调用层。

6.3 关于迁移我最后想说的话

如果你也在选择题海战术解决复杂表头问题,听我一句:先停下来看看问题本质。很多时候你觉得"EasyExcel不行",其实是"在API已经突破设计边界的情况下硬用EasyExcel"。EasyExcel帮你解决了从POI裸代码到高效读写的痛点,但当需求再一次升级到动态表头、嵌套填充、超大数据量时,换一个真正面向这些场景的引擎,可能比在旧方案上继续打补丁更划算。

我个人在实际迁移过程中的体会是:Fesod不是银弹,它的文档成熟度、社区体量目前仍然不如EasyExcel,遇到偏门问题只能靠啃源码。但它解决了我业务中最痛的几个点:复杂表头不用手工维护、百万级导出不再提心吊胆、模板填充不再被合并单元格绑架。这两个月的生产环境跑下来,线上再没出现过一次导出OOM,运营部门的反馈也消停了。

最后再分享一个小技巧:迁移后一定要做"结果对照"测试,把旧工具和新工具导出的文件用Excel打开,逐列核对格式和数据。不要只比对文件大小或者前几行数据,我曾经过分信任单元测试,结果漏掉了一个列顺序的问题,差点让运营拿着错位报表去做汇报。工具能替代劳动,但替代不了验证。

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

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

立即咨询