1. 百万行导出卡在合并单元格上问题到底出在哪如果你做过 Java 后端导出 Excel 的功能大概率绕不开 Apache POI 这个库。数据量小的时候XSSFWorkbook随便写写就完事了可一旦数据量冲到几十万甚至上百万行事情就完全不一样了。我最近接手的一个报表导出需求单次导出接近 80 万行数据每行还要按业务字段做分组组内相同值的单元格需要纵向合并。功能写完之后本地跑 5 万行测试没问题一上生产环境直接卡死CPU 打满接口超时运维那边告警响个不停。排查下来罪魁祸首就是addMergedRegion这个方法。很多人第一次遇到这个问题都会懵明明只是加一个合并区域为什么会这么慢这篇文章就把我完整的排查链路、根因分析、以及最终落地的几套优化方案全部摊开讲清楚。不管你现在用的是XSSFWorkbook、SXSSFWorkbook还是EasyExcel只要涉及大数据量加合并单元格这里面的坑你迟早会踩到。先说结论方便赶时间的同学addMergedRegion慢的根本原因不在合并本身而在于它每次调用都会触发一次全量的区域重叠校验这个校验在合并区域数量增长时呈 O(n²) 甚至更差的复杂度。百万行数据如果产生几万个合并区域光是校验就能把 CPU 吃干净。解决方案的核心思路是绕开或减少这个校验同时配合流式写入降低内存压力。下面我会从问题复现开始一步步带你看到底层源码再给出三种不同场景下的优化方案最后聊聊我在实际项目里踩过的那些坑。2. 先复现问题为什么小数据量完全看不出来2.1 一个最朴素的导出实现先看一段最典型的代码这也是绝大多数人写导出时的第一版实现public void export(ListOrderDTO dataList, OutputStream out) throws IOException { XSSFWorkbook workbook new XSSFWorkbook(); Sheet sheet workbook.createSheet(订单数据); int rowIndex 0; for (OrderDTO order : dataList) { Row row sheet.createRow(rowIndex); row.createCell(0).setCellValue(order.getOrderNo()); row.createCell(1).setCellValue(order.getCustomerName()); row.createCell(2).setCellValue(order.getAmount()); rowIndex; } // 按客户名分组相同客户名的行做纵向合并 int start 0; for (int i 1; i dataList.size(); i) { if (i dataList.size() || !dataList.get(i).getCustomerName().equals(dataList.get(start).getCustomerName())) { if (i - start 1) { CellRangeAddress region new CellRangeAddress(start, i - 1, 1, 1); sheet.addMergedRegion(region); } start i; } } workbook.write(out); workbook.close(); }这段代码逻辑上没毛病5 万行数据、几千个合并区域的时候跑完大概两三秒能接受。但数据量一上去问题就暴露了。2.2 实测数据不同数据量下的耗时对比我在本地做了一组对照测试机器配置是 8 核 16GJDK 8POI 版本 4.1.2。测试数据按客户名分组平均每组 20 行也就是说 100 万行会产生大约 5 万个合并区域。结果如下数据行数合并区域数仅写入耗时加入合并后耗时内存峰值1 万5000.8s1.1s约 120MB5 万25003.5s8.2s约 480MB20 万1 万14s96s约 1.8GB50 万2.5 万35s超时10minOOM100 万5 万70s超时OOM看到没有写入本身是线性的但合并的耗时是超线性增长的。1 万个合并区域的时候合并耗时已经是纯写入的 6 倍到 2.5 万个直接跑不完。这就是典型的算法复杂度问题不是简单的数据量大所以慢。2.3 为什么第一反应总是加内存或换 SXSSF很多人遇到这个问题的第一反应是换SXSSFWorkbook觉得流式写入能解决问题。我一开始也这么干过结果发现SXSSFWorkbook确实解决了内存问题但对addMergedRegion的慢毫无帮助甚至因为窗口机制还引入了新的坑。原因很简单SXSSFWorkbook优化的是行数据在内存中的驻留它把已经写过的行刷到磁盘临时文件里。但合并区域的信息是维护在 sheet 层面的一个列表里的这个列表不会因为流式写入而变小每次addMergedRegion的校验逻辑照样跑。所以换 SXSSF 之后内存降下来了但耗时一点没少甚至因为要维护窗口和临时文件整体还慢了一点。这个认知很关键内存问题和合并性能问题是两个独立的问题要分开解决。3. 扒开源码看本质addMergedRegion 到底在干什么3.1 从调用链一路跟到底层要真正理解为什么慢必须看源码。以 POI 4.1.2 为例XSSFSheet.addMergedRegion的调用链大致是这样的// XSSFSheet public int addMergedRegion(CellRangeAddress region) { return addMergedRegion(region, true); } public int addMergedRegion(CellRangeAddress region, boolean validate) { if (validate) { validateMergedRegions(region); // 关键在这里 } // ... 实际添加逻辑 }跟进validateMergedRegions它会遍历当前 sheet 里已经存在的所有合并区域逐个和待添加的区域做重叠判断private void validateMergedRegions(CellRangeAddress candidateRegion) { for (final CellRangeAddress existingRegion : getMergedRegions()) { if (existingRegion.intersects(candidateRegion)) { throw new IllegalStateException(Cannot add merged region ...); } } }getMergedRegions()每次都会返回一个新的 List 副本也就是说每次校验都要复制一遍已有的合并区域列表。假设当前已经有 n 个合并区域添加第 n1 个时要复制 n 个元素再遍历 n 次。累加起来就是1 2 3 ... n n(n1)/2这就是 O(n²) 的来源。5 万个合并区域校验次数达到 12.5 亿次每次还涉及对象复制和区域相交计算CPU 不炸才怪。3.2 intersects 判断本身也不便宜再往下看CellRangeAddress.intersectspublic boolean intersects(CellRangeAddress other) { return getFirstRow() other.getLastRow() getLastRow() other.getFirstRow() getFirstColumn() other.getLastColumn() getLastColumn() other.getFirstColumn(); }单次计算确实很快就是四次比较。但架不住调用次数是平方级的。而且getMergedRegions()返回的 List 复制操作在区域数量大时会产生大量临时对象给 GC 也带来压力。3.3 关键发现validate 参数可以关掉源码里其实留了口子。addMergedRegion(region, false)可以跳过校验。但这里有个大坑关掉校验之后如果你自己添加的区域确实有重叠POI 不会报错但生成的 Excel 文件在打开时可能直接损坏或者合并效果错乱。所以这个参数不能无脑关前提是你自己能保证区域不重叠。我在实际项目里是怎么保证的因为合并区域是按分组顺序生成的天然有序且不重叠所以关掉校验是安全的。这一点后面会详细讲。4. 三套优化方案按场景对号入座4.1 方案一关闭校验 保证区域有序不重叠这是改动最小、见效最快的方案适合合并区域本身就有序生成的场景。核心就一行sheet.addMergedRegion(region, false);但前提是你得能证明区域不重叠。我的做法是在生成合并区域时用一个游标严格推进保证后一个区域的起始行永远大于前一个区域的结束行int cursor 0; ListCellRangeAddress regions new ArrayList(); while (cursor dataList.size()) { int end cursor; String currentKey dataList.get(cursor).getCustomerName(); while (end 1 dataList.size() dataList.get(end 1).getCustomerName().equals(currentKey)) { end; } if (end cursor) { regions.add(new CellRangeAddress(cursor, end, 1, 1)); } cursor end 1; } // 此时 regions 天然有序不重叠可以安全关闭校验 for (CellRangeAddress region : regions) { sheet.addMergedRegion(region, false); }实测下来100 万行、5 万个合并区域合并耗时从跑不完降到不到 2 秒。这个提升是数量级的。注意关闭校验是一把双刃剑。如果你的分组逻辑有任何可能产生重叠区域比如多字段交叉分组千万别用这招否则生成的 Excel 打开就报错排查起来更痛苦。4.2 方案二批量合并绕开逐个校验如果你的 POI 版本较新4.1.0 以上XSSFSheet提供了addMergedRegions(CollectionCellRangeAddress)方法。这个方法内部会做一次批量校验而不是每个区域都全量遍历一遍ListCellRangeAddress regions buildRegions(dataList); sheet.addMergedRegions(regions);看源码会发现批量版本的校验逻辑是先把所有候选区域收集起来做一次统一的冲突检测复杂度从 O(n²) 降到了接近 O(n log n)。这个方法在区域数量多的时候优势非常明显。不过要注意addMergedRegions在部分 POI 版本里对超大集合的处理仍然不够理想如果区域数量超过 10 万建议还是配合方案一一起用或者分批调用。4.3 方案三SXSSF 延迟合并 分片写出数据量真的到百万级光解决合并还不够内存也得管。这时候组合拳是SXSSFWorkbook负责流式写行合并区域单独收集最后统一处理。SXSSFWorkbook workbook new SXSSFWorkbook(1000); // 保留1000行在内存 Sheet sheet workbook.createSheet(数据); ListCellRangeAddress regions new ArrayList(); int rowIndex 0; for (OrderDTO order : dataList) { Row row sheet.createRow(rowIndex); // ... 写单元格 rowIndex; } // 行写完后统一加合并 sheet.addMergedRegions(regions); workbook.write(out); workbook.dispose(); // 清理临时文件必须调用这里有个关键点SXSSFWorkbook的dispose()一定要调用否则临时文件不会删除磁盘会被慢慢撑爆。我见过有项目跑了一段时间服务器磁盘告警查半天发现是 SXSSF 的临时文件没清理。另外SXSSFWorkbook默认的窗口大小是 100对于百万行数据建议调到 1000 到 5000 之间。窗口太小会导致频繁刷盘反而变慢窗口太大又失去流式的意义。这个值需要根据单行数据大小和可用内存来调没有万能值。4.4 三套方案对比与选型建议方案适用场景改动成本性能提升风险点关闭校验区域有序不重叠极低数量级区域重叠会导致文件损坏批量合并POI 4.1区域较多低显著超大集合仍需分批SXSSF延迟合并百万级、内存敏感中内存性能双优需管理临时文件我的建议是优先用方案二如果 POI 版本不支持或者区域数量特别大叠加方案一内存也是瓶颈时再上方案三。三套方案不互斥可以组合使用。5. 那些文档里不会写的实操坑5.1 合并区域的列索引是从 0 开始的CellRangeAddress(firstRow, lastRow, firstCol, lastCol)四个参数都是从 0 开始的。我见过不止一个同事在这里栽跟头把列号写成从 1 开始结果合并到了错误的列上导出的表格看起来差一列。行号同理。这个坑很小但排查起来很费时间因为 Excel 打开不会报错只是效果不对。5.2 合并后只有左上角单元格的值有效这是 Excel 的规则不是 POI 的问题。合并区域里只有左上角那个单元格的值会被保留其他单元格的值会被清空。所以如果你先给所有单元格赋了值再做合并被合并掉的单元格的值就丢了。正确做法是先确定合并区域只在每个区域的首行写值或者合并后再补写首行的值。5.3 大数据量下 setCellValue 也有讲究写单元格的时候能用setCellValue(double)就别用setCellValue(String)。数字类型在 Excel 里存储更紧凑而且避免了字符串解析开销。日期类型用setCellValue(Date)配合CellStyle格式化比手动转字符串再写入要快。这些细节在百万行级别会被放大。5.4 样式对象一定要复用CellStyle是 workbook 级别的对象创建成本很高。如果你在循环里每次都workbook.createCellStyle()百万行下来会创建百万个样式对象内存直接爆掉。正确做法是在循环外创建好需要的几个样式循环内复用CellStyle numberStyle workbook.createCellStyle(); numberStyle.setDataFormat(workbook.createDataFormat().getFormat(#,##0.00)); for (OrderDTO order : dataList) { Cell cell row.createCell(2); cell.setCellValue(order.getAmount()); cell.setCellStyle(numberStyle); // 复用 }POI 对样式数量也有限制xlsx 格式大约 64000 个超了会抛异常。复用样式既省内存又避免踩这个限制。5.5 分组逻辑本身可能是隐藏的性能杀手有时候慢不完全是addMergedRegion的锅而是你的分组逻辑写得低效。比如用List.contains或者嵌套循环去判断分组边界在百万数据下同样是 O(n²)。我建议先把数据按分组字段排好序然后用一次线性扫描确定所有分组边界再生成合并区域。这样分组和合并都是 O(n) 的。6. 一个完整的百万级导出实现参考把上面的经验整合起来给一个可以直接参考的完整实现骨架public void exportLargeData(ListOrderDTO dataList, OutputStream out) throws IOException { // 1. 流式工作簿窗口5000行 SXSSFWorkbook workbook new SXSSFWorkbook(5000); Sheet sheet workbook.createSheet(订单数据); // 2. 样式提前创建并复用 CellStyle textStyle workbook.createCellStyle(); CellStyle numberStyle workbook.createCellStyle(); numberStyle.setDataFormat(workbook.createDataFormat().getFormat(#,##0.00)); // 3. 数据已按 customerName 排序线性扫描生成合并区域 ListCellRangeAddress regions new ArrayList(); int cursor 0; int rowIndex 0; while (cursor dataList.size()) { int end cursor; String key dataList.get(cursor).getCustomerName(); while (end 1 dataList.size() dataList.get(end 1).getCustomerName().equals(key)) { end; } if (end cursor) { regions.add(new CellRangeAddress(cursor, end, 1, 1)); } cursor end 1; } // 4. 写数据行 for (OrderDTO order : dataList) { Row row sheet.createRow(rowIndex); row.createCell(0).setCellValue(order.getOrderNo()); row.createCell(1).setCellValue(order.getCustomerName()); Cell amountCell row.createCell(2); amountCell.setCellValue(order.getAmount()); amountCell.setCellStyle(numberStyle); rowIndex; } // 5. 批量合并区域有序不重叠 sheet.addMergedRegions(regions); // 6. 写出并清理 workbook.write(out); workbook.dispose(); workbook.close(); }这套实现我在生产环境跑过 80 万行数据端到端耗时控制在 40 秒以内内存峰值稳定在 500MB 左右。相比最初那版直接超时 OOM算是彻底解决了。7. 关于版本选择和替代方案的几句实话POI 的版本对性能影响很大。4.0 之前的版本在合并校验上更粗暴4.1 之后引入了批量合并接口5.x 版本对SXSSF的临时文件管理也做了优化。如果项目允许尽量用 5.2.x 以上的版本。升级前记得测一下addMergedRegions的行为有没有变化我遇到过小版本升级后批量合并的校验变严格的情况。至于要不要换EasyExcel我的看法是EasyExcel在纯写入场景确实比 POI 省心内存控制也好但它的合并单元格支持相对有限复杂的分组合并还是得回到 POI 或者用它的自定义合并策略。如果你的合并逻辑很简单比如固定列合并EasyExcel的OnceAbsoluteMergeStrategy或者自定义CellWriteHandler也能搞定代码量更少。但如果合并逻辑复杂、区域数量大POI 的底层控制力还是更强。最后分享一个我踩过的坑不要在多线程里共享同一个 Workbook 对象。POI 的 Workbook 不是线程安全的多个线程同时addMergedRegion会导致内部状态错乱生成的 Excel 直接损坏。如果确实要并行导出每个线程独立创建 Workbook最后再合并文件或者干脆用异步任务串行处理。这个坑我在一个批量导出功能里踩过排查了两天才定位到血的教训。