做导出需求做到快崩溃的时候,我把目光投向了FastExcel。
前阵子接了一个月度销售报表导出的需求。拆开一看,三层表头、跨列合并、日期列动态生成、底部还有合计行,加上客户要求保留样式、不能乱码、百万级数据不能OOM。用POI硬写也不是不行,但表头合并逻辑加动态列布局调整,稍微改一版需求就得改半天代码。后来换成了FastExcel,这套东西才真正理顺了。
这篇就用实际案例把复杂表格导出这件事讲透,从选型、表头设计、动态列、合并单元格、样式控制,到大数据量下的内存调优和实战中容易踩的坑,全部分享出来。适合正在处理类似Excel导出任务、或者被POI和EasyExcel的内存问题折磨过的Java后端同学参考。
先说清楚:FastExcel是dromara社区维护的一个开源项目,底层基于EasyExcel扩展,兼容大部分EasyExcel注解和API,但大幅优化了写入速度和内存占用,还补齐了很多复杂表头、动态列、样式定制上的短板。下面所有内容都以导出场景为主线展开。
1. 为什么我最后选了FastExcel,而不是继续硬啃POI或EasyExcel
做Java后端的人,Excel导出这关基本都逃不掉。早期用Apache POI,后来EasyExcel火了很多项目都在用。FastExcel相对年轻,但它解决了我实际开发里最难受的几个问题。
1.1 传统POI方案的三个核心痛点
POI很强大,可以精细控制单元格、样式、合并区域,但它的代价是代码量和心智负担极大。一个带合并的表头,你要自己去计算每个单元格的坐标,addMergedRegion、setCellStyle、setBorder这些API全要手动编排。
更麻烦的是内存问题。Workbook会把整个Excel对象模型放在内存里,哪怕是SXSSFWorkbook这种流式版本,也只是解决了写入时的内存峰值,但如果你还用了复杂的缓存策略、样式对象过多,GC照样会报警。我曾经在一个百万行数据的报表任务里,直接堆内存开到4G还是不够,最后靠分批导出才顶过去。
POI第三个痛点是导出逻辑和业务代码强耦合。字段增删、顺序调整、格式变化都挤在一块,维护一个两百行的导出工具类,谁改谁疯。
1.2 EasyExcel解决了内存问题,但复杂表格差点意思
EasyExcel解决了我很多问题,@ExcelProperty注解映射实体字段,EasyExcel.write().sheet().doWrite(list)这种链式API,一行就能导出基础列表。底层用SAX模式读、SXSSF模式写,内存占用比POI低非常多,普通列表导出基本够用。
但是真正面对复杂报表时,EasyExcel的短板也很明显。多级动态表头、合并策略、自定义样式、模板填充这些功能虽然有,但要么用起来别扭,要么文档分散。我做过一个动态列项目,列数是根据查询结果动态生成的,用dynamicHead()或者动态头List来写,API的灵活度和体验说实话一般。而且合并单元格的坑也很多,比如动态列数变化之后,之前的合并行数坐标全部要重算,写起来头大。
1.3 FastExcel带给我的实际收益
FastExcel保留了EasyExcel的注解和大部分API,迁移成本很低,同时又加入了更灵活的ExcelWriter底层控制方式,特别适合复杂表头、动态行列、模板填充和大数据量导出并存的需求。
我整理了三个常用场景的对比,可以参考:
| 场景 | POI | EasyExcel | FastExcel |
|---|---|---|---|
| 简单列表导出 | 代码量大 | 很轻量 | 很轻量 |
| 多级固定表头 | 手动写合并 | 注解配合 | 注解配合 |
| 动态列+合并+样式 | 极繁琐 | 能实现但别扭 | API更顺手,性能优化好 |
| 百万行导出 | 内存风险高 | 可用 | 官方宣称性能更好,实测确实稳 |
| 自定义样式覆盖 | 灵活 | 有限制 | 更丰富 |
这不是说FastExcel是银弹,它也有一些API设计上的细节需要适应,但综合下来,在“复杂表格导出”这个命题上,它是目前我试过的最省心的方案。
2. 复杂表格到底复杂在哪,先把问题拆清楚
很多人一提“复杂表格导出”,第一反应就是“表头多层合并”,其实这只是其中一环。真正做起来你会发现,复杂至少体现在四个维度。
2.1 表头结构复杂:多级表头、跨列、动态列坐标
拿我那个月度销售报表举例:第一行是总标题“XX集团月度销售汇总”,跨整张表合并;第二行是一级分类,比如“销售额”“成本”“利润”;第三行是二级分类,比如“线上”“线下”“华北”“华南”;最后一列还有“合计”。
这种结构如果用POI写,你要先建一个二维数组来描述这个表头矩阵,每一格占几个单元格,合并从哪个行到哪个行、哪个列到哪个列。最恶心的是,如果某一列是动态生成的,列数一变,后面所有坐标全部要重新算。
FastExcel处理这类问题有两个思路:一种是表头结构完全固定,那直接用@ExcelProperty注解配合自定义合并策略,简单;另一种是表头半固定,比如维度和指标固定,但日期列是动态的,那就要用动态表头API,把表头结构当成List动态构建。两个思路后面我会分别给代码。
2.2 数据结构复杂:字段动态增减、映射关系变化
导出的数据结构往往不是一张平表。可能是多张关联表聚合出来的DTO,可能是Map嵌套List,可能字段名在不同场景下要映射成不同列。FastExcel既支持注解匹配实体字段,也支持用Map写入,这种方式在面对接口返回的宽松结构时特别好用。
但要注意,Map写入时列顺序是按Map的迭代顺序决定的,HashMap会乱序,所以要保证动态列顺序稳定,建议用LinkedHashMap,或者通过表头List固定好列顺序,再逐行按同样的顺序构建数据。
2.3 表现层复杂:样式、合并、下拉框、图片、超链接
除了数据本身,客户还很在意“表好看”。要设置列宽、行高、字体颜色、背景色、边框,要把相同内容的单元格合并,要在某些单元格加下拉列表校验,甚至要在表里嵌入图片。这些需求用注解很难完全覆盖,必须要通过写时拦截器或者自定义WriteHandler来处理。
FastExcel保留了EasyExcel的CellWriteHandler机制,可以拦截单元格写入过程,在写入时动态修改样式、合并区域。这个机制是复杂表格导出的大杀器,但要小心性能:每行都触发拦截器时,频繁创建样式对象会导致内存上升和写入变慢,后面会专门讲优化。
3. 动手实现:一份带动态列和合并单元格的复杂报表
理论知识说再多,不如直接写一份能跑的代码。下面我以“月度销售汇总报表”为例,分步骤演示用FastExcel从头部到数据到合并到样式完整实现的过程。
3.1 引入依赖,搭好基础环境
先在你的pom.xml里加入FastExcel依赖。注意FastExcel目前有两个仓库地址,dromara的Gitee仓库和Maven中央仓库都有分发,建议直接用最新release版本:
<dependency> <groupId>org.dromara</groupId> <artifactId>fastexcel</artifactId> <version>1.0.5</version> </dependency>如果你之前用的是EasyExcel,FastExcel的API整体兼容,把import com.alibaba.excel.*换成org.dromara.excel.*即可,大部分代码不需要改动。依赖它会自动带POI相关库,版本冲突时注意以POI新版本为准。
基础数据对象和注解定义:
public class SalesRowDTO { @ExcelProperty(value = "区域", order = 0) private String region; @ExcelProperty(value = "负责人", order = 1) private String owner; @ExcelProperty(value = "线上销售额", order = 2) private BigDecimal onlineAmount; @ExcelProperty(value = "线下销售额", order = 3) private BigDecimal offlineAmount; // 合计列由计算得出,不在DTO中存储 @ExcelIgnore private BigDecimal totalAmount; }3.2 固定表头最简单的方式:注解直接搞定
如果表头结构固定,导出一行代码就够了:
FastExcel.write(response.getOutputStream(), SalesRowDTO.class) .sheet("月度销售汇总") .doWrite(salesList);这里FastExcel.write的第二参数是指定表头映射类和样式模板,sheet()表示工作表名称,doWrite()写入数据。FastExcel会根据@ExcelProperty里定义的顺序和表头名称自动生成表头,数据行也按注解映射。
需要注意:@ExcelProperty的顺序默认按声明顺序,但建议显式加order属性,避免字段较多时定义顺序和导出顺序不一致。我见过有同事因为新增字段没有设置order,导出后所有列顺序全乱了。
3.3 动态表头:列数不确定的时候这样搞
固定表头好说,但如果“日期”是动态的,比如用户选择2025年1月-3月,那报表列就要按月份展开,每个月份下面再拆“目标”“实际”“完成率”三个子列。这种情况下注解就无法胜任了,需要用动态表头方式。
思路是先构建一个二维表头结构的List,每列由表头文本和列索引组成:
public void dynamicHeaderExport(OutputStream out, List<MonthStat> data) { List<List<String>> head = new ArrayList<>(); // 第一列表头 head.add(Collections.singletonList("区域")); head.add(Collections.singletonList("负责人")); // 动态生成月份列,每个月份对应三个子表头 for (String month : selectedMonths) { head.add(Arrays.asList(month, "目标")); head.add(Arrays.asList(month, "实际")); head.add(Arrays.asList(month, "完成率")); } // 最后一列合计 head.add(Collections.singletonList("合计")); // 数据行按同样顺序构建 List<List<Object>> rows = new ArrayList<>(); for (MonthStat stat : data) { List<Object> row = new ArrayList<>(); row.add(stat.getRegion()); row.add(stat.getOwner()); for (String month : selectedMonths) { row.add(stat.getTarget(month)); row.add(stat.getActual(month)); row.add(stat.getRate(month)); } row.add(stat.getTotal()); rows.add(row); } FastExcel.write(out) .head(head) .sheet("动态月度报表") .doWrite(rows); }第一列和最后一列用单层List,多级表头的月份列用双层List,这样FastExcel会自动生成跨列合并效果。数据行统一使用List
这个方案非常适合“维度固定+指标动态”的报表结构。如果连维度本身都是动态的,那也要按同一套逻辑把维度列放进head和row里,只是需要在循环前先收集所有维度。
3.4 合并单元格和样式:用WriteHandler写更符合“复杂表”需求
动态列方案展示的是结构性操作,但复杂表格几乎绕不开合并单元格和样式控制。比如报表顶部的总标题“XX集团月度销售汇总”要跨整表合并居中;不同区域的单元格可能要用不同背景色;每个月的“完成率”超过100%要标红提示。
这些需求推荐用CellWriteHandler实现。FastExcel提供了AbstractCellWriteHandler,你重写afterCellDispose方法,在单元格内容写入后进行样式处理和合并:
public class CustomCellWriteHandler extends AbstractCellWriteHandler { private final int totalColumns; public CustomCellWriteHandler(int totalColumns) { this.totalColumns = totalColumns; } @Override public void afterCellDispose(CellWriteHandlerContext context) { if (context.getHead()) { // 处理表头行的合并,比如前两行合成一个总标题 if (context.getRowIndex() == 0) { context.getSheet().addMergedRegion(new CellRangeAddress(0, 0, 0, totalColumns - 1)); } return; } // 数据行的合并:相同区域名称合并 Integer rowIndex = context.getRowIndex(); Integer colIndex = context.getColumnIndex(); // 判断当前行和上一行的区域字段是否相同,相同则合并 if (colIndex == 0 && rowIndex > 1) { String currentRegion = context.getCell().getStringCellValue(); String previousRegion = getPreviousCellValue(context, rowIndex - 1, 0); if (currentRegion != null && currentRegion.equals(previousRegion)) { // 合并区域字段单元格 context.getSheet().addMergedRegion(new CellRangeAddress(rowIndex - 1, rowIndex, 0, 0)); } } } }然后在写Excel时注册这个handler:
FastExcel.write(out) .registerWriteHandler(new CustomCellWriteHandler(totalColumns)) .sheet("月度销售汇总") .doWrite(dataList);合并逻辑最重要的是坐标计算。Excel的行列都是从0开始的,CellRangeAddress(firstRow, lastRow, firstCol, lastCol)四个参数很容易搞反,我的习惯是先在纸上画一个3行4列的简图,标注出需要的合并区域,再去对代码。
另外,合并单元格时一定要先判断两个单元格是否已经处于某个合并区域中,否则重复合并同一个区域会直接抛异常。判断方法可以遍历sheet.getMergedRegions(),看当前行是否存在包含关系。
样式控制其实也是写handler,在afterCellDispose里通过context.getCell().getCellStyle()或新建CellStyle设置字体、背景、边框。但要注意:不要每次都新建CellStyle,最好在handler初始化时预先创建好,然后复用,否则大数据量下样式对象个数就是内存炸弹。
3.5 大数据量流水线导出:边查边写,别一次性塞进内存
百万行数据导出是另一个层面的复杂。内存里同时塞100万条DTO,不管用什么库都会OOM。FastExcel支持流式写法,配合流式查询,可以实现边查边写。
做法是:doWrite接受InputStream,数据源不一次查询全部,而是分批从数据库取,逐步写入。可以用MyBatis的游标查询,或者最简单的分页循环:
ExcelWriter writer = FastExcel.write(out).build(); WriteSheet sheet = new WriteSheet().setSheetName("大表数据"); int page = 1; int pageSize = 10000; while (true) { List<SalesRowDTO> pageData = salesMapper.selectPage(page, pageSize); if (pageData.isEmpty()) { break; } writer.write(pageData, sheet); page++; } writer.finish();writer.write()可以多次调用,每批次的数据都会追加到同一张表里。调用finish()之后,数据真正落盘。要注意的是,流式导出在Web场景下,out是response.getOutputStream(),你必须在异步线程中写,否则大表会堵死HTTP响应。
我实测过一个场景:120万行、20列的数据,用分页批量写+FastExcel,总耗时大约35秒,峰值内存只有几百M,这个量级用POI裸写基本会炸内存。
4. 实战中那些容易踩的坑,我帮你提前排雷
做任何技术方案,光看API能跑通不算完,生产环境里各种边界情况才是真正考验。以下全部是我实际项目里踩过、排查过的问题,整理成了一份“避坑清单”。
4.1 金额字段导出后变成了科学计数法
这是最常见也最让人无语的坑。Java里的BigDecimal金额值,POI底层会自动把它当作double处理,超过一定长度后Excel就显示成1.23457E+15。虽然双击单元格能看到完整值,但客户不认。
解决办法有两个方向。第一,DTO字段上使用@NumberFormat("0.00")注解,强制序列化为保留两位小数的文本;第二,在ContentStyle或者CellWriteHandler里设置单元格dataFormat:
context.getCell().getCellStyle().setDataFormat((short) BuiltinFormats.getBuiltinFormat("0.00"));两种方案我推荐注解方式,因为注解语义更清晰,而且不依赖顺序。但要注意:一旦加了数字格式注解,排序、求和这些基于数值的类型判断可能会失效,如果有Excel公式需求要谨慎。
4.2 多级表头下的合并坐标错乱
用动态表头加合并逻辑时,很容易出现“表头合并位置不对”“数据行错位”的问题。本质原因是:表头行列索引和数据行列索引不是同一个坐标系。表头可能有2行,数据从第2行开始,你在配置合并策略时,如果把表头行数忘了加上,所有数据行的合并坐标就会往上偏移。
排查技巧:在afterCellDispose里临时打印rowIndex和columnIndex,和Excel文件实际打开后的行列对比,一眼就能看出偏移量。另外,FastExcel允许在合并时指定headRowNumber,比如:
FastExcel.write(out, SalesRowDTO.class) .registerWriteHandler(new CustomCellWriteHandler(2)) // 2行表头 .sheet("报表") .doWrite(data);这里第二个headRowNumber参数要和你实际表头结构完全一致,多一层少一层都会出问题。
4.3 合并单元格之后,直接在Excel里用SUM函数求和会出错
另外一个常见的业务痛点是:表格中有合并单元格,客户想在Excel里用SUM对金额求和。比如“区域”列合并了两个单元格,但对应的“销售额”列没合并,结构是正常的。但如果你把销售金额所在的列也做了合并,SUM函数会以合并区域左上角的单元格为准,其它单元格为空值,算出来就是错的。
解决方案是:只合并需要展示一致的维度列,数值列不要合并;如果确实要合并,用公式单元格放在合并区域的最后一行,或者导出前就计算好合计值写入。这个规则我几乎每次都要提醒需求方,因为“看着好看”和“能正确计算”之间往往有矛盾。
4.4 下拉框数量一多,Excel 直接提示无效
导出带下拉框的列,常规做法是Sheet里设置DataValidation。但Excel限制单个下拉框最多显示256个值,超过了就报“数据有效性无效”。
如果你刚好要导出一列几十上百个可选值,正确答案不是抱怨Excel限制,而是把源数据放到隐藏Sheet里,然后下拉框引用的Formula1指向隐藏Sheet的区域:
String formula = "HiddenDict!$A$1:$A$300"; DataValidationConstraint constraint = sheet.getDataValidationHelper() .createFormulaListConstraint(formula);这样就能绕过256个值的魔咒。FastExcel没有直接封装这个功能,还是得通过handler拿到底层POI的Sheet对象来操作。我做“城市级联”这类需求时,都是这么初始化隐藏字典表的。
4.5 临时文件把磁盘占满了
SXSSFWorkbook流式写入会产生临时文件,FastExcel在默认配置下也会。如果并发导出量大,又没有及时清理临时目录,/tmp可能被打爆。这个问题在Docker容器里尤其隐蔽,表现为导出间歇性失败、磁盘告警。
解决方案:在启动参数或者代码里设置POI临时目录:
System.setProperty("java.io.tmpdir", "/data/excel_tmp");或者干脆用SXSSFWorkbook的setCompressTmpFiles(true),临时文件写盘前压缩。定时任务再清理一次过期临时文件,基本可以避免磁盘问题。
4.6 拦截器内创建样式导致OOM
如果你在handler里为了追求精细样式,每一行都createCellStyle一次,那么百万行导出会创建百万个样式对象,内存和GC都会崩。这属于典型的性能反模式。
正确做法:在handler构造函数里预先创建好几种固定的CellStyle,比如“正常体”“加粗体”“红字体”,在回调里根据条件选择预设样式。同样的建议也适用于Font对象。POI的样式机制本身就是基于样式的复用,复用样式对象也能显著缩小文件体积。
5. 性能调优经验:如何把百万行导出压进30秒
除了避开坑,实际项目里最关心的就是导出的“快”和“稳”。我分享几个实测有效的调优手段。
5.1 先把不必要的工作簿特性关掉
FastExcel底层走的是POI的SXSSF,默认可能会生成SharedStringsTable等结构。对于纯导出场景,可以在初始化时关闭自动列宽,因为自动列宽会扫描所有单元格内容,很容易成为百万行场景的性能瓶颈。
开启自动列宽的方法和关闭方法很容易混,我的建议是:想要列宽智能调整,就只对头部行做;数据行不要做自动列宽。如果一定要所有列都自适应,那就只在小数据量下用。
5.2 调整POI版本和解压策略
FastExcel依赖的POI版本如果和项目里已有版本冲突,建议统一提升到较新版本。POI官方在4.1.1之后对大数据量的写入性能有改善,而某些老版本在Java8下会有内存相关的bug。
另一个容易忽略的地方:EXCEL文件一般先压缩成zip再导出,ZipOutputStream的压缩等级会影响CPU占用。如果是大数据量导出,建议把压缩等级调低,或者直接输出未压缩版本,文件稍大,但生成速度快很多。具体的压缩级别可以在POI的ZipSecureFile相关配置里调整。
5.3 独立线程异步导出,不阻塞主请求
不要直接在接口方法里同步执行百万行导出,否则HTTP请求要一直挂着,网关还有超时风险。正确方案是:接口接收参数后,立即返回一个任务ID,后台用线程池执行导出,前端通过任务ID轮询进度,导出完成后下载。
简单实现思路:
ExecutorService executor = Executors.newFixedThreadPool(4); public String startExport(ExportRequest request) { String taskId = UUID.randomUUID().toString(); executor.submit(() -> { try (OutputStream out = new FileOutputStream("/tmp/export_" + taskId + ".xlsx")) { doExport(out, request); taskStatus.put(taskId, "DONE"); } catch (Exception e) { taskStatus.put(taskId, "FAILED"); } }); return taskId; }taskStatus可以放在缓存里,文件生成后提供下载地址。这套“异步+任务ID+状态轮询”的架构,在用户量较大的系统里几乎是必须的,很多生产事故其实不是导出本身崩了,而是同步导出把应用线程池堵死了。
5.4 模板文件预填充也能大幅提速
如果报表格式基本固定,只是往里填数据,用fill模板填充方案比完全代码构建要快得多。把表头、样式、格式全部体现在一个xlsx模板文件里,代码只负责填数据:
FastExcel.fill(templateInputStream) .out(out) .sheet("Sheet1") .doFill(dataList);模板方式的好处有两个:第一,样式和布局由用户自己调,改完模板重新传一份即可,完全不需要动代码;第二,这种方案避免了代码里复杂样式逻辑的重复计算,导出的速度比代码生成要快不少。我强烈建议:凡是表头样式比较复杂的报表,都优先考虑模板填充,代码里写死复杂样式等于给自己埋坑。
6. 我个人的使用经验与几条收尾建议
最后说几个我实际使用中的个人习惯,供你参考。
做导出功能前,先花半小时梳理“表头结构”和“数据模型”的关系。哪怕是临时表,我也建议画一张Excel草稿,标清楚哪些列是固定的、哪些列是动态的、哪些行要合并、哪些列是纯展示列。这一步虽然费点时间,但能省掉后面至少三倍的调试时间。坐标计算永远是n个错误里最阴险的那个。
对FastExcel的API如果拿不准,优先去看它的源码,它直接依赖POI,很多问题其实都可以去POI的底层找答案。比如合并区域、下拉框、压缩等,本质都是POI能力,FastExcel只是帮你把流程封装得更好用。
另一个建议:尽量把导出接口设计成“可重试”的。不管基础组件多稳,大文件导出时长拉长,中间网络抖动、磁盘满、用户取消请求都可能发生。做好文件生成和下载的分离,导出失败时保留日志和临时文件,别让用户看到一堆无意义的报错。
如果你正在搭建一个通用的报表导出中心,我的做法是:用FastExcel做底层执行器,上层封装一个导出配置化平台,模板用Excel维护,数据用SQL或接口配置,这样业务方就能在完全不接触代码的情况下生成复杂报表。长尾需求再多,也就剩改模板和配字段了。