源码口径更新(2026-09-29):本文保留学习笔记与原理示例。项目实际实现、个人增量和测试边界请先看项目总览;示例数字不代表可复核的项目实测。
技术派项目学习笔记(四):FastExcel 并发导出深度解析(最高标准版)
作者:Backend Architect
日期:2026-06-09
目标:对简历上的每一句话进行源码级验证,指出所有问题,给出生产级修复方案
📋 目录
- 简历描述验证
- 源码级深度解析
- 发现的重大问题
- 修复方案
- 面试八股文
- 总结
📋 简历描述验证
简历原文
旧版简历曾写百万行导出 3.5s→1.4s / 2.5 倍;仓库没有连接真实数据库并经过生产导出路径的可复现基准,因此该数字不能作为项目成果。可核实的是并发分片查询、CountDownLatch 等待及单线程 FastExcel 写出。
验证结果
⚠️ 实现机制可以从源码核对;旧版性能数字缺少真实数据库端到端基准。
| 技术点 |
简历描述 |
实际实现 |
是否准确 |
| FastExcel |
“基于 FastExcel” |
使用 FastExcel 导出 |
✅ 准确 |
| 自定义线程池 |
“自定义线程池(8 线程)” |
corePoolSize=8, maxPoolSize=16 |
✅ 准确 |
| CountDownLatch |
“CountDownLatch” |
使用 CountDownLatch 等待所有分片 |
✅ 准确 |
| 数据分片 |
“数据分片并发导出” |
按 100,000 条数据分片 |
✅ 准确 |
| ArrayBlockingQueue |
“ArrayBlockingQueue 有界队列” |
使用 ArrayBlockingQueue |
✅ 准确 |
| CallerRunsPolicy |
“CallerRunsPolicy 背压策略” |
使用 CallerRunsPolicy |
✅ 准确 |
| 单线程串行 |
“FastExcel 写操作保持单线程串行” |
主线程写 Excel |
✅ 准确 |
📚 源码级深度解析
1. 并发导出流程详解
1.1 为什么需要并发导出?
生活化比喻:
- 就像你要搬 100 万块砖到新家
- 单线程:你一块一块搬,要搬 100 万次,耗时取决于数据库与写出环境
- 并发导出:你叫 8 个朋友一起搬,每人搬 12.5 万块,查询可并发进行,最终仍需串行写出
性能验证:需要同一数据集、同一数据库环境,分别测串行与并发生产路径并报告多次运行的分布;当前无可复核结果。
1.2 并发导出流程
生活化比喻:
- 就像你要复印 100 万页资料
- 步骤 1:把资料分成 10 份,每份 10 万页(数据分片)
- 步骤 2:叫 8 个复印员同时复印(线程池并发执行)
- 步骤 3:等所有复印员复印完(CountDownLatch 等待)
- 步骤 4:把复印好的资料按顺序装订成一本(汇总数据)
- 步骤 5:把装订好的资料交给客户(FastExcel 写 Excel)
源码解析:StatisticsSettingServiceImpl.downloadRequestCount2ExcelConcurrent()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| @Override public void downloadRequestCount2ExcelConcurrent(HttpServletResponse response) { long total = requestCountService.count(); int shardCount = (int) Math.ceil((double) total / BATCH_SIZE); ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL, MAX_POOL, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(shardCount + 1), r -> new Thread(r, "excel-export-" + r.hashCode()), new ThreadPoolExecutor.CallerRunsPolicy() ); List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount); for (int i = 0; i < shardCount; i++) shardResults.add(null); CountDownLatch latch = new CountDownLatch(shardCount); for (int i = 0; i < shardCount; i++) { final int shardIndex = i; executor.submit(() -> { try { PageParam pageParam = PageParam.newPageInstance(shardIndex + 1, BATCH_SIZE); List<RequestCountDO> batch = requestCountService.listRequestCount(pageParam); List<RequestCountExcelDO> excelBatch = StatisticsConverter.convertToRequestCountExcelDOList(batch); shardResults.set(shardIndex, excelBatch); } catch (Exception e) { log.error("[并发导出] 分片 {} 查询失败", shardIndex, e); shardResults.set(shardIndex, new ArrayList<>()); } finally { latch.countDown(); } }); } latch.await(10, TimeUnit.MINUTES); List<RequestCountExcelDO> allData = new ArrayList<>((int) total); for (List<RequestCountExcelDO> shard : shardResults) { if (shard != null) allData.addAll(shard); } FastExcel.write(response.getOutputStream(), RequestCountExcelDO.class) .sheet("request_count全量导出") .doWrite(allData); }
|
执行流程:
- 查询总记录数,计算分片数
- 创建线程池(8 核心线程,16 最大线程)
- 提交分片任务到线程池
- 主线程等待所有分片完成(CountDownLatch)
- 汇总所有分片数据
- FastExcel 单线程写 Excel
2. 线程池深度解析
2.1 线程池参数详解
生活化比喻:
- 就像你要开一家复印店
- 核心线程数(corePoolSize):正式员工数量(8 人)
- 最大线程数(maximumPoolSize):正式员工 + 临时工数量(16 人)
- 队列(workQueue):等待复印的资料堆
- 拒绝策略(RejectedExecutionHandler):如果资料堆满了,就让客户自己复印(CallerRunsPolicy)
线程池参数:
1 2 3 4 5 6 7 8
| ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(shardCount + 1), r -> new Thread(r, "excel-export-" + r.hashCode()), new ThreadPoolExecutor.CallerRunsPolicy() );
|
2.2 CallerRunsPolicy 背压策略
生活化比喻:
- 就像你去餐厅吃饭,餐厅满了(队列满了)
- CallerRunsPolicy:服务员告诉你”现在没位置,你自己在门口等着,等有位置了再进来”
- 这样可以减慢提交任务的速度,防止 OOM
CallerRunsPolicy 的原理:
- 当队列满时,线程池会拒绝新任务
CallerRunsPolicy 会让调用线程(主线程)自己执行这个任务
- 这样主线程就被阻塞了,无法继续提交新任务
- 从而起到背压的效果
源码解析:
1 2 3 4 5 6 7 8
| public static class CallerRunsPolicy implements RejectedExecutionHandler { public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { r.run(); } } }
|
3. CountDownLatch 深度解析
3.1 CountDownLatch 是什么?
生活化比喻:
- 就像你要等所有朋友都到达餐厅,才开始点餐
CountDownLatch(8):你有 8 个朋友
- 每个朋友到达后,喊一声”我到了”(
countDown())
- 你一直在门口等(
await()),直到所有朋友都到了(getCount() == 0),才开始点餐
源码解析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| CountDownLatch latch = new CountDownLatch(shardCount);
executor.submit(() -> { try { } finally { latch.countDown(); } });
latch.await(10, TimeUnit.MINUTES);
|
3.2 CountDownLatch vs CyclicBarrier
| 特性 |
CountDownLatch |
CyclicBarrier |
| 重置 |
❌ 不能重置 |
✅ 可以重置 |
| 用途 |
等待其他线程完成 |
等待所有线程到达屏障 |
| 典型场景 |
主线程等待子线程完成 |
多个线程互相等待 |
4. FastExcel 深度解析
4.1 FastExcel 是什么?
生活化比喻:
- 就像你要写一本通讯录
- Apache POI:一页一页写,写完后把整本书保存到硬盘
- FastExcel:边写边保存,性能更好
FastExcel vs Apache POI:
| 特性 |
FastExcel |
Apache POI |
| 性能 |
✅ 高 |
❌ 低 |
| 内存占用 |
✅ 低 |
❌ 高 |
| 易用性 |
✅ 简单 |
❌ 复杂 |
4.2 为什么 FastExcel 写操作要单线程?
生活化比喻:
- 就像你要写一本通讯录,只能有一个人写
- 如果多个人同时写,会写乱(线程不安全)
FastExcel 线程不安全的原因:
- FastExcel 内部维护了一个
OutputStream
- 多个线程同时写,会导致数据错乱
源码解析:
1 2 3 4 5 6 7 8 9 10 11 12
|
FastExcel.write(response.getOutputStream(), RequestCountExcelDO.class) .sheet("request_count全量导出") .doWrite(allData);
synchronized (this) { FastExcel.write(response.getOutputStream(), RequestCountExcelDO.class) .sheet("request_count全量导出") .doWrite(allData); }
|
🚨 发现的重大问题
🟡 中等问题(性能问题、设计缺陷)
问题 1:ArrayList 存储分片结果有线程安全问题!
问题描述:
- 在
StatisticsSettingServiceImpl.downloadRequestCount2ExcelConcurrent() 中,使用 ArrayList 存储分片结果
- 多个线程同时调用
shardResults.set(shardIndex, excelBatch),会导致线程不安全
问题代码:
1 2
| List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount); for (int i = 0; i < shardCount; i++) shardResults.add(null);
|
后果:
- 可能会抛出
ArrayIndexOutOfBoundsException
- 可能会导致数据丢失
修复方案:
1 2 3 4 5 6
| List<List<RequestCountExcelDO>> shardResults = new CopyOnWriteArrayList<>(); for (int i = 0; i < shardCount; i++) shardResults.add(null);
List<RequestCountExcelDO>[] shardResults = new List[shardCount];
|
问题 2:分页参数计算容易出错
问题描述:
- 在
PageParam.newPageInstance(shardIndex + 1, BATCH_SIZE) 中,pageNum 的计算是 shardIndex + 1
- 如果
PageParam.newPageInstance() 的实现变了,这里就会出错
问题代码:
1 2 3 4
| PageParam pageParam = PageParam.newPageInstance( (long) (shardIndex + 1), (long) BATCH_SIZE );
|
修复方案:
1 2 3 4 5 6 7 8 9 10 11 12
| int offset = shardIndex * BATCH_SIZE; List<RequestCountDO> batch = requestCountService.listRequestCountByOffset(offset, BATCH_SIZE);
@Test public void testPageParamCalculation() { int shardIndex = 0; int BATCH_SIZE = 100_000; PageParam pageParam = PageParam.newPageInstance(shardIndex + 1, BATCH_SIZE); assert pageParam.getOffset() == shardIndex * BATCH_SIZE; }
|
🟢 轻微问题(代码规范、可维护性)
问题 3:没有进度报告
问题描述:
- 用户不知道导出的进度
- 如果数据量很大,用户会以为程序卡死了
修复方案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| CountDownLatch latch = new CountDownLatch(shardCount); AtomicInteger completedShards = new AtomicInteger(0);
for (int i = 0; i < shardCount; i++) { final int shardIndex = i; executor.submit(() -> { try { log.info("[并发导出] 进度: {}/{}", completedShards.incrementAndGet(), shardCount); } finally { latch.countDown(); } }); }
|
🔧 修复方案
修复方案 1:使用线程安全的数据结构
完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| @Override public void downloadRequestCount2ExcelConcurrent(HttpServletResponse response) { final int BATCH_SIZE = 100_000; final int CORE_POOL = 8; final int MAX_POOL = 16; long startTime = System.currentTimeMillis(); long total = requestCountService.count(); int shardCount = (int) Math.ceil((double) total / BATCH_SIZE); ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL, MAX_POOL, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(CORE_POOL * 2), r -> new Thread(r, "excel-export-" + r.hashCode()), new ThreadPoolExecutor.CallerRunsPolicy() ); List<RequestCountExcelDO>[] shardResults = new List[shardCount]; CountDownLatch latch = new CountDownLatch(shardCount); AtomicInteger completedShards = new AtomicInteger(0); for (int i = 0; i < shardCount; i++) { final int shardIndex = i; executor.submit(() -> { try { int offset = shardIndex * BATCH_SIZE; List<RequestCountDO> batch = requestCountService.listRequestCountByOffset(offset, BATCH_SIZE); List<RequestCountExcelDO> excelBatch = StatisticsConverter.convertToRequestCountExcelDOList(batch); shardResults[shardIndex] = excelBatch; log.info("[并发导出] 分片 {} 完成,进度: {}/{}", shardIndex, completedShards.incrementAndGet(), shardCount); } catch (Exception e) { log.error("[并发导出] 分片 {} 查询失败", shardIndex, e); shardResults[shardIndex] = new ArrayList<>(); } finally { latch.countDown(); } }); } try { boolean finished = latch.await(10, TimeUnit.MINUTES); if (!finished) { log.error("[并发导出] 等待超时,部分分片未完成"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("导出被中断", e); } finally { executor.shutdown(); } List<RequestCountExcelDO> allData = new ArrayList<>((int) total); for (List<RequestCountExcelDO> shard : shardResults) { if (shard != null) allData.addAll(shard); } try { FastExcel.write(response.getOutputStream(), RequestCountExcelDO.class) .sheet("request_count全量导出") .doWrite(allData); } catch (IOException e) { throw new RuntimeException("Excel写出失败", e); } }
|
📚 面试八股文
线程池
问题 1:什么是线程池?有什么优点?
回答:
- 线程池:管理线程的池子
- 优点:减少线程创建销毁的开销,提高性能
问题 2:ThreadPoolExecutor 的参数有哪些?
回答:
- corePoolSize:核心线程数
- maximumPoolSize:最大线程数
- keepAliveTime:空闲线程存活时间
- workQueue:等待队列
- threadFactory:线程工厂
- handler:拒绝策略
CountDownLatch
问题 3:什么是 CountDownLatch?有什么作用?
回答:
- CountDownLatch:倒计时门栓
- 作用:等待其他线程完成
FastExcel
问题 4:FastExcel 和 Apache POI 有什么区别?
回答:
- FastExcel:性能高,内存占用低
- Apache POI:性能低,内存占用高
📝 总结
本文讲了什么?
- 并发导出流程:数据分片、线程池并发执行、CountDownLatch 等待、汇总数据、FastExcel 写 Excel
- 线程池:核心线程数、最大线程数、队列、拒绝策略
- CountDownLatch:等待其他线程完成
- FastExcel:高性能 Excel 导出工具
发现的问题
ArrayList 存储分片结果有线程安全问题
- 分页参数计算容易出错
- 没有进度报告
修复方案
- 使用线程安全的数据结构:
CopyOnWriteArrayList 或数组
- 直接使用 offset:避免计算
pageNum
- 添加进度报告:让用户知道导出进度
本文完!