上次写单线程导出Excel的方案时我留了个尾巴当数据量到几十万行、还要按业务拆成多个sheet时单线程真的扛不住。这周我重新把导出模块改成了基于POI的多线程分sheet实现整个过程踩了不少坑从并发模型到临时文件清理都折腾了一遍值得单独写一篇记录。如果你正在做后台报表导出遇到数据量大、sheet多、耗时被用户反复投诉的情况这篇文章能直接给你一套可落地的思路和代码骨架。1. 为什么要把Excel导出从单线程改成多线程分sheet1.1 用户要的其实是分类清晰但性能成了瓶颈我接触到这个需求是在一个订单后台。导出的Excel需要按月份拆成6个sheet每个sheet大约8万行全部加起来超过40万行。一开始没想太多因为上篇文章我们已经用SXSSFWorkbook解决了单sheet大数据量导出的内存问题所以我只需要在循环里依次处理6个月份每个月份生成一个sheet再写进同一个输出流。写完之后功能没问题但真正点导出按钮时体验很差。单线程里查询6次每次查完紧接着写8万行期间CPU只在一个核上忙其他核全闲着。从点击导出到浏览器出现下载大概要20多秒。如果同时有几个运营同学各导一次后台线程池排队等待时间直接翻倍。这不是单纯的电脑性能不够而是思路问题分片之间没有依赖关系却被串行执行了。线程池和CPU多核在导出场景下完全可以派上用场我们缺的只是把单线程任务拆成可并行任务的设计。1.2 多线程分sheet的边界不是所有场景都能提速多线程分sheet不是说无脑把导出数据切成块丢给线程池就完事。它有几个前提。第一数据量要大得值得拆分。如果一共只有几千行单线程1秒就写完多线程反而会因为线程调度、临时文件生成和合并增加额外开销。第二每个分片的数据要能独立查询。如果业务上支持按月份、地区、状态这类天然维度拆分那是最好的。如果没有天然维度退而求其次按主键范围拆分但要注意主键分布是否均匀否则会出现某个线程处理100万行、其他线程处理几千行的情况。第三分片之间不能有强依赖。比如多个sheet需要共享同一个序号、合计行要依赖其他sheet的数据这种逻辑就不适合直接并行需要先做数据预处理。我在这次实现里选的是按月份分片每个分片的数据只依赖自己的月份天然独立。下面所有代码和测试也都基于这个前提展开。2. 先避开并发写入的坑POI的Workbook不是线程安全的临时文件才是正解2.1 共享一个Workbook写不同sheet为什么不行第一次设计时我的想法是主线程创建一个SXSSFWorkbook预先创建6个sheet然后把每个sheet交给一个线程去写。表面上看每个线程操作不同的sheet互不搭界。但在POI内部这个想法站不住脚。以XSSFWorkbook为例它内部维护了样式表、字体表、共享字符串表等大量公共结构。新建一个带样式的单元格时可能要去公共样式表里查找或新建样式往单元格写字符串时可能要去共享字符串表里追加记录。这些操作如果发生在多个线程同时对同一个Workbook做操作时就没有任何锁保护。即便你只是在不同sheet上createRow、createCell底层也可能同时修改同一个公共容器最终结果可能是样式错乱、内容覆盖运气差一点直接抛ConcurrentModificationException。SXSSFWorkbook本身也不是线程安全的文档写得很直白外部不额外加锁就不能并发使用。不要试图用synchronized锁住整个Workbook来保证安全。锁住以后所有线程的写入变成串行多线程的收益直接归零。所以正确的思路是让每个线程从头到尾只访问自己独立创建的Workbook实例实例之间完全不共享。2.2 为什么先写临时文件最后再合并既然每个线程一个Workbook那最后一个Excel文件的多个sheet从哪来最简单的做法是每个线程把Workbook对象放到一个集合里主线程最后打开这些Workbook把里面的sheet依次合并到目标Workbook。但这里有个内存问题。假设6个分片同时执行每个分片用XSSFWorkbook在内存里构建8万行6个Workbook同时驻留堆内存峰值直接是单线程方案的数倍。如果每个分片数据再多一点OOM几乎是必然的。即使改用SXSSFWorkbook多个工作簿同时存在每个工作簿的滑窗、临时文件句柄、底层XML对象叠加起来内存和文件句柄的压力也很大。所以我采用的方案是临时文件中转每个线程把分片数据写到自己的临时xlsx文件写完立即关闭并释放Workbook资源主线程等所有分片完成后再依次打开临时文件把每个sheet复制到最终Workbook里。这样做同一时间内存里最多只有正在运行的分片Workbook和合并阶段打开的一个源Workbook峰值被控制在一个稳定区间内。2.3 临时文件方案还带来了额外好处除了内存可控临时文件方案还有两个隐性收益。一是每个分片文件可以作为故障现场保留下来。某个月份的数据查询中途挂了临时文件里可能已经有部分数据排查时能直接看出是哪一段数据写不进去。二是支持断点式清理。用户如果取消导出主线程可以快速判断哪些临时文件还在直接整目录删除不影响最终Excel的生成。当然有代价每个分片要多一次磁盘写、多一次磁盘读。合并阶段要逐个文件读取并复制单元格这部分是串行的。但实测下来磁盘IO的开销远小于多线程带来的收益具体数据我在后面章节给出。如果你的临时目录是机械硬盘这个方案的读写竞争会明显变大我建议临时目录放SSD或者使用内存盘。3. 落地代码任务分片、并发写临时文件、合并最终Excel3.1 分片规则与查询方式先确定分片。这次是按月份分片月份列表从接口传入有几个月份就建几个sheet。每个分片对象包含sheet名称、查询起始时间、查询结束时间。public class SheetSlice { private final int index; private final String sheetName; private final LocalDateTime startTime; private final LocalDateTime endTime; // 构造函数省略 }如果你没有天然分组可以按主键范围分片。先查询一次最小ID和最大ID再按差值均分成N段每段是一个[startId, endId)区间。要注意主键分布不均的场景例如大量数据集中在某个时间段内直接均分会导致分片倾斜。这种时候更好的做法是先用SQL统计每个分组的数据量再决定怎么分。查询方式非常关键。不要在分片任务里用LIMIT offset, size这样翻页查询因为offset越大数据库扫描的成本越高总耗时接近O(N²)。按月份查询时直接使用WHERE create_time ? AND create_time ?并且配合合适的索引。如果查询结果集很大建议使用流式游标比如MyBatis的Cursor逐条处理而不是一次性把所有行load到List里。3.2 线程池参数和执行入口线程池我直接用了ThreadPoolExecutor核心线程数和最大线程数取同一个值避免动态扩容带来的线程创建开销。参数里要注意两个点队列必须有界拒绝策略用CallerRunsPolicy。int poolSize Math.min(sliceList.size(), Runtime.getRuntime().availableProcessors()); ThreadPoolExecutor pool new ThreadPoolExecutor( poolSize, poolSize, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue(sliceList.size()), new ThreadPoolExecutor.CallerRunsPolicy() );为什么用有界队列如果分片任务很多比如20个sheet而无界队列会允许任务全部堆在内存里同时每个任务可能还在等待数据库连接最终导致任务积累和内存上涨。有界队列配合CallerRunsPolicy当队列满了就让提交任务的主线程自己去执行一个任务顺便天然实现了一个背压效果不让任务无限制堆积。提交和收集结果的方式ListFuturePath futures new ArrayList(); for (SheetSlice slice : sliceList) { futures.add(pool.submit(new SheetWriterTask(slice, tempDir, orderMapper))); } pool.shutdown();3.3 线程任务生成临时Excel文件每个线程的任务是独立的我把它封装成一个CallablePath。内部逻辑可以分成三层查询分片数据、写sheet、落盘临时文件。public class SheetWriterTask implements CallablePath { private final SheetSlice slice; private final Path tempDir; private final OrderMapper orderMapper; Override public Path call() throws Exception { Path output tempDir.resolve(slice_ slice.getIndex() .xlsx); try (SXSSFWorkbook workbook new SXSSFWorkbook(500)) { Sheet sheet workbook.createSheet(slice.getSheetName()); writeHeader(sheet); // 这里的 scanByTimeRange 是模拟DAO流式查询 // 实际项目中可用 MyBatis Cursor 或 JDBC 的 fetchSize 方式实现 orderMapper.scanByTimeRange(slice.getStartTime(), slice.getEndTime(), order - { Row row sheet.createRow(sheet.getLastRowNum() 1); fillRow(row, order); }); try (FileOutputStream fos new FileOutputStream(output.toFile())) { workbook.write(fos); } } finally { // 临时文件写入完成后不再需要滑动窗口生成的磁盘缓存 // 建议显式调用dispose避免资源残留 } return output; } }这里的scanByTimeRange是一个函数式回调用来模拟流式查询。实际项目中如果是MyBatis可以使用CursorOrder如果是JDBC可以用带fetchSize的PreparedStatement。总之不要把几十万行一次性装进内存再慢慢写。new SXSSFWorkbook(500)表示内存滑窗大小是500行超过500行的数据会被刷到磁盘上的临时文件。这个值不是越大越好调大会降低磁盘写入次数但增加内存占用调小则反之。对于每个分片8万行的场景500是个稳妥的起步值。写入时要注意花时间最多的往往是样式。表头每个sheet只需要写一遍可以用一个CellStyle对象反复设置给表头单元格。数据行如果不需要特殊格式直接用默认样式不要每行都新建CellStyle否则Excel文件体积会暴涨写入速度也会变慢。这个原则在多线程场景下尤其重要因为每个线程都在高速生成单元格式样。3.4 合并临时文件到最终Workbook主线程收集完所有Future后开始合并。合并顺序按分片索引排保证最终sheet顺序和业务约定一致。代码如下try (XSSFWorkbook targetWorkbook new XSSFWorkbook()) { for (Path tempFile : tempFiles) { try (Workbook src WorkbookFactory.create(tempFile.toFile(), (String) null, true)) { Sheet srcSheet src.getSheetAt(0); Sheet targetSheet targetWorkbook.createSheet(srcSheet.getSheetName()); copySheet(srcSheet, targetSheet); } } try (FileOutputStream finalOut new FileOutputStream(finalFile)) { targetWorkbook.write(finalOut); } }copySheet的核心逻辑是逐行逐列复制同时保留列宽、行高、合并区域和单元格样式private static void copySheet(Sheet src, Sheet dest) { int maxCol 0; for (Row row : src) { if (row.getLastCellNum() maxCol) { maxCol row.getLastCellNum(); } } for (int c 0; c maxCol; c) { dest.setColumnWidth(c, src.getColumnWidth(c)); } for (CellRangeAddress range : src.getMergedRegions()) { dest.addMergedRegion(range); } for (Row srcRow : src) { Row destRow dest.createRow(srcRow.getRowNum()); destRow.setHeight(srcRow.getHeight()); for (Cell srcCell : srcRow) { Cell destCell destRow.createCell(srcCell.getColumnIndex()); CellUtil.copyCell(srcCell, destCell, true, true); } } }CellUtil.copyCell会把源单元格的类型、值、样式都复制到目标单元格它内部会处理样式在目标Workbook中的创建。这个方法在POI 4.1.2以上版本里用起来最稳老版本可能需要自己复制样式。如果你发现合并后的文件样式不对先升级POI版本然后重点检查字体、填充色这类样式属性。如果最终sheet数量特别多XSSFWorkbook作为目标Workbook会把所有sheet都保存在内存里内存压力同样不小。这时可以把targetWorkbook也换成SXSSFWorkbook因为copySheet方法基于Sheet接口XSSF和SXSSF的sheet都能传进去。不过SXSSF的合并逻辑会多一层临时文件处理建议先确认你的POI版本对SXSSFSheet的getMergedRegions支持情况再切。3.5 收尾与清理临时文件必须在finally里清理。我习惯用一个UUID作为本次导出任务的临时目录结束阶段递归删除整个目录避免多个导出任务互相干扰Path tempDir Paths.get(System.getProperty(java.io.tmpdir), excel_export_ UUID.randomUUID()); Files.createDirectories(tempDir); try { // 提交任务、合并文件 } finally { deleteRecursively(tempDir); }还有一点SXSSFWorkbook的dispose()和close()要分清楚。close()负责释放常规资源但滑动窗口写入的临时文件通常要靠dispose()清理。不同POI版本的close行为不完全一致最稳妥的写法是在finally里调用workbook.dispose()。如果你直接用了try-with-resources也建议在finally里补一次dispose成本很低但能避免临时文件残留。4. 实测数据与线程数/分片数的选择策略4.1 一次48万行导出的对比数据写这段之前先说明以下数据来自我当时测试环境8核16G内存数据库连接池最大5临时目录在SSD上。业务数据是48万行订单6个分片每片8万行每个分片查出后写一个sheet。我分别测了单线程、4线程、6线程和8线程四种方案。执行方案总耗时峰值内存约说明单线程逐sheet写32.6秒1.1GB查询和写sheet全部串行4线程临时文件合并12.8秒620MB3个线程的收益比较明显6线程临时文件合并9.4秒570MB分片数和线程数匹配效果最好8线程临时文件合并10.2秒550MB没有继续变快反而略慢为什么8线程反而更慢因为数据库连接池连最大值只有5。8个线程里至少3个会卡在等数据库连接上线程空转、上下文切换增加整体没有带来正向收益。这组数据说明了一件事多线程导出的上限不是CPU核数而是整个链路里最短板的那一环。数据库连接池、磁盘IO、临时文件所在磁盘的写入速度任何一个不够都可能吃掉多线程带来的收益。4.2 线程数怎么确定我的建议是核心线程数取三个值的交集上限分片数、CPU核数、数据库可用连接数。如果数据库连接池比较紧张导出任务和其他业务共用连接池线程数更要保守。你可以先用一个临时线程池跑一次压测观察数据库连接池的等待时间如果等待时间超过总耗时的20%说明连接池已经成了瓶颈要么降低线程数要么给连接池扩容。另外要注意availableProcessors()拿到的是物理核或超线程核的数目在容器环境里可能不等于实际可用的CPU配额。如果应用部署在容器里最好通过环境变量显式配置导出线程数不要完全依赖JVM探测。4.3 分片大小怎么估算分片数量不是越多越好。每个分片从创建SXSSFWorkbook到写临时文件都有固定的初始化成本。分片太细比如总数据量只有10万行却分10个分片每个分片1万行初始化成本占比就会很高最终可能比单线程还慢。我自己的经验值单个分片行数控制在1万到10万之间具体看单元格列数和样式复杂度。纯文本数据可以放量到10万如果每行有十几个列且带各种样式5万行左右就比较合适。分片数尽量向线程数靠拢不要线程数6却分20个sheet除非你有特殊的业务分组需求否则应该先合并小分片用任务数等于线程数的方式提交。5. 生产落地必须处理的边界问题与经验5.1 临时文件的生命周期临时文件方案最怕的是进程结束但临时文件还在。不要直接往系统默认临时目录里丢一堆part_1.xlsx否则多次导出之后磁盘被占满。我用的是每次导出建一个UUID子目录 任务结束后递归删除的组合。如果任务中途崩了还有一个兜底手段启动时扫描临时根目录删除超过2小时没有修改过的子目录。这个清理动作可以放在导出任务方法第一次被调用时执行成本很低。另外如果你的应用部署在容器里临时目录可能被限定在一个很小的磁盘上建议通过环境变量把导出临时目录指向大容量磁盘并在导出前检查剩余空间。分片文件是并行写的6个线程同时写大文件时磁盘空间下降速度比你想象中快。5.2 查询分片是最大的隐性瓶颈很多POI导出文章把焦点放在Workbook怎么写但我在实际项目里发现真正卡时间的是查询。如果分片任务里每个线程都执行一次先count再limit的翻页查询数据库负载会成倍增加。按月分片还好只要建好时间索引按主键范围分片时要先看数据分布。还有一种情况需要特别注意流式查询Cursor会占用数据库连接直到整个结果集遍历完。如果你的线程池线程数大于数据库连接池大小分片任务会互相等待连接甚至出现连接获取超时。所以线程数设计时必须把连接池可用连接数算进去这也是我上一节反复强调的原因。5.3 样式和模板统一多线程分sheet合并最容易被忽略的坑是样式。每个线程独立创建Workbook如果不加控制每个线程创建的字体、填充样式、边框样式都是独立实例。合并到目标Workbook时样式表里会出现大量重复定义文件体积变大合并时间变长甚至可能出现样式串了的情况。我建议在导出模块里做一个公共的样式模板方法比如createHeaderStyle(Workbook wb)所有线程都通过这个方法创建样式保证同一个导出任务里所有sheet的样式名称和数据格式是一致的。更进一步可以让每个分片线程在创建Workbook时都从一个模板xlsx文件加载这样字体索引、列宽、表头样式天然统一合并时几乎不会出现样式冲突。模板文件可以是一个只含空sheet的xlsx放在resources目录里。5.4 兜底、取消与降级导出任务不是永远成功的。我遇到过某个月份的数据中混入特殊字符POI在写单元格时抛异常的情况。多线程场景下单个分片失败不应该导致整个导出任务卡死。我的处理方式是给Future.get设置超时时间超时后取消剩余未完成的任务删除临时目录然后给前端返回明确的错误提示。如果有重试机制只重试失败的那个分片不要整批重跑。还有一个降级策略如果数据量不大、或者当前系统负载已经很高可以直接走旧版单线程导出。这个判断可以在入口处做比如总行数小于5万行时走单线程大于阈值才走多线程。这样既保证了小数据量的效率又避免线程池被导出任务占满后影响其他业务。阈值可以从配置中心读取方便上线后调整。在实际代码里把线程池创建和导出策略封装成独立组件业务方只需要传入数据来源、分片规则和输出路径后续再调整线程数、合并策略或者接新的导出需求都只需要改组件内部不会波及业务层。这也是这次重构之后我体会最深的一点导出逻辑和业务查询逻辑解耦才能真正把多线程分sheet这种复杂方案沉淀成可复用的基础设施。