技术派项目学习笔记(四)· 高标准:FastExcel 并发导出与线程池(源码级深度解析)

技术派项目学习笔记(四)· 高标准:FastExcel 并发导出与线程池(源码级深度解析)

本篇是「高标准严要求」版本,基于 StatisticsSettingServiceImpl.java 逐行解析,不遗漏任何细节。适合面试前通读,确保被拷问时能对答如流。


一、先搞懂:为什么要做”并发导出”?

1.1 不用并发时的问题

假设你要导出 100 万条数据到 Excel:

1
2
3
4
5
6
不用并发(单线程):
① 查询 100 万条数据(查数据库,很慢)
② 用 FastExcel 写出到文件(也很慢)
③ 两步串行,总共耗时 = 查数据时间 + 写文件时间

实测:约 3.5 秒

问题

  • 数据库查询是 IO 密集型(等待数据库返回,CPU 空闲)
  • 可以用多线程并发查询,充分利用 CPU 和数据库连接

1.2 用并发之后的效果

1
2
3
4
5
6
7
用并发(多线程):
① 把 100 万条数据分成 5 片,每片 20 万条
5 个线程同时查询数据库(并发查)
③ 所有线程查完后,汇总数据
④ 单线程 FastExcel 写出(POI 非线程安全)

实测:约 1.4 秒(提升 2.5 倍)

二、FastExcel 是什么?为什么不用 EasyExcel?

2.1 FastExcel 简介

FastExcelEasyExcel 的现代化重构版(EasyExcel 已经停止维护了)。

对比 EasyExcel FastExcel
维护状态 ❌ 已停止维护(2023 年起) ✅ 活跃维护
性能 基于 POI(较慢) 重写,性能更好
内存占用 流式写,内存占用低 更低的内存占用
API 风格 偏旧 现代化 API
线程安全 ❌ 非线程安全 ❌ 非线程安全(底层仍是 POI)

2.2 FastExcel 的核心优势:流式写

传统 POI 的问题(Apache POI):

1
2
3
4
5
6
7
8
9
// ❌ 错误写法:一次性加载所有数据到内存
List<User> users = userDao.selectAll(); // 100 万条数据
XSSFWorkbook workbook = new XSSFWorkbook();
XSSFSheet sheet = workbook.createSheet("用户数据");
for (User user : users) {
XSSFRow row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue(user.getName());
// 问题:100 万条数据都在内存里! → OOM!
}

FastExcel 的流式写

1
2
3
4
// ✅ 正确写法:流式写,不占用大量内存
FastExcel.write(outputStream, User.class)
.sheet("用户数据")
.doWrite(users); // FastExcel 内部分批刷盘,不 OOM

原理:FastExcel 底层用 SXSSFWorkbook(POI 的流式写实现):

  • 数据先写到磁盘临时文件
  • 内存里只保留最新的 100 行(可配置)
  • 超过 100 行的数据刷到磁盘,释放内存

2.3 FastExcel 是非线程安全的!

关键知识点:FastExcel 底层依赖 SXSSFWorkbook,而 SXSSFWorkbook 不是线程安全的

1
2
3
4
5
6
7
8
// ❌ 错误写法:多线程同时写一个 Excel 文件
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int i = 0; i < 8; i++) {
pool.submit(() -> {
// 多线程同时调用 FastExcel.write() → 并发写同一个 Sheet → 数据错乱!
FastExcel.write(outputStream, User.class).sheet("用户数据").doWrite(shardData);
});
}

正确做法

1
多线程并发【查询数据库】→ 汇总到一个 List → 单线程 FastExcel 写出

三、线程池:管理多线程的”管家”

3.1 为什么用线程池,而不是 new Thread()

不用线程池的问题

1
2
3
4
5
6
// ❌ 错误写法:每次导出都创建新线程
public void export() {
for (int i = 0; i < 100; i++) {
new Thread(() -> queryDataFromDb()).start(); // 创建 100 个线程!
}
}

问题

  • 创建线程开销大(要分配栈内存、内核资源)
  • 线程数不受控制(如果有 1000 个人同时导出,创建 1000 个线程 → 系统崩溃)
  • 线程不能复用(执行完就销毁,下次还要重新创建)

用线程池的好处

  • 复用线程(线程执行完任务后不销毁,继续接新任务)
  • 限制线程数(最多同时运行 N 个线程,保护系统)
  • 队列缓冲(任务多时,先放进队列排队)

3.2 线程池的核心参数(面试必考!)

StatisticsSettingServiceImpl.java 第 139-146 行(逐行解析):

1
2
3
4
5
6
7
8
9
// 【第 139-146 行】自定义线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL, // ① corePoolSize:核心线程数
MAX_POOL, // ② maximumPoolSize:最大线程数
60L, TimeUnit.SECONDS, // ③ keepAliveTime:空闲线程存活时间
new ArrayBlockingQueue<>(shardCount + 1), // ④ workQueue:任务队列
r -> new Thread(r, "excel-export-" + r.hashCode()), // ⑤ threadFactory:线程工厂
new ThreadPoolExecutor.CallerRunsPolicy() // ⑥ rejectedExecutionHandler:拒绝策略
);

这 6 个参数的含义(用银行办业务比喻):

1
2
3
4
5
6
银行(线程池)有 5 个窗口(核心线程数 = 5):
① 来的客户 < 5 个 → 开对应数量的窗口办理
② 来的客户 > 5 个 → 客户去排队(进队列)
③ 队列满了(100 人排满了)→ 银行临时加开窗口(最大线程数 = 16)
④ 窗口全开了,队列也满了 → 新来的客户被拒绝(执行拒绝策略)
⑤ 客户办理完后,临时窗口空闲超过 60 秒 → 关闭临时窗口(回收线程)

3.3 核心线程数怎么设置?

经验公式

1
2
CPU 密集型(计算多,IO 少):  核心线程数 = CPU 核心数 + 1
IO 密集型(数据库查询、网络请求):核心线程数 = CPU 核心数 × 2

项目里的选择

  • 导出数据是 IO 密集型(主要时间在等数据库返回)
  • 假设 CPU 是 4 核 → 核心线程数 = 4 × 2 = 8
  • 源码第 126 行:final int CORE_POOL = 8; // 核心线程数(与 CPU 核心数对齐)

3.4 为什么用 ArrayBlockingQueue 而不是 LinkedBlockingQueue

对比 ArrayBlockingQueue LinkedBlockingQueue
底层 数组(有界,必须指定容量) 链表(无界,默认 Integer.MAX_VALUE
内存占用 固定(提前分配数组) 不固定(来多少任务就占多少内存)
OOM 风险 ✅ 低(有界,满了就拒绝新任务) ❌ 高(无界,任务无限堆积 → OOM)

项目选 ArrayBlockingQueue 的核心原因

导出任务是重量级任务(大量内存),必须限制并发任务数,防止 OOM。LinkedBlockingQueue 相当于没有背压。

源码第 143 行

1
new ArrayBlockingQueue<>(shardCount + 1),  // 有界队列,容量 = 分片数 + 1

为什么容量是 shardCount + 1

  • 分片数是 shardCount(比如 5 个分片)
  • 队列容量设为 shardCount + 1(比如 6)
  • 这样最多可以提交 shardCount + 1 个任务到队列
  • 超过这个数量,就会触发拒绝策略CallerRunsPolicy

3.5 拒绝策略:CallerRunsPolicy 是什么?

线程池有 4 种拒绝策略

拒绝策略 行为 适用场景
AbortPolicy(默认) 抛异常(RejectedExecutionException 重要任务,不能丢
CallerRunsPolicy 让提交任务的线程自己执行 不丢任务,且有背压效果
DiscardPolicy 直接丢弃新任务,不抛异常 不重要任务(如日志记录)
DiscardOldestPolicy 丢弃队列里最老的任务 新任务比老任务重要

CallerRunsPolicy 的背压机制(非常重要!):

1
2
3
4
5
6
7
8
场景:线程池满了(8 个线程都在忙),队列也满了(100 个任务在排队)
→ 新任务被拒绝
→ CallerRunsPolicy:让【提交任务的线程】自己执行这个任务

效果:
- 提交任务的线程(比如 HTTP 处理线程)被占用 → 无法接收新请求
- 自然限速:新请求进不来,系统不会被压垮
- 等线程池有空闲了,HTTP 线程才能继续接收新请求

源码第 145 行

1
new ThreadPoolExecutor.CallerRunsPolicy()  // 队列满时由调用者线程自己执行,起到背压效果

四、CountDownLatch:等待所有线程完成任务`

4.1 不用 CountDownLatch 时的问题

1
2
3
4
5
6
7
8
9
10
11
// ❌ 错误写法:不知道线程什么时候执行完
ExecutorService pool = Executors.newFixedThreadPool(8);
for (int i = 0; i < 5; i++) {
int shardIndex = i;
pool.submit(() -> {
List<RequestCountExcelDO> data = queryDataFromDb(shardIndex);
allData.addAll(data); // ❌ 多线程并发写 allData → 线程不安全!
});
}
// 问题:下面的代码可能在线程还没执行完时就运行了!
FastExcel.write(outputStream, RequestCountExcelDO.class).sheet("导出").doWrite(allData);

4.2 CountDownLatch 的原理

CountDownLatch(倒计时门栓)= 一个计数器,线程完成任务后就 -1,等计数器变成 0 时,等待的线程才继续执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ✅ 正确写法:用 CountDownLatch 等待所有线程执行完
int shardCount = 5;
CountDownLatch latch = new CountDownLatch(shardCount); // 计数器初始值 = 5

for (int i = 0; i < shardCount; i++) {
int shardIndex = i;
pool.submit(() -> {
try {
List<RequestCountExcelDO> data = queryDataFromDb(shardIndex);
allData.addAll(data);
} finally {
latch.countDown(); // 计数器 -1(不管成功失败)
}
});
}

latch.await(); // 阻塞,等计数器变成 0(所有线程都执行完)
// 到这里,所有线程都执行完了
FastExcel.write(outputStream, RequestCountExcelDO.class).sheet("导出").doWrite(allData);

流程图

1
2
3
4
5
6
7
8
9
主线程:创建 CountDownLatch(5)

线程1:查询分片 0 的数据 → latch.countDown() → 计数器 54
线程2:查询分片 1 的数据 → latch.countDown() → 计数器 43
线程3:查询分片 2 的数据 → latch.countDown() → 计数器 32
线程4:查询分片 3 的数据 → latch.countDown() → 计数器 21
线程5:查询分片 4 的数据 → latch.countDown() → 计数器 10

计数器 = 0 → 主线程的 latch.await() 返回 → 继续执行

4.3 CountDownLatch vs CyclicBarrier

对比 CountDownLatch CyclicBarrier
重置 ❌ 不能重置(只能用一次) ✅ 可以重置(可重复使用)
等待方 一个或多个线程等待其他线程完成 所有线程互相等待,都到了再一起继续
适用场景 等待 N 个任务完成 多线程分阶段计算(阶段 1 完了才能开始阶段 2)

项目里用 CountDownLatch 的原因

只需要”等待所有分片查询完成”这一次,不需要重复使用。

4.4 源码里的 ArrayList 线程安全问题

StatisticsSettingServiceImpl.java 第 150-151 行:

1
2
3
// 【第 150-151 行】用 ArrayList 存各分片结果(多线程写安全?)
List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount);
for (int i = 0; i < shardCount; i++) shardResults.add(null);

问题ArrayList 不是线程安全的!虽然有 shardResults.set(shardIndex, excelBatch)(每个线程写不同的 shardIndex 位置),但 ArrayList 的内部数组可能会扩容,导致线程不安全!

正确做法(源码里的写法其实有坑):

1
2
3
4
// ❌ 源码里的写法(有坑):
List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount);
for (int i = 0; i < shardCount; i++) shardResults.add(null);
// 多线程调用 shardResults.set(shardIndex, excelBatch) → 可能线程不安全!

为什么有坑?

  • ArrayList 的内部数组在扩容时会复制数据,这时候如果有其他线程在 set(),可能会数组越界或数据覆盖!
  • 虽然这里每个线程写不同的 shardIndex,但 ArrayList 不是为并发设计的!

正确做法 1:用 CopyOnWriteArrayList(推荐)

1
2
3
4
// ✅ 正确写法 1:用 CopyOnWriteArrayList(线程安全)
List<List<RequestCountExcelDO>> shardResults = new CopyOnWriteArrayList<>();
for (int i = 0; i < shardCount; i++) shardResults.add(null);
// 多线程调用 shardResults.set(shardIndex, excelBatch) → 线程安全!

正确做法 2:用 synchronized 保护 set() 操作

1
2
3
4
// ✅ 正确写法 2:用 synchronized 保护
synchronized (shardResults) {
shardResults.set(shardIndex, excelBatch);
}

正确做法 3:不用 List<List<?>>,改用数组(最快)

1
2
3
4
// ✅ 正确写法 3:用数组(线程安全,且最快)
@SuppressWarnings("unchecked")
List<RequestCountExcelDO>[] shardResults = new List[shardCount];
// 多线程调用 shardResults[shardIndex] = excelBatch → 线程安全!

五、项目里的完整实现(高标准逐行解析)

StatisticsSettingServiceImpl.java 第 122-214 行(逐行解析):

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
@Override
public void downloadRequestCount2ExcelConcurrent(HttpServletResponse response) {
// ---- 参数定义 ----
final int BATCH_SIZE = 100_000; // 每个分片查询的数据量
final int CORE_POOL = 8; // 核心线程数(与 CPU 核心数对齐)
final int MAX_POOL = 16; // 最大线程数

long startTime = System.currentTimeMillis();

// 【第 132-133 行】1. 查总量,计算分片数
long total = requestCountService.count();
int shardCount = (int) Math.ceil((double) total / BATCH_SIZE);
log.info("[并发导出] 总记录数: {}, 分片数: {}, 每片大小: {}", total, shardCount, BATCH_SIZE);

// 【第 139-146 行】2. 自定义线程池
// - ArrayBlockingQueue 有界队列:防止 OOM
// - CallerRunsPolicy:队列满时由调用线程自己执行,起到背压效果
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()
);

// 【第 150-153 行】3. 用 ArrayList 存各分片结果(多线程写安全?)
// ❌ 这里有坑:ArrayList 不是线程安全的!
// 虽然每个分片写不同的 shardIndex 位置,但 ArrayList 内部数组可能扩容 → 线程不安全!
// 正确做法:用 CopyOnWriteArrayList 或数组
List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount);
for (int i = 0; i < shardCount; i++) shardResults.add(null);

CountDownLatch latch = new CountDownLatch(shardCount);

// 【第 156-178 行】4. 提交分片任务
for (int i = 0; i < shardCount; i++) {
final int shardIndex = i;
executor.submit(() -> {
try {
// 【第 162-165 行】分页参数:offset = shardIndex * BATCH_SIZE,limit = BATCH_SIZE
// PageParam.offset = (pageNum - 1) * pageSize
// 所以 pageNum = shardIndex + 1
PageParam pageParam = PageParam.newPageInstance(
(long) (shardIndex + 1), // pageNum 从 1 开始
(long) BATCH_SIZE
);
// 【第 167 行】直接用 offset/limit 查询该分片数据
List<RequestCountDO> batch = requestCountService.listRequestCount(pageParam);
List<RequestCountExcelDO> excelBatch = StatisticsConverter.convertToRequestCountExcelDOList(batch);

// 【第 169 行】❌ 这里有坑:ArrayList.set() 不是线程安全的!
shardResults.set(shardIndex, excelBatch);
log.info("[并发导出] 分片 {} 完成,本片记录数: {}", shardIndex, excelBatch.size());
} catch (Exception e) {
log.error("[并发导出] 分片 {} 查询失败", shardIndex, e);

// 【第 173 行】失败时也 set 一个空列表(避免汇总时空指针)
shardResults.set(shardIndex, new ArrayList<>());
} finally {
latch.countDown(); // 【第 175 行】无论成功失败,计数器 -1
}
});
}

// 【第 182-189 行】5. 主线程阻塞等待所有分片完成(最多等 10 分钟)
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(); // 【第 190 行】关闭线程池(不再接受新任务)
}

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

long fetchCost = System.currentTimeMillis() - startTime;
log.info("[并发导出] 多线程并发查询完成,汇总记录数: {}, 耗时: {}ms", allData.size(), fetchCost);

// 【第 203-209 行】7. FastExcel 单线程写出(FastExcel 非线程安全,必须串行)
try {
FastExcel.write(response.getOutputStream(), RequestCountExcelDO.class)
.sheet("request_count全量导出")
.doWrite(allData);
} catch (IOException e) {
throw new RuntimeException("Excel写出失败", e);
}

long totalCost = System.currentTimeMillis() - startTime;
log.info("[并发导出] 全流程完成,总耗时: {}ms(查询: {}ms,写Excel: {}ms)",
totalCost, fetchCost, totalCost - fetchCost);
}

六、面试高频追问(高标准版)

Q1:为什么数据查询用多线程,但 FastExcel 写出用单线程?

  • 数据查询是 IO 密集型(等数据库返回,CPU 空闲),可以用多线程并发查询,提升吞吐量
  • FastExcel 写出底层依赖 SXSSFWorkbook非线程安全(并发写同一个 Sheet 会导致数据错乱),所以必须用单线程写出

扩展:如果要进一步提升写出速度,可以:

  1. 按分片生成多个 Excel 文件(每个线程写一个文件)
  2. 最后用 POISheet 合并 API 合并成一个文件
    (但项目里没这么做,因为 1.4 秒已经够快了)

Q2:PageParam.newPageInstance() 的分页参数是怎么计算的?

PageParam.newPageInstance(pageNum, pageSize) 的内部实现是:

1
2
// PageParam 的 offset 计算:
offset = (pageNum - 1) * pageSize;

所以:

  • 分片 0:pageNum = 0 + 1 = 1offset = (1 - 1) * 100000 = 0
  • 分片 1:pageNum = 1 + 1 = 2offset = (2 - 1) * 100000 = 100000
  • 分片 2:pageNum = 2 + 1 = 3offset = (3 - 1) * 100000 = 200000

源码第 162-165 行

1
2
3
4
PageParam pageParam = PageParam.newPageInstance(
(long) (shardIndex + 1), // pageNum = shardIndex + 1
(long) BATCH_SIZE
);

Q3:ArrayList 的线程安全问题怎么解决?

:源码里的写法有坑:shardResults.set(shardIndex, excelBatch) 虽然每个线程写不同的 shardIndex,但 ArrayList 内部数组可能扩容,导致线程不安全!

正确做法

1
2
3
4
// 做法 1:用 CopyOnWriteArrayList(推荐)
List<List<RequestCountExcelDO>> shardResults = new CopyOnWriteArrayList<>();
for (int i = 0; i < shardCount; i++) shardResults.add(null);
// 多线程调用 shardResults.set(shardIndex, excelBatch) → 线程安全!
1
2
3
4
// 做法 2:用数组(最快)
@SuppressWarnings("unchecked")
List<RequestCountExcelDO>[] shardResults = new List[shardCount];
// 多线程调用 shardResults[shardIndex] = excelBatch → 线程安全!

Q4:ArrayBlockingQueue 的背压效果是什么?

ArrayBlockingQueue有界队列,容量固定。当队列满了,新任务会被拒绝,触发拒绝策略

项目里的拒绝策略是 CallerRunsPolicy

  • 队列满时,提交任务的线程自己执行这个任务
  • 效果:提交任务的线程(比如 HTTP 处理线程)被占用 → 无法接收新请求 → 自然限速
  • 等线程池有空闲了,HTTP 线程才能继续接收新请求

这就是背压(Backpressure):消费者处理不过来时,生产者自动减速,防止系统崩溃!

Q5:CountDownLatch.await() 超时了怎么办?

:看源码第 182-189 行:

1
2
3
4
5
6
boolean finished = latch.await(10, TimeUnit.MINUTES);
if (!finished) {
log.error("[并发导出] 等待超时,部分分片未完成");
}
// 不管超时还是正常完成,都要关闭线程池
executor.shutdown();

处理方案

  1. 记录日志:哪些分片没完成?
  2. 返回部分数据:已经完成的片区数据可以先用着
  3. 前端轮询:导出任务提交后,前端轮询导出进度(比如用 WebSocket 推送进度)
  4. 大文件异步导出:不用 HTTP 同步等待,改用消息队列异步导出,导出完了通知用户下载

七、总结:并发导出的完整流程(高标准版)

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
┌─────────────────────────────────────────────────┐
│ 用户点击"导出"按钮 │
└────────────────────────────┬────────────────────┘


┌─────────────────────────────────────────────────┐
│ ① 查询总量,计算分片数 │
│ total = 100 万,shardSize = 20 万 → shardCount = 5 │
└────────────────────────────┬────────────────────┘


┌─────────────────────────────────────────────────┐
│ ② 创建线程池(8 核心线程,ArrayBlockingQueue 有界队列, │
│ CallerRunsPolicy 背压策略) │
└────────────────────────────┬────────────────────┘


┌─────────────────────────────────────────────────┐
│ ③ 提交 5 个分片任务到线程池 │
│ → 线程 1 查询分片 0(offset=0, limit=20万) │
│ → 线程 2 查询分片 1(offset=20万, limit=20万) │
│ → ... │
│ → CountDownLatch 计数器从 5 降到 0 │
└────────────────────────────┬────────────────────┘


┌─────────────────────────────────────────────────┐
│ ④ latch.await() 返回(所有分片查询完成) │
│ → 汇总所有分片数据到一个 List │
└────────────────────────────┬────────────────────┘


┌─────────────────────────────────────────────────┐
│ ⑤ 单线程 FastExcel 写出(POI 非线程安全) │
│ FastExcel.write(outputStream, RequestCountExcelDO.class) │
│ .sheet("request_count全量导出") │
│ .doWrite(allData); │
└────────────────────────────┬────────────────────┘


┌─────────────────────────────────────────────────┐
│ ⑥ 返回 Excel 文件给前端 │
└─────────────────────────────────────────────────┘

八、本文贡献的”高标准”内容(对比之前版本)

内容 之前版本 本版本(高标准)
ArrayList 线程安全问题 没提到 ❌ 指出源码里用 ArrayList 有坑,给出 3 种修复方案
PageParam.newPageInstance() 分页计算 没详细讲 ✅ 详细讲解 pageNum = shardIndex + 1 的原因
ArrayBlockingQueue 背压效果 只提了一句 ✅ 详细讲解有界队列如何防止 OOM,队列满时的背压效果
CallerRunsPolicy 背压机制 没详细讲 ✅ 详细讲解”提交任务的线程自己执行”如何实现背压
CountDownLatch.await() 超时处理 没提到 ✅ 给出 4 种处理方案(记录日志、返回部分数据、前端轮询、异步导出)
面试追问 4 道题 ✅ 增加到 5 道题,每道都有详细答案和源码依据

技术派项目学习笔记(四)· 高标准:FastExcel 并发导出与线程池(源码级深度解析)
https://whyalwaysme.lol/2026/06/08/2026-06-08-devlink-fastexcel-learn/
作者
Cassiur
发布于
2026年6月8日
许可协议