尧图精选

EasyExcel多级表头与数据合并导出实战:从踩坑到性能调优

🕒 发布时间:2026/10/2 14:31:27 📁 来源:尧图网络
后台管理系统里十张报表八张要导出 Excel其中至少有一张得带多级表头——“订单信息”底下挂“订单编号”“下单时间”“客户信息”底下挂“客户名称”“联系方式”然后同一个客户的订单行还要在“客户名称”这一列纵向合并成一个大格子。我第一次接到 EasyExcel 导出 Excel 合并表头和数据这个需求时心里想的是“不就导个表嘛”结果真上手才发现多级表头拼接、数据行纵向合并、合并后边框样式消失、几万行数据导出直接把内存打爆这几个坑一个不落全踩了一遍。这篇文章就按我实际项目的落地过程来写从需求拆解、方案选型、核心机制到完整可跑的代码、参数调优和踩坑记录一次性讲透。它适合正在做报表导出、被合并单元格折磨过的后端和全栈同学哪怕你之前只写过最简单的单表头导出跟着走也能把带合并的复杂报表啃下来。1. 需求拆解与选型多级表头合并到底卡在哪1.1 什么样的报表需要合并表头和数据先把需求讲清楚不然后面的选型都是空中楼阁。带合并的导出通常出现在“分组统计型”报表里。比如你要导出一份“客户订单明细”逻辑结构是这样的每个客户下面挂着若干个订单每个订单下面又挂着若干商品明细。如果平铺成一行行导出客户名和订单号就会大量重复看起来极其臃肿业务方根本没法一眼看出层级关系。这种场景在 Excel 里最自然的表达方式就是合并把“客户名称”这一列里连续的相同客户合并成一个纵向大格“订单编号”这一列里同一个订单的多条商品明细合并成一个格。同时表头也往往是有层级的——“客户信息”作为一个大的父标题横跨“客户名称”“联系方式”两列。这就是典型的“合并表头 合并数据”双合并需求。很多人一开始会低估它觉得 EasyExcel 注解写几个数组就完事了。多级表头确实能用注解拼出来而且 EasyExcel 会自动把相同父标题合并这个能力很好用。但数据行的纵向合并EasyExcel 官方并没有直接提供一个“按列相同值自动合并”的开关需要你自定义合并策略。这一步才是真正的分水岭也是绝大多数人卡住的地方。所以第一件事就是明确你到底要不要合并数据行以及合并的判定维度是什么是按客户合并还是按订单合并还是两者都要。1.2 POI、EasyExcel、模板导出三种路线怎么选选型之前先看清自己手里有多少种工具。最底层的是原生 Apache POI功能最全合并、样式、公式、图表样样能写但代码极其啰嗦一个简单表格就能写上百行而且它对内存非常不友好XSSFWorkbook 在内存里构建整棵树几万行数据上百万个单元格对象堆内存分分钟溢出。它的优势是“什么都能干”劣势是“什么都得自己干”。路线二是模板导出先把表头、合并区域、样式在 Excel 模板里画好程序只负责往里塞数据用{}占位符或者列表填充。这条路的优点是样式和合并完全由模板管着代码干净特别适合“表头固定、样式复杂”的报表。缺点是动态性差如果表头列会随着筛选条件变化模板就废了。路线三就是 EasyExcel。它在 POI 基础上做了一层封装核心卖点是把读写拆成了 SAX 模式读的时候不把整个文件加载进内存写的时候也做了很多优化配合注解和回调开发效率高。它的定位很清晰面向“结构相对固定、数据量大”的报表场景。我们最终选它是因为项目里的报表已经用了 EasyExcel 做基础导出多级表头用注解直接拼出来数据合并虽然要自己写策略但比起原生 POI 还是省了大量样板代码。如果你的报表样式极度复杂、合并区域完全手工定制那 POI 或模板可能更合适但如果是常规业务报表EasyExcel 是性价比最高的选择。提示不要一上来就纠结“哪个最快”先看你的核心约束是内存、开发效率还是样式自由度不同的约束会指向完全不同的工具。1.3 版本与依赖的取舍版本这块有个必须提的坑。EasyExcel 在 3.x 版本做了较大的 API 调整比如CellWriteHandler里afterCellDispose的参数从早期的CellData变成了ListWriteCellData?一些网上的老示例直接拿到新版本里跑会编译不过。所以选版本时要么统一用 3.x推荐2023 年之后新项目基本都是 3.x要么锁定一个稳定的 2.x别混着抄代码。我个人项目的依赖是这样的dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency需要注意EasyExcel 内部依赖了 POI 和 ehcache如果你的项目里已经引了不同版本的 POI很容易出现NoSuchMethodError。做法是在父 pom 里用dependencyManagement把 POI 版本统一锁死比如 4.1.2 或 5.2.x让 EasyExcel 和你的其他依赖用同一个 POI冲突基本就没了。这个点在多人协作项目里非常关键我见过好几次“本地能跑、服务器报错”最后都栽在 POI 版本冲突上。2. 核心机制注解、表头结构与合并策略2.1 ExcelProperty 如何拼出多级表头EasyExcel 的多级表头靠的是ExcelProperty注解里传一个字符串数组。数组的每一个元素对应一层从最外层父标题到最内层子标题依次排列。比如ExcelProperty({客户信息, 客户名称}) private String customerName;这就表示“客户信息”是第一级表头“客户名称”是第二级。如果多个字段的第一级名称完全相同EasyExcel 在生成表头时会自动把这一级横向合并成一个格子。也就是说“客户信息”横跨“客户名称”和“联系方式”两列你不需要自己调addMergedRegion框架帮你做了。这是个很容易被忽略的便利点——很多人以为多级表头也得自己合并其实表头合并在 EasyExcel 里是自动的你只要保证父标题字符串一致即可。字段的导出顺序默认按注解数组的书写顺序如果你想精确控制列序还可以配合ExcelProperty(value {...}, index 0)指定索引。但这里有个坑一旦你用了 index所有需要导出的字段都得显式指定 index否则没指定的字段顺序会变得不可预测。我的习惯是干脆全部用书写顺序不写 index这样后期增删字段时不会因为漏改 index 而错位。另外导出实体里尽量只放需要导出的字段别为了省事把业务实体直接拿来当导出对象字段一多容易误导出敏感信息。2.2 表头合并与数据合并是两套机制这是全文最需要划重点的一句话表头合并是框架自动完成的数据纵向合并是要你自己写策略的两者完全不是一回事千万别混在一个思路里。表头合并靠的是相同父标题字符串自动触发你几乎零成本。而数据行合并本质上是“把同一列里连续的、值相同的单元格合并成一个区间”这个“连续相同”的判断逻辑 EasyExcel 不认识你的业务它不知道你希望按哪一列合并、哪些列要合并、相同值怎么定义。所以它只给你一个扩展点也就是合并策略让你自己去判断和调用 POI 的sheet.addMergedRegion(...)。理解这一点后整个实现思路就清晰了表头部分我用注解拼好交给框架自动合并父标题数据部分我注册一个自定义合并策略在数据写入过程中对指定列做“相邻相同值合并”。两条线各走各的互不干扰这样代码结构也最干净。很多新手把两者搅在一起试图在合并策略里连表头也管结果要么把表头误合并要么注解自带的多级表头被覆盖掉问题就出在没分清这两套机制。注意数据合并有一个隐含前提——待合并的列必须是有序的相同值一定要连续出现。如果同一个客户的订单被分散在不相邻的行里你的合并逻辑会把它们识别成多段合并出来是一堆碎格子。所以导出前务必对数据按合并维度排序这一步做在数据库ORDER BY里最稳。2.3 合并策略的执行时机与选择EasyExcel 里做合并主要有几个入口选错了要么合并不上要么性能差。第一种是AbstractMergeStrategy这是官方最推荐的扩展点它的merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex)方法会在数据行写入时对每个单元格回调一次relativeRowIndex就是数据相对行号从 0 开始不含表头。你可以在这里比较当前格和上一行同列值决定是否合并。第二种是OnceAbsoluteMergeStrategy它适合“位置固定”的合并比如固定把表头某几行某几列合并构造时直接传四个参数起始行、结束行、起始列、结束列。这种适合同一位置永远要合并的场景不适合按数据内容动态合并。第三种是完整的CellWriteHandler你可以在afterCellDispose里拿到单元格信息自己处理灵活度最高但代码也最多需要自己维护合并的起止状态。我的建议是表头自动合并靠注解按数据内容动态合并优先用AbstractMergeStrategy。它对数据行的回调时机刚刚好拿得到 sheet 和 cell逻辑也好写。这里有个执行时机的细节要留意合并回调是逐格触发的也就是说你没法在“所有数据写完”后统一处理只能在回调里即时判断。这带来一个典型问题——每一列的最后一段合并容易漏掉因为后面没有下一个格子来触发“值变化”的判断。解决思路是把数据总数传进策略当relativeRowIndex等于最后一行时主动给每一列做一次收尾结算。这一点我在第 3 节的代码里会具体实现。3. 完整实操从实体类到导出接口3.1 依赖与导出实体类定义先定义导出实体。假设我们要导出一份客户订单明细表头分三层客户信息客户名称、联系方式、订单信息订单编号、下单时间、商品明细商品名称、数量、金额。注意这里客户和订单的很多字段其实是一对多的结构导出时会被平铺成多行靠后续合并还原层级。Data public class OrderExportVO { ExcelProperty({客户信息, 客户名称}) private String customerName; ExcelProperty({客户信息, 联系方式}) private String contactPhone; ExcelProperty({订单信息, 订单编号}) private String orderNo; ExcelProperty({订单信息, 下单时间}) private String orderTime; ExcelProperty({商品明细, 商品名称}) private String productName; ExcelProperty({商品明细, 数量}) private Integer quantity; ExcelProperty({商品明细, 金额}) private BigDecimal amount; }这份实体导出来后表头会自动形成“客户信息 / 订单信息 / 商品明细”三个横向合并的父级区域父级下再各挂子标题。我们打算合并的列是“客户名称”和“订单编号”也就是第 0 列和第 2 列。注意这两列必须是有序的SQL 里要ORDER BY customer_name, order_no保证相同值连续出现否则合并逻辑会失效。实体类里我特意用了BigDecimal存金额避免用double导致精度问题导出时 EasyExcel 会按数值处理但如果你需要保留两位小数最好在数据层就格式化好或者在单元格样式里设置dataFormat。3.2 自定义合并策略按列合并相邻相同值这是全文的核心代码。我写一个继承自AbstractMergeStrategy的策略类构造时传入两个参数需要合并的列索引集合以及数据总条数用于最后一段收尾。import com.alibaba.excel.metadata.Head; import com.alibaba.excel.write.handler.AbstractMergeStrategy; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.util.CellRangeAddress; import java.util.*; public class ColumnMergeStrategy extends AbstractMergeStrategy { private final SetInteger mergeColumns; private final int totalDataSize; private final MapInteger, Integer startRowMap new HashMap(); private final DataFormatter dataFormatter new DataFormatter(); public ColumnMergeStrategy(SetInteger mergeColumns, int totalDataSize) { this.mergeColumns mergeColumns; this.totalDataSize totalDataSize; } Override protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) { if (relativeRowIndex null) { return; } int colIndex cell.getColumnIndex(); if (!mergeColumns.contains(colIndex)) { return; } int rowIndex cell.getRowIndex(); Integer start startRowMap.get(colIndex); if (start null) { startRowMap.put(colIndex, rowIndex); } else { String prevValue getCellStringValue(sheet.getRow(rowIndex - 1).getCell(colIndex)); String currentValue getCellStringValue(cell); if (!Objects.equals(prevValue, currentValue)) { if (rowIndex - start 1) { sheet.addMergedRegion(new CellRangeAddress(start, rowIndex - 1, colIndex, colIndex)); } startRowMap.put(colIndex, rowIndex); } } if (relativeRowIndex totalDataSize - 1) { int s startRowMap.getOrDefault(colIndex, rowIndex); if (rowIndex - s 1 1) { sheet.addMergedRegion(new CellRangeAddress(s, rowIndex, colIndex, colIndex)); } } } private String getCellStringValue(Cell cell) { if (cell null) { return ; } return dataFormatter.formatCellValue(cell); } }逻辑拆开看每一列的合并状态用一个startRowMap记录“当前这段合并从哪一行开始”。当某个单元格的值和上一行同列不同时说明上一段结束结算范围[start, rowIndex-1]如果长度大于 1 就调用addMergedRegion合并然后把这一行设为新一段的起点。最后当relativeRowIndex到达数据最后一行时对每一列做一次收尾结算把最后一段也合并上。这里有几个细节值得说。第一sheet.getRow(rowIndex - 1)拿的是上一行的物理行必须保证它不为 null正常数据连续写入不会有空行但保险起见可以在取值前做空判断。第二用DataFormatter转字符串来比较是为了兼容数字、日期等类型避免拿Cell对象直接equals出错——数字 1 和字符串 1 会判成不同用格式化器统一成字符串更稳。第三addMergedRegion区域不能重叠我们的逻辑天然保证每段不重叠所以安全。第四数据总条数这里我假设是单次全量写入如果是分页写入这个totalDataSize就得换一种收尾判断方式第 3.4 节会讲。3.3 表头样式、列宽与导出接口光有合并不够导出的报表还得好看。合并之后有个副作用必须先处理——单元格一旦被合并只有左上角那格保留样式其余格子的边框会消失看起来像被啃了一块。所以样式要在合并之前或统一设置好通常用HorizontalCellStyleStrategy设置表头和内容的整体样式。private HorizontalCellStyleStrategy buildStyleStrategy() { WriteCellStyle headStyle new WriteCellStyle(); headStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); headStyle.setVerticalAlignment(VerticalAlignment.CENTER); WriteFont headFont new WriteFont(); headFont.setFontHeightInPoints((short) 11); headFont.setBold(true); headStyle.setWriteFont(headFont); WriteCellStyle contentStyle new WriteCellStyle(); contentStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); contentStyle.setVerticalAlignment(VerticalAlignment.CENTER); contentStyle.setWrapped(true); setBorder(contentStyle); return new HorizontalCellStyleStrategy(headStyle, contentStyle); } private void setBorder(WriteCellStyle style) { style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN); }边框样式我单独抽了个方法因为四个方向都要设写一起容易漏。表头用灰色填充加粗居中内容居中且自动换行。列宽方面EasyExcel 内置了LongestMatchColumnWidthStyleStrategy能根据内容自动拉宽列省得手工一个个设.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())不过要提醒这个策略对纯中文的宽度估算在部分 POI 版本下不准确可能出现一列内容“挤在一起”或“宽得离谱”的情况。我的处理是给它加个上限或者干脆对关键列用sheet.setColumnWidth(colIndex, 20 * 256)手工定死。.xlsx里一列宽度的单位是字符宽乘 256 是换算成 POI 用的单位。导出接口就顺理成章了GetMapping(/export/order) public void exportOrder(HttpServletResponse response) throws IOException { ListOrderExportVO dataList orderService.listForExport(); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(客户订单明细, UTF-8).replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment;filename*utf-8 fileName .xlsx); SetInteger mergeColumns new HashSet(Arrays.asList(0, 2)); EasyExcel.write(response.getOutputStream(), OrderExportVO.class) .registerWriteHandler(buildStyleStrategy()) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .registerWriteHandler(new ColumnMergeStrategy(mergeColumns, dataList.size())) .sheet(订单明细) .doWrite(dataList); }注意这里ColumnMergeStrategy的totalDataSize用的是dataList.size()因为我们是一次性全量写入。文件名的中文必须用URLEncoder.encode处理而且要把编码后的替换成%20否则中文文件名在某些浏览器里会变成一堆乱码或者空格。3.4 大数据量分页导出与内存控制数据量一大上面那种一次性doWrite(dataList)就有问题了。虽然 EasyExcel 写内存比 POI 友好但如果你把十万条数据先list()全查出来塞进一个 List光这个 List 就够呛更别提导出过程中框架还要构建单元格。正确姿势是分批查库、分批写入用ExcelWriter手动控制。ExcelWriter excelWriter null; try { excelWriter EasyExcel.write(response.getOutputStream(), OrderExportVO.class) .registerWriteHandler(buildStyleStrategy()) .build(); WriteSheet writeSheet EasyExcel.writerSheet(订单明细).build(); int pageSize 5000; int pageNo 1; while (true) { ListOrderExportVO page orderService.pageForExport(pageNo, pageSize); if (CollectionUtils.isEmpty(page)) { break; } excelWriter.write(page, writeSheet); pageNo; } } finally { if (excelWriter ! null) { excelWriter.finish(); } }但分页写入和合并策略有一个天然矛盾跨页的相同值合并会断掉。比如第 1 页最后一行和第 2 页第一行都是“张三”的订单但它们被分成两次write合并策略在页边界处看不到连续性就会生成两个独立的合并段中间断成两截。解决思路有三种一是按“合并维度”来分页保证同一个客户的订单不会被拆到两页二是把页大小调大减少边界出现概率但不根治三是记录上一页最后一行的值在下一页第一行写入时用它做比较实现跨页续接——这需要在策略里缓存上一页的收尾状态代码复杂度会上升。我的实际选择是第一种分页查询时按客户或订单分组切分保证同一分组的行落在同一页。这需要在查询层做点功课比如先把分组的主键查出来再按组批量拉明细。虽然麻烦一点但合并结果最稳定也不会因为导出逻辑的复杂度失控。另外那个totalDataSize的收尾判断在分页下也要调整可以改成“每页写完时对当前页做一次收尾”代价是页边界可能多合并一段所以还是推荐按组切页。提示ExcelWriter用完必须finish()而且在finally里调用。如果中途抛异常没 finish响应流可能被写了一半前端下载到的是损坏文件。这个坑我在生产环境排查过一次现象就是“偶尔下载的 Excel 打不开”。4. 常见问题与排查技巧实录4.1 合并后边框和样式丢失这是最高频的反馈。改完合并逻辑测试一看合并的大格子里边框缺了一块。原因前面提过POI 合并单元格后只有左上角单元格的样式会保留并渲染其余被合并掉的格子的样式就“看不见”了尤其是边框因为它本是画在单元格四边上的。解决方式有两种。一种是让样式策略在合并之后依然生效——但HorizontalCellStyleStrategy是在写入单元格时设样式的合并在其后所以边框还是丢。更靠谱的做法是在自定义合并策略里合并完成后把合并区域左上角那个单元格的样式重新设置一遍特别是补上四边边框。可以在addMergedRegion之后拿到sheet.getRow(start).getCell(colIndex)给它设一个带边框的CellStyle。private void resetBorder(Sheet sheet, int startRow, int endRow, int colIndex) { CellStyle style sheet.getWorkbook().createCellStyle(); style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN); Cell topLeft sheet.getRow(startRow).getCell(colIndex); if (topLeft ! null) { topLeft.setCellStyle(style); } }要注意createCellStyle创建的样式最好复用别在循环里无限创建否则样式数量会爆炸Excel 打开时会报“样式过多”的警告。更优雅的方式是在合并策略里持有几个预建好的 CellStyle反复使用。另一种更省事的方案是干脆不用 EasyExcel 的合并回调导完后用 POI 打开文件重新画一遍边框但那样又回到了 POI 的内存问题得不偿失。4.2 表头层级错位、列宽异常表头错位通常有两类原因。一类是多级表头的父标题不一致——比如一处写“客户信息”另一处写“客户资料”框架就认为是两个不同的父级合并自然对不上还会出现该跨列的没跨。排查办法是盯着实体类注解把相同父级的字符串逐字对齐包括空格和标点。另一类是表头层级深度不一致。比如大部分字段是两级表头个别字段只写了一级EasyExcel 在拼接时会按最大深度补齐短的那些会在顶部留空看起来像表头少了一块、整体下沉。解决办法就是所有导出字段的表头层级保持统一要么全两级要么该补的父级补上。列宽异常的元凶通常是LongestMatchColumnWidthStyleStrategy对中文的估算。中文一个字占用约两个字符宽度但 POI 的估算模型偏西文容易出现中文列被算窄、内容“鼓”出来换行的情况。我的做法是给这个策略配合一个列宽上限同时给那些明显偏窄的列手工setColumnWidth。还有一个细节是自动换行setWrapped(true)开了之后如果列宽不够单元格会换行把行高撑高表头和内容对不齐所以列宽要么给足要么关掉换行。注意表头合并区域和列宽是两码事父标题虽然横跨多列但它的显示宽度是它所占各列宽度之和单列太窄会显得父标题“挤”。合并表头的报表列宽宁可宽一点。4.3 大量数据导出 OOM 与超时几万行以上最怕的就是OutOfMemoryError: Java heap space或者前端一直转圈等到网关超时。先厘清一个误区EasyExcel 写模式并不是完全不占内存它只是比 POI 全量构建要省但你一次性塞几十万行照样爆。所以第一步就是别全量list()改成分页write这一点在 3.4 节已经做了。第二步是控制 JVM 堆。如果导出是偶发的大任务可以在导出接口所在的服务上调大堆内存比如-Xmx1g到-Xmx2g但这不是根治手段只是给缓冲。第三步是排查有没有别的地方在“撑”内存最典型的是把EasyExcel.write(response.getOutputStream(), OrderExportVO.class)写成了EasyExcel.write(某个 ByteArrayOutputStream, ...)然后再把字节数组写到响应流——这样一来整个 Excel 的字节全都堆在内存里白瞎了 EasyExcel 的流式设计。正确做法是直接把response.getOutputStream()交给 EasyExcel。超时问题则往往和网关、反向代理的响应超时配置有关。导出几万行可能要几十秒如果网关设了 30 秒超时请求就被掐断用户看到“下载失败”。这类问题要么把导出做成异步任务先返回任务 ID前端轮询下载要么调大相关超时。涉及具体环境的超时参数我这里就不展开了原则是“链路每一跳都要确认超时阈值”。4.4 文件名乱码与下载失败文件名乱码几乎是每个做导出的人都会遇到的。核心是响应头Content-Disposition的编码规范。浏览器对中文文件名有filename和filename*两套规范前者用 ISO-8859-1后者用 RFC 5987 规定的 UTF-8 编码。稳妥写法就是前面代码里的response.setHeader(Content-Disposition, attachment;filename*utf-8 URLEncoder.encode(fileName, UTF-8).replaceAll(\\, %20) .xlsx);URLEncoder默认把空格编码成所以要把换回%20否则文件名里带空格就会显示成加号。下载失败还有一类隐蔽原因响应流已经被写过一部分此时再抛异常。比如文件写到一半某个单元格处理报错异常抛出去被全局异常处理器接住它又试图往同一个响应里写 JSON 错误信息结果响应体就变成“前半段 xlsx 二进制 后半段 JSON”前端下载下来打不开。规避办法是在导出接口里先把要用的数据、样式、策略都准备好确保进入写文件阶段后尽量不抛业务异常同时全局异常处理里对导出类接口做特判一旦响应已提交就不要再写东西。这个坑我踩过一次表现是“大部分时候正常个别数据触发异常时下载到坏文件”。把上面这些问题整理成一张速查表排查时对照着看会快很多现象可能原因排查方向与处理合并后边框消失合并只保留左上角样式在合并回调里重置左上角单元格边框样式表头该合并的没合并父标题字符串不一致逐字对齐注解里的相同父级名称表头整体下沉表头层级深度不统一统一各字段的层级补齐父级标题合并后出现碎格子待合并列未排序相同值不连续导出 SQL 增加按合并维度的 ORDER BY最后一段没合并逐格回调缺少收尾触发用数据总数判断最后一行主动收尾分页导出合并断开相同值被拆到两页按分组切页保证同组数据同页导出 OOM全量 list 后一次性写入改分页 write直接用响应流写入请求超时网关或代理响应超时过短改异步导出或调大超时阈值文件名乱码Content-Disposition 编码不对用 filename* 加 URLEncoder 编码5. 参数调优与可复用工具类设计5.1 合并性能与内存相关参数合并本身是有成本的每调用一次addMergedRegion都会在 sheet 上维护一个区域列表合并区域越多POI 在后续渲染和写出时的开销就越大。所以能减少合并次数就减少。最直接的一招是合理选择合并列只合并真正需要的列别把每一列都拿去合并。有些报表为了好看把明细里其实每条都不同的列也纳入合并结果是每次都在“新建一段”白白增加开销。第二个调优点是批量写入的页大小。页太小write调用次数多跨页合并断裂概率高页太大单页内存占用上升收尾判断也更容易吃力。我实测下来页大小设在 3000 到 5000 之间是个比较舒服的区间既不至于频繁跨页也不会一次性占用太多内存。具体值要结合单行数据的字段数量来调字段多、单元格多页就该小一点。第三个点是避免在合并策略里做重活。比如有人图省事在merge回调里每次去查一次数据库或者做复杂的正则这会让本来 O(n) 的合并退化成灾难。策略里只做“读上一行值、比较、合并”这三件事任何业务查询都放在数据准备阶段完成。还有DataFormatter建议在策略里只 new 一次然后复用别每个单元格都 new 一个虽然它本身不重但回调次数是行数乘以列数量级积少成多。5.2 把合并逻辑抽成通用工具类一个项目里往往不止一张报表要合并如果每张报表都复制一份合并策略后期改起来会很痛苦。我的做法是把“按列合并相邻相同值”抽成一个通用策略类只暴露两个配置入口合并列索引集合、数据总数或分页模式。前面写的ColumnMergeStrategy基本就是通用形态不同报表只是构造参数不同。再进一步还可以把整个导出流程封装成一个模板方法比如ExcelExportTemplate.export(response, fileName, sheetName, clazz, styleStrategy, mergeColumns, dataProvider)把响应头设置、writer 构建、分页循环、异常收尾都收进去。这样业务代码只剩“查数据、指定合并列、调用导出”三步新人接手也不会把响应头写错。这里要强调的是工具类里不要写死任何业务字段合并列用索引传入样式用策略对象传入保持足够的中立性才能真正复用。另外分享一下我做样式时的经验表头样式和内容样式各建一次然后缓存成静态常量不要每次导出都new WriteCellStyle()。POI 对工作簿内的样式数量是有上限的.xls约 4000.xlsx宽松很多但也有代价如果导出批量大或者单元格巨多反复创建样式会拖慢生成甚至触发样式超限的告警。样式对象一旦建好就复用既省内存又省时间。这些看起来是小事但在动辄几万行的导出任务里累积效应很明显。最后再补一个我实际调试用的小技巧合并是否正确别每次都启动整个项目去点导出看结果那样反馈太慢。可以写一个单元测试直接在本地FileOutputStream里导出一小批构造好的假数据输出到本地文件用 Excel 打开检查合并区域。假数据里特意造几组“连续相同”和“交叉不同”的值专门用来验证边界——比如一整列全是同一个值、一整列全不重复、以及“A A B B A”这种带回跳的序列能一次性把合并逻辑的漏洞暴露出来。调通之后再把数据源换成真实查询效率高很多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →