ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FastExcel实战:搞定复杂表头与百万级数据导出

FastExcel实战:搞定复杂表头与百万级数据导出 做导出需求做到快崩溃的时候我把目光投向了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底层控制方式特别适合复杂表头、动态行列、模板填充和大数据量导出并存的需求。我整理了三个常用场景的对比可以参考场景POIEasyExcelFastExcel简单列表导出代码量大很轻量很轻量多级固定表头手动写合并注解配合注解配合动态列合并样式极繁琐能实现但别扭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 groupIdorg.dromara/groupId artifactIdfastexcel/artifactId version1.0.5/version /dependency如果你之前用的是EasyExcelFastExcel的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, ListMonthStat data) { ListListString 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(合计)); // 数据行按同样顺序构建 ListListObject rows new ArrayList(); for (MonthStat stat : data) { ListObject 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时注册这个handlerFastExcel.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) { ListSalesRowDTO 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.23457E15。虽然双击单元格能看到完整值但客户不认。解决办法有两个方向。第一DTO字段上使用NumberFormat(0.00)注解强制序列化为保留两位小数的文本第二在ContentStyle或者CellWriteHandler里设置单元格dataFormatcontext.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或接口配置这样业务方就能在完全不接触代码的情况下生成复杂报表。长尾需求再多也就剩改模板和配字段了。
返回列表