EasyExcel实战:如何用滑动窗口思想优化10万+数据合并单元格性能?

📅 发布时间:2026/7/11 20:30:15 👁️ 浏览次数:
EasyExcel实战:如何用滑动窗口思想优化10万+数据合并单元格性能?
EasyExcel性能跃迁用滑动窗口算法重构10万数据合并单元格的实战思考如果你在Java后端开发中处理过大规模数据导出特别是需要合并单元格的复杂报表大概率经历过那种“等待到怀疑人生”的煎熬。当数据量突破万级尤其是达到十万、百万级别时传统的逐行比对合并逻辑会迅速成为性能瓶颈内存占用飙升导出时间呈指数级增长。这不仅仅是工具使用的问题更是算法设计在特定场景下的失效。最近在重构一个B端系统的报表导出模块时我遇到了一个典型场景需要根据用户ID、产品版本等多层维度合并单元格导出超过10万条记录。最初的实现基于EasyExcel的CellWriteHandler在afterCellDispose中逐行与前一行比对简单直接但在数据量上去后一次导出耗时接近8分钟CPU和内存曲线令人心惊。经过几轮优化最终将导出时间压缩到5秒以内核心的突破点在于引入了滑动窗口这一算法思想彻底重构了合并逻辑。这不是简单的API调用技巧而是一次从“业务实现”到“算法优化”的思维跃迁。下面我将完整分享这次重构的思路、核心实现以及性能对比数据希望能为面临类似困境的开发者提供一个全新的解决方案。1. 问题诊断为什么传统合并策略会“卡死”在深入优化之前我们首先要理解瓶颈究竟在哪里。EasyExcel本身是基于Apache POI的封装其默认的合并单元格策略或者社区常见的自定义CellWriteHandler实现大多采用一种“即时比对”的模式。1.1 传统实现的核心逻辑与缺陷典型的合并策略会在每一行数据写入完成后触发afterCellDispose方法在这个方法中获取当前单元格的值。获取上一行同列单元格的值。如果两者相同则与之前的合并区域进行判断和扩展或创建新的合并区域。这种逻辑在代码上非常直观例如下面这段常见的代码片段Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, ListCellData? cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (isHead) return; int curRow cell.getRowIndex(); int curCol cell.getColumnIndex(); if (!needMerge(curCol)) return; // 关键性能瓶颈点频繁的Sheet操作和区域查询 Sheet sheet writeSheetHolder.getSheet(); Cell preCell sheet.getRow(curRow - 1).getCell(curCol); Object curData getCellValue(cell); Object preData getCellValue(preCell); if (curData.equals(preData)) { ListCellRangeAddress mergedRegions sheet.getMergedRegions(); // 1. 获取所有已合并区域 boolean isMerged false; for (int i 0; i mergedRegions.size(); i) { // 2. 遍历查找 CellRangeAddress range mergedRegions.get(i); if (range.isInRange(curRow - 1, curCol)) { // 3. 判断是否在区域内 sheet.removeMergedRegion(i); // 4. 移除旧区域 range.setLastRow(curRow); // 5. 扩展区域 sheet.addMergedRegion(range); // 6. 添加新区域 isMerged true; break; } } if (!isMerged) { sheet.addMergedRegion(new CellRangeAddress(curRow - 1, curRow, curCol, curCol)); // 7. 创建新区域 } } }这段代码在数据量小的时候没有问题但当数据量增大时其性能缺陷会暴露无遗O(n²)的遍历成本每次合并都需要遍历sheet.getMergedRegions()返回的列表。随着合并区域越来越多这个列表会不断增长每次遍历的成本线性增加。昂贵的Sheet操作removeMergedRegion和addMergedRegion是直接操作POI底层Sheet对象的方法频繁调用会引发大量的内部数组拷贝和索引重建。重复的单元格值获取对于同一列相邻行的值会被反复获取和比较。注意sheet.getMergedRegions()返回的是一个List每次调用并非简单的属性读取POI内部可能涉及防御性拷贝在数据量大时这本身就会成为开销。1.2 性能瓶颈量化分析为了更直观地感受问题我对一个包含57509行、每行都需要进行多列合并的数据集进行了压测。使用上述传统策略结果如下表所示操作阶段耗时 (ms)说明数据准备与写入~1200将数据转换为List并调用doWrite不包含合并逻辑合并单元格处理~488,000核心瓶颈占总耗时99%以上总耗时~489,200约8分9秒可以看到合并逻辑的处理耗时占据了绝对主导。其时间复杂度可以近似看作O(m * n)其中m是数据行数n是已存在的合并区域数。在后期n会变得非常大导致处理单行的时间急剧上升。问题的本质在于这种“当下行与上一行即时比对”的策略是无状态的、短视的。它只关心当前行和前一行的关系无法对连续的、待合并的行进行批量、高效的处理。而滑动窗口算法正是解决这种“连续相同序列”问题的利器。2. 算法破局将滑动窗口思想引入单元格合并滑动窗口是处理数组/列表中连续子序列问题的经典思想。其核心是维护一个窗口通过移动窗口的左右边界在不重复遍历的情况下高效处理数据。在我们的场景中“需要合并的连续行”就是一个滑动窗口。例如对于某一列从第3行到第10行的值都是“用户A”那么这8行就构成了一个待合并的窗口。2.1 滑动窗口合并算法的设计思路我们不再在每行写入时进行“回溯式”合并而是改为“前瞻式”的批量合并状态记录在开始处理数据行之前初始化一个“窗口”。我们需要记录两个核心状态lastCells[]: 一个数组记录上一行各个需要合并的列的值。用于判断连续性是否中断。mergeCountArray[]: 一个数组记录当前窗口在各个需要合并的列上已经连续出现了多少行相同的值。窗口滑动与判定处理新的一行时遍历需要合并的列。如果当前行的值等于lastCells中对应列的值说明连续性保持mergeCountArray对应位置计数1此时不进行合并操作。如果当前行的值不等于lastCells中对应列的值说明连续性中断。此时mergeCountArray中记录的计数值就代表了上一个“窗口”的大小。触发合并操作将上一个窗口对应的行范围进行合并然后将mergeCountArray中该列的计数重置为1新窗口的开始。收尾处理所有行处理完毕后对mergeCountArray中所有计数大于1的列进行最终的合并操作以处理延伸到数据末尾的窗口。这种设计将合并操作从每行可能多次减少到每个连续区间仅一次。算法的时间复杂度降低为O(m)其中m是数据行数与合并区域的数量n无关。2.2 关键数据结构与流程为了清晰说明我们定义几个关键变量和流程public class OptimizedMergeStrategy implements RowWriteHandler { // 需要合并的列索引集合 private SetInteger mergeColumnIndexSet; // 记录各列连续相同值的行数窗口大小 private int[] mergeCountArray; // 缓存上一行各列的值用于比较 private Object[] lastRowCellValues; // 总数据行数用于最后一行判断 private int totalDataRows; // 表头行数 private int headRowCount 1; Override public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) { int currentRowIndex row.getRowNum(); // 当前物理行号 // 跳过表头 if (isHead) { headRowCount; return; } // 计算当前是第几条数据行从0开始 int dataRowIndex currentRowIndex - headRowCount; // 遍历所有需要合并的列 for (Integer colIndex : mergeColumnIndexSet) { Object currentValue getCellValue(row.getCell(colIndex)); Object lastValue lastRowCellValues[colIndex]; if (dataRowIndex 0) { // 第一条数据行初始化窗口计数为1 mergeCountArray[colIndex] 1; lastRowCellValues[colIndex] currentValue; continue; } if (Objects.equals(currentValue, lastValue)) { // 值相同窗口扩大计数1 mergeCountArray[colIndex]; } else { // 值不同窗口中断。如果之前窗口计数1则合并上一个窗口 if (mergeCountArray[colIndex] 1) { int startRow currentRowIndex - mergeCountArray[colIndex]; int endRow currentRowIndex - 1; doMerge(writeSheetHolder, startRow, endRow, colIndex); } // 重置窗口开始新的连续区间 mergeCountArray[colIndex] 1; } // 更新上一行缓存 lastRowCellValues[colIndex] currentValue; } // 最后一行数据处理完毕后强制合并所有未完成的窗口 if (dataRowIndex totalDataRows - 1) { for (Integer colIndex : mergeColumnIndexSet) { if (mergeCountArray[colIndex] 1) { int startRow currentRowIndex - mergeCountArray[colIndex] 1; int endRow currentRowIndex; doMerge(writeSheetHolder, startRow, endRow, colIndex); } } } } private void doMerge(WriteSheetHolder writeSheetHolder, int firstRow, int lastRow, int col) { // 实际的合并操作 Sheet sheet writeSheetHolder.getSheet(); CellRangeAddress range new CellRangeAddress(firstRow, lastRow, col, col); sheet.addMergedRegion(range); } }这个流程的核心优势在于doMerge方法只在连续性中断时和数据处理完毕时被调用调用次数等于该列中独立值的个数而不是数据行数。对于10万行数据如果“用户ID”列只有1000个不同的用户那么合并操作只发生1000次左右而不是10万次。3. 实战实现高性能CellMergeStrategy完整代码理论需要落地。下面是我在项目中重构后的CellMergeStrategy核心代码。它实现了RowWriteHandler接口在行级别进行操作并严格遵循了滑动窗口的思想。import com.alibaba.excel.write.handler.RowWriteHandler; import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; import com.alibaba.excel.write.metadata.holder.WriteTableHolder; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.util.CellRangeAddress; import java.util.*; Slf4j public class CellMergeStrategy implements RowWriteHandler { private final SetInteger mergeColumnIndexSet; private final int[] mergeCountArray; private final Object[] lastRowCellValues; private final int totalDataRows; private int headRowCount 0; private long mergeOperationCount 0; // 用于监控合并操作次数 /** * 构造函数 * param totalDataRows 总数据行数不包括表头 * param mergeColumnIndexArray 需要合并的列索引数组从0开始 */ public CellMergeStrategy(int totalDataRows, Integer[] mergeColumnIndexArray) { this.totalDataRows totalDataRows; // 对列索引排序并去重方便处理 Arrays.sort(mergeColumnIndexArray); this.mergeColumnIndexSet new LinkedHashSet(Arrays.asList(mergeColumnIndexArray)); int maxColumnIndex mergeColumnIndexArray[mergeColumnIndexArray.length - 1]; // 初始化数组长度设为最大列索引1避免后续判断越界 this.mergeCountArray new int[maxColumnIndex 1]; this.lastRowCellValues new Object[maxColumnIndex 1]; // 初始化窗口计数为1假设第一行数据就是一个窗口的开始 for (Integer idx : mergeColumnIndexSet) { mergeCountArray[idx] 1; } log.debug(滑动窗口合并策略初始化完成需合并列{}总数据行数{}, mergeColumnIndexSet, totalDataRows); } Override public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) { int currentPhysicalRow row.getRowNum(); if (isHead) { headRowCount; return; } // 计算当前数据行索引从0开始 int currentDataRowIndex currentPhysicalRow - headRowCount; // 如果是第一行数据只初始化缓存不进行合并判断 if (currentDataRowIndex 0) { for (Integer colIndex : mergeColumnIndexSet) { lastRowCellValues[colIndex] getCellValue(row.getCell(colIndex)); } return; } // 滑动窗口核心逻辑 for (Integer colIndex : mergeColumnIndexSet) { Cell currentCell row.getCell(colIndex); Object currentVal getCellValue(currentCell); Object lastVal lastRowCellValues[colIndex]; if (Objects.equals(currentVal, lastVal)) { // 连续相同窗口扩大 mergeCountArray[colIndex]; } else { // 出现不同值触发合并上一个窗口 if (mergeCountArray[colIndex] 1) { int windowStartRow currentPhysicalRow - mergeCountArray[colIndex]; int windowEndRow currentPhysicalRow - 1; performMerge(writeSheetHolder.getSheet(), windowStartRow, windowEndRow, colIndex); mergeOperationCount; } // 重置窗口新值作为窗口起点 mergeCountArray[colIndex] 1; lastRowCellValues[colIndex] currentVal; } } // 处理最后一行所有未闭合的窗口都需要合并 if (currentDataRowIndex totalDataRows - 1) { for (Integer colIndex : mergeColumnIndexSet) { if (mergeCountArray[colIndex] 1) { int windowStartRow currentPhysicalRow - mergeCountArray[colIndex] 1; int windowEndRow currentPhysicalRow; performMerge(writeSheetHolder.getSheet(), windowStartRow, windowEndRow, colIndex); mergeOperationCount; } } log.info(合并操作完成。总合并操作次数{}远小于总行数{}, mergeOperationCount, totalDataRows); } } /** * 执行实际的单元格合并 */ private void performMerge(Sheet sheet, int firstRow, int lastRow, int col) { if (firstRow 0 || lastRow firstRow) { return; } try { CellRangeAddress range new CellRangeAddress(firstRow, lastRow, col, col); sheet.addMergedRegion(range); // 注意此处使用addMergedRegion而非addMergedRegionUnsafe。 // 在明确线程安全由外部控制的情况下可使用Unsafe版本获得微秒级性能提升 // 但需自行处理并发问题。对于常规导出此方法已足够高效。 } catch (Exception e) { log.warn(合并单元格失败范围[{}:{}], 列{} 错误{}, firstRow, lastRow, col, e.getMessage()); } } /** * 安全地获取单元格值 */ private Object getCellValue(Cell cell) { if (cell null) { return null; } CellType cellType cell.getCellType(); switch (cellType) { case STRING: return cell.getStringCellValue(); case NUMERIC: return cell.getNumericCellValue(); case BOOLEAN: return cell.getBooleanCellValue(); case FORMULA: return cell.getCellFormula(); case BLANK: return ; default: return null; } } }使用方式非常简单在构建ExcelWriter时注册即可// 假设有10万条数据 ListYourDataDTO dataList fetchData(); // 10万条 EasyExcel.write(outputStream, YourDataDTO.class) .sheet(Sheet1) // 注册优化后的合并策略指定需要合并的列索引例如第012列 .registerWriteHandler(new CellMergeStrategy(dataList.size(), new Integer[]{0, 1, 2})) .doWrite(dataList);3.1 处理多层嵌套合并的进阶策略上述策略解决了单层连续合并的问题。但在实际业务中我们常遇到多层嵌套合并比如先按“省份”合并再按“城市”合并且城市合并不能跨省份。这要求我们的滑动窗口需要具备“上下文感知”能力。解决思路是引入一个合并标记位。当上一级列如“省份”的值发生变化时强制中断所有下级列如“城市”的合并窗口即使下级列的值看起来还是连续的。我们可以在CellMergeStrategy的基础上进行增强增加一个parentColumnIndex参数并在窗口滑动逻辑中加入对父列值变化的检查// 在遍历处理每个需合并的列时加入如下判断 for (Integer colIndex : mergeColumnIndexSet) { // ... 获取当前值 currentVal, 上一行值 lastVal ... // 关键增强检查是否因为父列变化而必须中断合并 boolean parentColumnChanged false; if (parentColumnIndex ! null parentColumnIndex 0) { Object currentParentVal getCellValue(row.getCell(parentColumnIndex)); Object lastParentVal lastRowCellValues[parentColumnIndex]; parentColumnChanged !Objects.equals(currentParentVal, lastParentVal); } if (!parentColumnChanged Objects.equals(currentVal, lastVal)) { mergeCountArray[colIndex]; // 连续性保持 } else { // 父列变化或本列值变化都触发合并 if (mergeCountArray[colIndex] 1) { // ... 执行合并 ... } mergeCountArray[colIndex] 1; // 重置窗口 lastRowCellValues[colIndex] currentVal; } }通过这种方式我们可以构建一个支持多级、带依赖关系的合并策略确保合并的层级结构正确无误。4. 性能对比与压测数据理论很美好但效果需要用数据说话。我在同一台开发机器MacBook Pro, 2.6 GHz 六核Intel Core i7, 16GB内存上对同一份57509行的数据集进行了压测对比。测试环境与数据数据量57,509行15列。合并需求第0、1、2列需要根据值相同进行行合并。模拟真实场景大部分行在这些列上都是连续的。JVM参数-Xms512m -Xmx1024m测试方法各运行10次取平均耗时并监控GC情况。策略平均总耗时 (ms)合并逻辑耗时 (ms)GC次数 (Minor/Major)峰值内存 (MB)合并操作调用次数传统逐行比对策略489,867~488,50045 / 8~650~172,000滑动窗口优化策略5,511~4,3003 / 0~280~1,850结果分析性能提升两个数量级总耗时从近8分钟降至约5.5秒提升近100倍。这完全符合算法复杂度从O(m*n)到O(m)的理论预期。内存消耗大幅降低峰值内存占用从650MB降至280MB。这是因为滑动窗口策略避免了维护巨大的ListCellRangeAddress并进行频繁的遍历和修改减少了大量临时对象和POI内部结构的开销。GC压力显著减轻Full GC次数从8次降为0次Minor GC次数也大幅减少。更少的内存分配和更简单的对象生命周期对JVM更加友好。操作次数锐减合并操作的调用次数从约17万次接近3倍行数因为有多列降至不到2000次这直接印证了“批量处理”的效率。提示实际提升倍数取决于数据中连续相同值的“段数”。如果数据完全随机没有连续相同值那么两种策略的合并操作次数会接近。但现实中的业务数据如按时间、按用户、按分类排序后导出通常具有很好的局部连续性这正是滑动窗口发挥威力的场景。5. 避坑指南与最佳实践在将滑动窗口策略应用到生产环境时有几个关键点需要特别注意5.1 数据排序是前提滑动窗口算法依赖数据的有序性。你必须确保传入EasyExcel.write的ListT dataList是按照需要合并的列进行排序的。例如你需要按“用户ID”合并那么dataList必须按照“用户ID”字段排序。如果数据是乱序的合并结果将不可预测。// 在准备数据后务必排序 dataList.sort(Comparator.comparing(YourDataDTO::getUserId) .thenComparing(YourDataDTO::getProductVersion));5.2 处理表头行数我们的策略中使用了row.getRowNum()来获取物理行号。EasyExcel在写入时表头行也会被计算在内。因此在计算数据行索引和窗口起始行时必须准确减去表头行数headRowCount。我们的实现中通过isHead标志位来累加headRowCount这是一种可靠的方法。5.3 关于addMergedRegion与addMergedRegionUnsafe在POI中Sheet.addMergedRegion(CellRangeAddress region)是线程安全的方法它内部会进行一些检查和同步操作。而addMergedRegionUnsafe是其非线程安全版本性能稍好。在我们的场景中导出任务通常在单个线程内完成不存在并发写入同一个Sheet的情况。因此可以考虑使用addMergedRegionUnsafe来获取极致的性能。但使用时必须确保没有其他线程同时操作这个Sheet。合并的区域不会重叠我们的算法保证了这一点。修改performMerge方法如下private void performMerge(Sheet sheet, int firstRow, int lastRow, int col) { if (firstRow 0 || lastRow firstRow) { return; } try { CellRangeAddress range new CellRangeAddress(firstRow, lastRow, col, col); // 使用高性能的Unsafe方法在单线程导出场景下是安全的 sheet.addMergedRegionUnsafe(range); } catch (Exception e) { log.error(合并单元格失败, e); // 降级处理使用安全方法 sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, col, col)); } }5.4 监控与调试为了便于线上问题排查建议在策略中加入简单的监控日志但要注意日志级别避免在高压下产生IO瓶颈。// 在构造函数或关键方法中 if (log.isDebugEnabled()) { log.debug(优化合并策略初始化数据行数{} 合并列{}, totalDataRows, mergeColumnIndexSet); } // 在最终完成时记录总结性信息 log.info(导出完成总计触发合并操作 {} 次处理数据行 {} 行。, mergeOperationCount, totalDataRows);5.5 与EasyExcel其他组件的兼容性该策略继承自RowWriteHandler与EasyExcel的其他WriteHandler如样式处理器、宽度处理器是兼容的执行顺序由注册顺序决定。通常合并处理器应该在样式处理器之后注册以确保合并后的单元格能正确应用样式。EasyExcel.write(outputStream, YourDataDTO.class) .registerWriteHandler(new CustomCellStyleHandler()) // 先样式 .registerWriteHandler(new CellMergeStrategy(data.size(), new Integer[]{0, 1})) // 后合并 .sheet(报表) .doWrite(data);这次从传统逐行比对的泥沼中跳脱出来用滑动窗口算法重构EasyExcel合并逻辑的经历让我深刻体会到在面对海量数据处理时选择正确的算法比优化代码细节更重要。当你的数据导出从几分钟优化到几秒钟那种体验的提升对用户来说是质的飞跃。滑动窗口的思想并不复杂但将其巧妙地应用于解决POI合并单元格的性能痛点却产生了意想不到的巨大效果。如果你的系统也正受困于大数据量Excel导出的性能问题不妨从这个角度入手或许会有惊喜。