首页 / 资讯中心 / 文章详情

多线程并行导出Excel:SXSSFWorkbook分Sheet写入方案与踩坑总结

多线程并行导出Excel:SXSSFWorkbook分Sheet写入方案与踩坑总结 ★ FEATURED ARTICLE
上一篇写单线程版本的 POI 分 sheet 导出时我说过要单独写一篇多线程方案。后台不少朋友催更也有人直接问我SXSSFWorkbook 到底能不能让多个线程各写各的 sheet这次我就把一套我在导出任务里实际验证过的方案完整拆开讲包括线程模型、核心代码、调参思路还有几个我踩过之后念念不忘的坑。如果你现在正处理几十万行、多个业务分组的报表导出这一篇应当能帮你少走不少弯路。先给结论POI 的 SXSSFWorkbook 本身不是线程安全的但我们可以通过“预创建 sheet 子线程独占 sheet 主线程只做汇合”的方式实现多线程并行写多个 sheet。前提是收干净所有共享的可变状态尤其是样式表。1. 先说痛点单线程导出到底慢在哪很多人一看到“导 Excel 慢”第一反应是 POI 写入慢于是想换框架、换工具类。我在实际排查中发现大部分情况下瓶颈根本不在 POI 写入上而是卡在这三处第一数据库查询。一个 sheet 对应几十万行数据如果用单线程从头查到尾查询时间占了整个导出耗时的大头。尤其是分页查询每翻一页都要重新执行一次 SQL串行跑下来光等数据库就等得人心焦。第二数据在内存里的组装。查询出来的行对象要转成 POI 的 Row 和 Cell这个过程涉及大量的对象创建和字段赋值单线程跑满一个 CPU 核心但其他核心闲着。第三磁盘临时文件写入。SXSSFWorkbook 在数据超过窗口大小时会把旧行刷到本地临时 XML 文件这部分是磁盘 IO。串行写多个 sheet 时IO 也是一段一段排队走的。单线程串行处理这三件事总耗时就是“查询时间 组装时间 写入时间”。多线程方案的核心思想很简单把多个 sheet 的导出任务拆开并行推进让 CPU、数据库连接、磁盘 IO 尽量重叠起来而不是排队。我当时遇到的实际场景是这样一个报表需要按月份拆成 12 个 sheet每个月大约 8 万行总共接近百万行。单线程版本跑下来要 3 分多钟用户等到怀疑人生。把 12 个 sheet 拆给 6 个线程并行处理后耗时降到 1 分钟以内。这就是多线程的价值不是炫技是实打实把等待时间压下来。2. 方案选型三种多线程导出的路子分别什么情况用真正做技术方案的时候多线程导出分 sheet 能走的路不止一条。我见过不少写法有的确实能用但成本和风险差别很大。这里把我评估过的三种方案列出来重点讲为什么最终选了“单工作簿 子线程分写 sheet”。2.1 方案一只并行查数串行写 sheet这种方案最保守线程池执行所有查询任务把每个 sheet 的数据全部查出来放到内存 List 里然后主线程拿着这些数据依次创建 sheet、写行、输出。好处是绝对安全POI 的写入环节完全没有并发怎么折腾都不会有线程安全问题。坏处也明显数据量大时几十万行对象堆在内存里GC 压力大甚至可能直接 OOM。而且写入阶段还是串行只是把查询时间压缩了写盘慢的问题依旧存在。这个方案适合数据量不大、但查询本身很慢的场景。如果单 sheet 只有一两万行内存和写入都不是瓶颈那确实没必要上更复杂的方案。2.2 方案二单工作簿多线程分写 sheet这就是本篇文章要讲的重点方案。主线程创建一个 SXSSFWorkbook提前把需要的 sheet 创建好再把每个 sheet 分给一个线程去写。多个线程同时操作同一个 Workbook 实例但每个线程只碰自己的 Sheet 对象。这个方案看起来有点冒险毕竟 POI 官方明确说 Workbook 不是线程安全的。但实践下来只要把共享的可变操作全部放到主线程预执行子线程真的可以安心写各自的 sheet前提是严格遵守边界。下面会细说。它最大的收益是查询、组装、写入三个环节全部并行多个 sheet 之间互不等待总体耗时缩减效果最明显。2.3 方案三每线程一个独立工作簿最后合并也有团队用这种方式每个线程 new 一个 SXSSFWorkbook只写一个 sheet全部写完后再把多个工作簿的 sheet 合并到最终文件里。听起来很干净但问题在于 POI 里“合并多个工作簿的 sheet”不是一个现成的 API背后是解析 XML、处理样式映射、合并共享字符串这些脏活。除非你的 sheet 里只有最简单的数据完全不需要风格否则合并过程会非常痛苦耗时也不低。我评估过之后直接放弃了。除非你的业务就是要输出多个独立的 xlsx 文件否则不要走这条路。三种方案放在一起对比方案并行维度内存风险实现复杂度适用场景并行查数 串行写盘查询并行高数据全在内存低数据量小但查询慢单工作簿分写 sheet查询、写盘全并行低SXSSF 自动刷盘中大数据量、多分组多工作簿最后合并全部并行低高特殊需求一般不用我最终选方案二核心原因只有一个SXSSFWorkbook 的窗口机制天然适合“边写边刷盘”每个 sheet 写的时候并不会把所有行都留在内存里数据量再大也能扛。加上分 sheet 的业务场景里sheet 之间本来就相互独立并行写就是顺水推舟的事。3. 核心实现线程池编排 SXSSFWorkbook 分 sheet 写入方案定了下面直接上可落地的代码。这一节是整个方案的中枢我会按线程模型的顺序来拆从主线程准备阶段到子线程写入阶段再到最后的汇合输出。3.1 线程模型与共享边界先把规则讲清楚否则代码看懂了一上线还是会炸。SXSSFWorkbook 内部有两类东西。一类是 Workbook 级别的共享状态比如样式表 StylesSource、sheet 列表、单元格样式索引映射这些一旦被多个线程同时写轻则数据错乱重则直接抛异常。另一类是 Sheet 级别的状态SXSSFSheet 内部持有自己的 SheetDataWriter负责把刷出去的行写入独立的临时 XML 文件。不同 sheet 对应的临时文件不同互不干扰。基于这个结构我们可以划出一条清晰的共享边界可以并发写不同的 SXSSFSheet 对象在各自线程里创建行、创建单元格。只能主线程写Workbook.createSheet、Workbook.createCellStyle、Workbook.createFont。子线程只能读主线程创建好的 CellStyle 对象。不要共享跨线程使用同一个 Sheet 对象写入。只要把这条边界守死多线程写 sheet 就能安全运行。下面代码里的每一步都在贯彻这条边界。3.2 主线程准备创建 Workbook、预建 sheet 和样式先看主线程代码。这一阶段的核心是“预创建”把所有 WorkBook 级别的可变操作全部在这里完成。public void export(ListSheetTaskSpec specs, OutputStream outputStream) throws Exception { // 窗口大小100后续会根据实际情况调整 SXSSFWorkbook workbook new SXSSFWorkbook(100); try { // 主线程预创建所有sheet避免子线程并发createSheet MapString, SXSSFSheet sheetMap new HashMap(); MapString, CellStyle styleMap new HashMap(); for (SheetTaskSpec spec : specs) { SXSSFSheet sheet workbook.createSheet(spec.getSheetName()); sheetMap.put(spec.getSheetName(), sheet); } // 主线程预创建样式 CellStyle headerStyle workbook.createCellStyle(); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); CellStyle dataStyle workbook.createCellStyle(); dataStyle.setDataFormat(workbook.getCreationHelper().createDataFormat().getFormat(0.00)); styleMap.put(header, headerStyle); styleMap.put(data, dataStyle); // 提交任务await完成再write submitAndWait(workbook, sheetMap, styleMap, specs, outputStream); } finally { workbook.close(); } }这段代码里有两个细节值得展开。第一个是new SXSSFWorkbook(100)这个 100 表示行访问窗口大小。窗口中保留最近 100 行超过的行会被刷到临时文件。这个值不是随便拍的后面我会单独讲怎么调。第二个是样式预创建。headerStyle 和 dataStyle 是子线程要复用的对象。这里必须放在主线程创建因为 createCellStyle 会写入 Workbook 级别的样式表多个子线程同时调用就是并发写共享结构必炸。3.3 子线程任务每个 sheet 一个写任务查询、组装、写入全在这个线程主线程准备好后接下来是提交任务。每个 SheetTaskSpec 对应一个 future每个 future 处理一个独立的 sheet。private void submitAndWait(SXSSFWorkbook workbook, MapString, SXSSFSheet sheetMap, MapString, CellStyle styleMap, ListSheetTaskSpec specs, OutputStream outputStream) throws Exception { ListFutureBoolean futures new ArrayList(); CountDownLatch latch new CountDownLatch(specs.size()); for (SheetTaskSpec spec : specs) { SXSSFSheet sheet sheetMap.get(spec.getSheetName()); SheetWriteTask task new SheetWriteTask(spec, sheet, styleMap, latch); futures.add(executor.submit(task)); } // 等待全部sheet写完超时控制很重要 for (FutureBoolean future : futures) { future.get(10, TimeUnit.MINUTES); } // 所有sheet写完后再刷出到输出流 workbook.write(outputStream); }这里注意一个常见的反模式不要在循环里先future.get()再提交下一个任务那样会退化成串行。我上面的写法是先把所有任务提交完再统一等待。然后是核心的 SheetWriteTask。这个类继承了 Callable是真正干活的线程public class SheetWriteTask implements CallableBoolean { private final SheetTaskSpec spec; private final SXSSFSheet sheet; private final MapString, CellStyle styleMap; private final CountDownLatch latch; Override public Boolean call() { try { // 写表头 writeHeader(sheet, spec.getHeaders(), styleMap.get(header)); // 按ID范围分片查数避免深分页 long startId spec.getMinId(); long endId spec.getMaxId(); long batchSize spec.getBatchSize(); int rowIndex 1; for (long batchStart startId; batchStart endId; batchStart batchSize) { long batchEnd Math.min(batchStart batchSize - 1, endId); ListRowData pageData spec.getDataProvider().queryByRange(batchStart, batchEnd); for (RowData rowData : pageData) { SXSSFRow row sheet.createRow(rowIndex); writeDataRow(row, rowData, styleMap.get(data)); } } return true; } catch (Exception e) { Thread.currentThread().interrupt(); throw new RuntimeException(sheet写入失败: spec.getSheetName(), e); } finally { latch.countDown(); } } }这个任务里的查询方式值得单独说一下。我用的不是传统的 pageNum/pageSize 那种 offset 分页而是按 ID 范围切片。因为导出大数据量时offset 分页越翻越慢数据库要扫掉前面所有行才能定位到目标位置。按主键 ID 范围切片每个线程只取一段连续 ID走主键索引会快一个量级。queryByRange(batchStart, batchEnd)对应的 SQL 大致是SELECT * FROM business_data WHERE id #{batchStart} AND id #{batchEnd} AND create_time BETWEEN #{startTime} AND #{endTime}每个子线程内部串行查一批、写一批但多个 sheet 之间完全并行。这样就实现了查询和写入双重并行。3.4 行写入的细节从 RowData 到 Row写行的代码看起来简单但有些细节不处理会埋雷。这里放出 writeDataRow 的实现private void writeDataRow(SXSSFRow row, RowData data, CellStyle dataStyle) { String[] values data.getValues(); for (int i 0; i values.length; i) { SXSSFCell cell row.createCell(i); String raw values[i]; // 按列类型写入 switch (data.getColumnTypes()[i]) { case NUMERIC: cell.setCellValue(Double.parseDouble(raw)); cell.setCellStyle(dataStyle); break; case STRING: // 超过32767字符会报错需要截断 if (raw ! null raw.length() 32767) { raw raw.substring(0, 32767); } cell.setCellValue(raw null ? : raw); break; default: cell.setCellValue(raw null ? : raw); } } }有两个点特别提醒。第一Excel 单元格字符串上限是 32767 个字符。业务数据里如果存在超长文本导出的 Excel 会直接损坏且没有任何前置异常。我遇到过用户把整段 JSON 塞进一个字段里的情况解决办法就是截断或者干脆报错提示。第二SXSSFSheet 的 createRow 是直接创建新行不需要先 removeRow。窗口机制会自动把旧行刷到临时文件。但行号必须由当前任务自己维护不要依赖sheet.getLastRowNum()因为在窗口刷盘后内存里的行数并不等于最终文件行数容易出偏差。4. 调参与优化线程数、窗口大小、分页大小分别怎么定代码能跑只是第一步。我在实际压测中发现同样一套代码参数不同性能可以差出一倍多。这一节把几个关键参数掰开揉碎讲清楚。4.1 线程数不是 sheet 多就线程多也不是越大越好sheet 数量和线程数是两回事。线程池大小最合理的基准是看任务的类型。这个导出任务里每个子线程既要查数据库IO 等待又要写临时文件磁盘 IO还要做对象组装CPU 计算是一个典型的混合型任务。线程数可以比 CPU 核心数多一些但不要无脑设大。我一般按CPU 核数 * 2起步去压测观察数据库连接池会不会成为瓶颈。如果数据库连接池最大只有 10你把线程池设成 20那必然有线程在等连接多出来的线程只会增加上下文切换开销。反过来如果 sheet 数量本身只有 3线程池设 8 也没意义最多只有 3 个任务在跑。所以最直接的规则是线程池大小 min(CPU核数 * 2, sheet数量, 数据库连接池上限)。我这个场景里有 12 个 sheet机器是 4 核 8 线程就把核心线程数设成 6。4.2 rowAccessWindowSize内存与临时文件的平衡点这个参数很多人理解错了。它不是说最多能在内存里缓冲多少行而是说 SXSSFWorkbook 会在内存中保留最近多少个已写入的行超过的部分刷入磁盘。窗口设得越小内存越省但临时文件写入频率越高磁盘 IO 压力越大。窗口设得大内存占用高但刷盘次数少。对大数据量导出IO 往往才是瓶颈所以我一般不会把窗口设得太小。我的习惯单 sheet 数据量 5 万以内窗口 500 足够10 万到 20 万行窗口 200 到 300 比较平衡超过 20 万行窗口再大也没有意义反正都会被刷盘不如把窗口控制住给 JVM 留出余地。如果导出过程中观察到 GC 频繁、老年代涨得快优先考虑缩小窗口而不是加大堆内存。4.3 数据分片能用 ID 范围就不要用 offset前面代码里我用了按 ID 范围切片。这套思路在分表分库场景下同样适用只需要把 ID 换成分布式 ID 的范围即可。如果没有连续自增主键可以退而求其次用queryByPage但不要手动拼 LIMIT 大偏移量。更好的办法是先查一批主键 ID用 ID 集合去批量捞数据。不管怎么设计原则是避免深分页让每次查询都走索引快速定位。5. 实战踩坑这些雷我替你试过了这部分是全文最值得收藏的地方。多线程导出和单线程导出不一样问题不会第一时间暴露在你的业务代码里而是以各种莫名其妙的姿态出现。我全部踩过现在整理成容易对照的症状与解法。5.1 子线程里创建 CellStyle导致偶发数组越界第一次改造时我把创建样式的代码放在 SheetWriteTask 里每个线程开始写表头前自己去创建 headerStyle。压测时发现偶发ArrayIndexOutOfBoundsException并且很不稳定跑 10 次可能挂 2 次。原因就是多个子线程并发修改了 Workbook 内部的样式表。POI 在分配样式索引时不是线程安全的。表面上是一个数组越界实际上索引已经被并发写乱了。解决方式就是我上面代码里的写法样式全部由主线程创建子线程只读使用。这是一个硬性规则没有例外。5.2 并发 createSheet 导致生成的 Excel 里 sheet 数量随机变少有一次测试时生成的 Excel 里某几个 sheet 整个消失。查 log 发现子线程里调了 workbook.createSheet。多个线程同时往工作簿的 sheet 列表里加元素HashMap 结构被破坏丢数据是必然的。这个问题的修法最简单所有 sheet 提前建好。因为 sheet 的结构是跑业务之前就能确定的完全没必要放在子线程创建。5.3 close、dispose、write 的调用顺序SXSSFWorkbook 在写文件过程中会产生临时文件临时文件在close()或dispose()时会被删除。如果你在 workbook.write(outputStream) 之前调用了 close 或 dispose写入时就会报一个类似 “Temporary files were already deleted” 的异常。正确顺序是先write(OutputStream)再调用close()。不需要手动调 disposeclose 内部会处理临时文件清理。我还见过有同事在 finally 里同时写了 close 和 dispose导致重复清理报错这也是不必要的。5.4 下载的 Excel 打开提示“部分内容有问题”这个问题不一定和多线程直接相关但在多线程导出场景里更容易出现。经验告诉我们绝大多数原因是写入的单元格数据不合规最常见的两个一是单元格字符串长度超过 32767。这个必须截断。二是单元格里面塞入了非法 XML 字符Excel 解析 XML 时会失败。比如有些业务数据里有控制字符\u0000POI 写入时不会报错但 Excel 打开就报错。解决办法是在写入前把非法字符清洗掉或者用正则替换掉非 XML 1.0 允许的字符。String cleaned raw.replaceAll([\\u0000-\\u001F], );不过要注意\n和\t是合法的不要误删。上面这个正则主要是干控制字符。6. 多线程导出常见问题速查表把上面这些经验整理成一张速查表建议收藏备查。症状可能原因解决办法偶发数组越界、空指针子线程并发创建样式样式统一主线程创建sheet 数量随机变少子线程并发 createSheet主线程预创建全部 sheet临时文件已被删除异常write 之前调用了 close/dispose先 write 再 close打开 Excel 提示内容损坏字符串超长或非法字符截断字符串、过滤控制字符导出慢但 CPU 不高数据库查询串行或深分页按 ID 范围切片查询GC 频繁、内存飙升窗口大小设置过大缩小 rowAccessWindowSize某个 sheet 数据错乱跨线程写同一个 sheet每个 sheet 只允许一个线程写7. 一些我的个人体会这套方案我从最初版本改到最终版最大的体会是多线程导出的难点从来不在写代码本身而是在对 POI 内部结构的认知上。只要理解 SXSSFWorkbook 哪里是共享的、哪里是独立的方案设计就顺理成章。再分享一个我后来一直在用的习惯会让压测环境模拟真实用户的“最大导出量”跑 20 轮。多线程的问题很多是概率性的你测试时只跑一两轮问题未必暴露得出来。等线上用户一点导出偶发的问题才出来那是最被动的局面。跑 20 轮无异常这个方案才敢上线。分 sheet 多线程导出的思路不只适用于 POI。后面如果你换用其他 Excel 库也一样要检查它们的线程模型和内部共享状态思路是通用的。希望这篇能帮到正在跟导出性能较劲的朋友。
阅读完成 · 觉得有帮助?
咨询建站