技术派项目学习笔记(四):FastExcel 并发导出深度解析(最高标准版)

源码口径更新(2026-09-29):本文保留学习笔记与原理示例。项目实际实现、个人增量和测试边界请先看项目总览;示例数字不代表可复核的项目实测。

技术派项目学习笔记(四):FastExcel 并发导出深度解析(最高标准版)

作者:Backend Architect
日期:2026-06-09
目标:对简历上的每一句话进行源码级验证,指出所有问题,给出生产级修复方案


📋 目录

  1. 简历描述验证
  2. 源码级深度解析
  3. 发现的重大问题
  4. 修复方案
  5. 面试八股文
  6. 总结

📋 简历描述验证

简历原文

旧版简历曾写百万行导出 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) {
// 1. 查总量,计算分片数
long total = requestCountService.count();
int shardCount = (int) Math.ceil((double) total / BATCH_SIZE);

// 2. 自定义线程池
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()
);

// 3. 用 CopyOnWriteArrayList 存各分片结果
List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount);
for (int i = 0; i < shardCount; i++) shardResults.add(null);

CountDownLatch latch = new CountDownLatch(shardCount);

// 4. 提交分片任务
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();
}
});
}

// 5. 主线程阻塞等待所有分片完成
latch.await(10, TimeUnit.MINUTES);

// 6. 汇总所有分片数据
List<RequestCountExcelDO> allData = new ArrayList<>((int) total);
for (List<RequestCountExcelDO> shard : shardResults) {
if (shard != null) allData.addAll(shard);
}

// 7. FastExcel 单线程写出
FastExcel.write(response.getOutputStream(), RequestCountExcelDO.class)
.sheet("request_count全量导出")
.doWrite(allData);
}

执行流程:

  1. 查询总记录数,计算分片数
  2. 创建线程池(8 核心线程,16 最大线程)
  3. 提交分片任务到线程池
  4. 主线程等待所有分片完成(CountDownLatch)
  5. 汇总所有分片数据
  6. 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, // corePoolSize:核心线程数(正式员工)
16, // maximumPoolSize:最大线程数(正式员工 + 临时工)
60L, TimeUnit.SECONDS, // keepAliveTime:临时工空闲多久后被解雇
new ArrayBlockingQueue<>(shardCount + 1), // workQueue:等待队列(有界)
r -> new Thread(r, "excel-export-" + r.hashCode()), // threadFactory:线程工厂(给线程起名字)
new ThreadPoolExecutor.CallerRunsPolicy() // handler:拒绝策略(让调用线程自己执行)
);

2.2 CallerRunsPolicy 背压策略

生活化比喻:

  • 就像你去餐厅吃饭,餐厅满了(队列满了)
  • CallerRunsPolicy:服务员告诉你”现在没位置,你自己在门口等着,等有位置了再进来”
  • 这样可以减慢提交任务的速度,防止 OOM

CallerRunsPolicy 的原理:

  • 当队列满时,线程池会拒绝新任务
  • CallerRunsPolicy 会让调用线程(主线程)自己执行这个任务
  • 这样主线程就被阻塞了,无法继续提交新任务
  • 从而起到背压的效果

源码解析:

1
2
3
4
5
6
7
8
// 当队列满时,CallerRunsPolicy 会执行到这里
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
// 1. 创建 CountDownLatch,初始计数为 shardCount
CountDownLatch latch = new CountDownLatch(shardCount);

// 2. 每个分片任务执行完后,计数 -1
executor.submit(() -> {
try {
// 执行分片任务
// ...
} finally {
latch.countDown(); // 计数 -1
}
});

// 3. 主线程等待所有分片任务完成
latch.await(10, TimeUnit.MINUTES); // 最多等 10 分钟

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 写操作必须单线程
// 方案 1:主线程写(当前实现)
FastExcel.write(response.getOutputStream(), RequestCountExcelDO.class)
.sheet("request_count全量导出")
.doWrite(allData);

// 方案 2:用 synchronized 锁住写操作
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);  // ❌ ArrayList 不是线程安全的!
for (int i = 0; i < shardCount; i++) shardResults.add(null);

后果:

  • 可能会抛出 ArrayIndexOutOfBoundsException
  • 可能会导致数据丢失

修复方案:

1
2
3
4
5
6
// 方案 1:使用 CopyOnWriteArrayList
List<List<RequestCountExcelDO>> shardResults = new CopyOnWriteArrayList<>();
for (int i = 0; i < shardCount; i++) shardResults.add(null);

// 方案 2:使用数组(最安全)
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), // ❗❗ pageNum 从 1 开始
(long) BATCH_SIZE
);

修复方案:

1
2
3
4
5
6
7
8
9
10
11
12
// 方案 1:直接使用 offset(推荐)
int offset = shardIndex * BATCH_SIZE;
List<RequestCountDO> batch = requestCountService.listRequestCountByOffset(offset, BATCH_SIZE);

// 方案 2:加单元测试
@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()
);

// 1. 使用数组存储分片结果(线程安全)
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:性能低,内存占用高

📝 总结

本文讲了什么?

  1. 并发导出流程:数据分片、线程池并发执行、CountDownLatch 等待、汇总数据、FastExcel 写 Excel
  2. 线程池:核心线程数、最大线程数、队列、拒绝策略
  3. CountDownLatch:等待其他线程完成
  4. FastExcel:高性能 Excel 导出工具

发现的问题

  1. ArrayList 存储分片结果有线程安全问题
  2. 分页参数计算容易出错
  3. 没有进度报告

修复方案

  1. 使用线程安全的数据结构:CopyOnWriteArrayList 或数组
  2. 直接使用 offset:避免计算 pageNum
  3. 添加进度报告:让用户知道导出进度

本文完!


技术派项目学习笔记(四):FastExcel 并发导出深度解析(最高标准版)
https://whyalwaysme.lol/2026/06/09/2026-06-09-devlink-fastexcel-ultimate/
作者
Cassiur
发布于
2026年6月9日
许可协议