技术派面试拷打③:FastExcel + 线程池 + CountDownLatch 并发导出

技术派面试拷打③:FastExcel + 线程池 + CountDownLatch 并发导出

简历原文:「基于 FastExcel + 自定义线程池(8 线程)+ CountDownLatch 实现数据分片并发导出,将百万级数据导出耗时从 3.5s 压缩至 1.4s,性能提升约 2.5 倍;采用 ArrayBlockingQueue 有界队列 + CallerRunsPolicy 背压策略防止 OOM,FastExcel 写操作保持单线程串行保证线程安全。」

面试口径:“百万级数据导出用了分片并发方案:先查总量算分片数(5片×20万),自定义线程池(8核心线程,I/O 密集型选 2×CPU,ArrayBlockingQueue 有界队列防 OOM,CallerRunsPolicy 背压)并发执行分片 DB 查询,CountDownLatch 等所有分片完成后汇总,最后单线程 FastExcel 写出(POI 非线程安全)。实测 3.5s → 1.4s,提升 2.5 倍,瓶颈从查询转移到了写出。”


第一层:为什么要并发导出?(性能优化基础题)

Q1:不用并发,单线程导出有什么问题?瓶颈在哪?

答: 单线程导出的瓶颈在 DB 查询(I/O 阻塞),多线程可以把这个瓶颈打满。

单线程导出流程(慢):

1
2
3
4
5
6
主线程:
1. 查 DB 总量(SELECT COUNT(*) → 1 秒)
2. 分页查询第 1 页(1000 条)→ FastExcel 写出
3. 分页查询第 2 页(1000 条)→ FastExcel 写出
...(重复 1000 次)
4. 完成

问题: DB 查询是 I/O 操作(等待数据库返回),CPU 在等待期间是空闲的。单线程只能一条一条查,总耗时 = 页数 × 单页查询时间。

并发导出流程(快):

1
2
3
4
5
6
7
8
9
10
主线程:
1. 查 DB 总量(1 秒)
2. 计算分片数(比如 5 个分片,每个分片 20 万条)
3. 提交 5 个分片任务到线程池(并发执行!)
├── 分片 1(线程 1):查询第 1~20 万条
├── 分片 2(线程 2):查询第 20~40 万条
└── ...(同时进行)
4. CountDownLatch.await() 等待所有分片完成
5. 汇总所有分片数据
6. 单线程 FastExcel 写出

优势: 5 个分片的 DB 查询同时进行(只要 DB 能扛住并发查询),总耗时 ≈ 最慢的那个分片的时间(而不是所有分片时间之和)。

项目里的实测数据:

1
2
3
单线程:3.5 秒(瓶颈:DB 查询)
并发: 1.4 秒(瓶颈:FastExcel 写出)
提升: 2.5

为什么提升不是 5 倍(分片数)? 因为:

  1. 线程池不是无限大:最多 8 个线程同时跑,5 个分片可以完全并行
  2. 写出是单线程:汇总后 FastExcel 写出也要时间,这部分没优化
  3. DB 并发查询有上限:数据库的连接池、CPU、磁盘 I/O 都有上限

面试回答:

“单线程导出的瓶颈在 DB 查询(I/O 阻塞),CPU 在等待期间是空闲的。并发导出把数据分成多个分片,用多线程同时查询 DB,把 I/O 等待时间重叠起来,总耗时 ≈ 最慢的分片时间。我们项目实测从 3.5s 降到 1.4s,提升 2.5 倍。之所以不是 5 倍(分片数),是因为写出是单线程的,而且 DB 并发查询也有上限。”


Q2:为什么用 FastExcel 而不是 EasyExcel?两者有什么区别?

答: FastExcel 是 EasyExcel 的社区维护版(EasyExcel 官方已停止维护),API 完全兼容,但性能更好、Bug 更少。

EasyExcel 的问题:

  1. 官方已停止维护(2023 年后不再更新)
  2. 有一些已知 Bug(比如大文件导出时内存泄漏)
  3. 性能不是最优(FastExcel 在某些场景下比 EasyExcel 快 20~30%)

FastExcel 的优势:

维度 EasyExcel FastExcel
维护状态 ❌ 已停止维护(2023) ✅ 社区活跃维护
性能 基准 快 20~30%
内存占用 较高 优化过,更低
API 兼容性 - ✅ 完全兼容 EasyExcel
Bug 修复 ❌ 不再修复 ✅ 持续修复

项目里的依赖配置(pom.xml):

1
2
3
4
5
6
<dependency>
<groupId>cn.idev</groupId>
<artifactId>fastexcel</artifactId>
<version>${fastexcel.version}</version> <!-- 1.0.0 -->
</dependency>
<!-- 不再用 com.alibaba:easypoi 或 com.alibaba:easyexcel -->

FastExcel 的基本用法(和 EasyExcel 一模一样):

1
2
3
4
// 写 Excel(和 EasyExcel 用法完全一样)
FastExcel.write(response.getOutputStream(), RequestCountExcelDO.class)
.sheet("request_count全量导出")
.doWrite(allData);

面试回答:

“EasyExcel 官方已经停止维护了(2023 年后),有一些已知 Bug 和安全漏洞没人修。FastExcel 是社区维护的替代方案,API 完全兼容 EasyExcel(迁移成本为零),性能还更好(快 20~30%)。我们项目里直接用 FastExcel,不用改代码。”


第二层:线程池参数设计(Java 并发核心题)

Q3:线程池的 7 个参数,你项目里是怎么设计的?为什么?

答: 线程池的参数设计是面试必考,要根据任务类型(CPU 密集型 vs I/O 密集型)来选。

线程池的 7 个参数:

1
2
3
4
5
6
7
8
9
new ThreadPoolExecutor(
corePoolSize, // 1. 核心线程数(常驻线程)
maximumPoolSize, // 2. 最大线程数(紧急扩容用)
keepAliveTime, // 3. 空闲线程存活时间
unit, // 4. 时间单位
workQueue, // 5. 工作队列(存等待执行的任务)
threadFactory, // 6. 线程工厂(给线程起名字)
handler // 7. 拒绝策略(队列满了怎么办)
);

项目里的参数设计StatisticsSettingServiceImpl.java):

1
2
3
4
5
6
7
8
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8, // ① 核心线程数 = 8
16, // ② 最大线程数 = 16
60L, TimeUnit.SECONDS, // ③ 空闲线程 60 秒后回收
new ArrayBlockingQueue<>(shardCount + 1), // ④ 有界队列,长度 = 分片数 + 1
r -> new Thread(r, "excel-export-" + r.hashCode()), // ⑤ 线程名自定义(便于排查)
new ThreadPoolExecutor.CallerRunsPolicy() // ⑥ 拒绝策略:调用者自己执行
);

为什么核心线程数选 8?(关键!)

1
2
3
4
5
6
任务类型:I/O 密集型(DB 查询是 I/O 操作,等待期间 CPU 空闲)

公式:核心线程数 = CPU 核心数 × 2
I/O 密集型,CPU 等待 I/O 时可以切换去执行其他线程)

我的机器:CPU 4 核 → 4 × 2 = 8 ← 这就是 8 的由来!

如果是 CPU 密集型任务(比如复杂计算):

1
2
3
4
公式:核心线程数 = CPU 核心数 + 1
(+1 是为了防页缺失,让 CPU 永远不空闲)

4CPU → 核心线程数 = 5

面试回答:

“我们项目里的任务是 I/O 密集型(DB 查询要等待数据库返回,CPU 在等待期间是空闲的),所以核心线程数选 2 × CPU核心数(我的机器 4 核 → 8 个核心线程)。最大线程数选 16(核心 × 2),作为紧急扩容。如果是 CPU 密集型任务,核心线程数应该选 CPU核心数 + 1(比如 4 核 → 5 个线程)。”


Q4:为什么用 ArrayBlockingQueue 而不是 LinkedBlockingQueue?(必考)

答: ArrayBlockingQueue 是有界队列,可以限制队列长度,防止 OOM;LinkedBlockingQueue 默认是无界队列(长度 Integer.MAX_VALUE),可能撑爆内存。

两者对比:

维度 ArrayBlockingQueue LinkedBlockingQueue
队列长度 构造时必须指定(有界) 默认 Integer.MAX_VALUE(无界)
内存风险 ✅ 安全(可以限制队列长度) ❌ 危险(可能无限堆积任务,导致 OOM)
锁的实现 1 把锁(入队和出队用同一把锁) 2 把锁(入队锁和出队锁分离,并发度更高)
吞吐量 较低 较高(锁分离)
适用场景 需要限制并发任务数(背压) 任务量不可预估,但内存充足

为什么项目里选 ArrayBlockingQueue?(关键!)

1
2
3
4
5
6
7
8
9
10
11
12
13
导出任务是重量级任务(每个分片查询 20 万条数据,占用内存很大)

如果用 LinkedBlockingQueue(无界):
→ 线程池满了(8 个核心线程都在忙)
→ 新任务无限进入 LinkedBlockingQueue
→ 队列里堆积了 1000 个等待执行的任务
→ 每个任务占用 100MB 内存 → 1000 × 100MB = 100GB!
→ OOM!

用 ArrayBlockingQueue(有界,长度 = 分片数 + 1):
→ 队列最多存(分片数 + 1)个任务
→ 队列满了 → 触发拒绝策略(CallerRunsPolicy)
→ 调用者线程自己执行任务(背压效果,防止 OOM)

面试回答:

“ArrayBlockingQueue 是有界队列,构造时必须指定长度,可以防止任务无限堆积导致 OOM;LinkedBlockingQueue 默认是无界的(Integer.MAX_VALUE),如果任务生产速度 > 消费速度,队列会无限增长,最终 OOM。我们项目里导出任务是重量级的(每个分片查询 20 万条),用 ArrayBlockingQueue 限制队列长度 = 分片数 + 1,配合 CallerRunsPolicy 实现背压,保护系统不被压垮。”


Q5:CallerRunsPolicy 是什么?四种拒绝策略对比?(必考)

答: CallerRunsPolicy 是让提交任务的线程自己执行这个任务,起到”背压”效果(提交速度 > 处理速度时,提交方会被阻塞,自然限速)。

四种拒绝策略对比:

拒绝策略 行为 优点 缺点 适用场景
AbortPolicy(默认) 抛异常(RejectedExecutionException 失败快速感知 会抛异常,要自己处理 重要任务,不能丢
CallerRunsPolicy 让提交任务的线程自己执行 不抛异常;提交方变慢 = 自然限速(背压) 提交方线程被占用(比如 HTTP 线程) Web 场景(我们项目)
DiscardPolicy 直接丢弃新任务,不抛异常 不抛异常 任务丢了,不知道 不重要的任务(比如日志记录)
DiscardOldestPolicy 丢弃队列里最老的任务,然后重试提交 保留最新任务 老任务丢了 实时性要求高(只要最新数据)

CallerRunsPolicy 的背压机制(关键!):

1
2
3
4
5
6
7
8
9
10
11
12
场景:线程池满了(8 个核心线程都在忙)+ 队列也满了(ArrayBlockingQueue 满了)

正常流程(没有拒绝策略):
1. 主线程提交任务 9 → 队列满了 → 创建新线程(直到 maximumPoolSize)
2. 主线程提交任务 10 → 线程数 = maximumPoolSize,队列也满 → 抛异常!

CallerRunsPolicy:
1. 主线程提交任务 9 → 队列满了
2. 不抛异常!→ 主线程**自己执行**任务 9(不提交到线程池了)
3. 主线程在执行任务 9 期间,**不能提交新任务**(被阻塞了)
4. 线程池里有线程完成了任务 → 队列空出一个位置
5. 主线程提交任务 10 → 进入队列 → 成功

效果: 提交速度 > 处理速度时,提交方(主线程)会被阻塞,自然限速,保护系统。

项目里的应用场景:

1
2
3
4
5
6
// 主线程(HTTP 请求线程)提交分片任务
for (int i = 0; i < shardCount; i++) {
executor.submit(() -> queryShard(i)); // 提交到线程池
}
// 如果线程池满了 + 队列满了 → 主线程自己执行分片查询
// → 主线程被占用 → 无法接收新 HTTP 请求 → 自然限速

面试回答:

“四种拒绝策略:AbortPolicy(抛异常)、CallerRunsPolicy(提交方自己执行,背压)、DiscardPolicy(直接丢弃)、DiscardOldestPolicy(丢弃最老的任务)。我们项目选 CallerRunsPolicy,因为导出任务是 Web 场景(HTTP 请求触发),如果线程池满了,让 HTTP 线程自己执行导出任务,HTTP 线程就被占用了,无法接收新请求,自然限速,保护系统不被压垮。这是最简单的背压实现方式。”


第三层:CountDownLatch 原理(并发工具题)

Q6:CountDownLatch 的原理是什么?和 CyclicBarrier 有什么区别?(必考)

答: CountDownLatch 是倒计时门栓,一个或多个线程等待 N 个线程完成后再继续;CyclicBarrier 是集合点,N 个线程都到达集合点后再同时继续。

CountDownLatch 的工作原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 主线程:等待 5 个分片任务完成
CountDownLatch latch = new CountDownLatch(5); // 计数器 = 5

// 分片线程 1:完成 → countDown()
latch.countDown(); // 计数器 5 → 4

// 分片线程 2:完成 → countDown()
latch.countDown(); // 计数器 4 → 3

// ...(其他分片)

// 分片线程 5:完成 → countDown()
latch.countDown(); // 计数器 1 → 0 → 主线程被唤醒!

// 主线程:阻塞等待(计数器 > 0 时就一直等)
latch.await(); // 计数器 = 0 了,继续往下执行(汇总数据 + 写出 Excel)

CountDownLatch vs CyclicBarrier:

维度 CountDownLatch CyclicBarrier
核心语义 等待 N 个线程完成 N 个线程互相等待,都到了再同时继续
是否可复用 ❌ 一次性(计数器到 0 就完了) ✅ 可复用(自动重置计数器)
典型场景 主线程等子线程完成(我们项目) 多个线程互相等待,都准备好了再同时开始(比如模拟并发测试)
是否有回调 ❌ 没有 ✅ 支持(所有线程都到达后,先执行回调,再同时继续)

CyclicBarrier 的例子(帮助理解):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 3 个玩家互相等待,都准备好了再同时开始游戏
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("所有玩家都准备好了,开始游戏!");
});

// 玩家 1 线程:
barrier.await(); // 等待其他玩家(计数器 3 → 2)

// 玩家 2 线程:
barrier.await(); // 等待其他玩家(计数器 2 → 1)

// 玩家 3 线程:
barrier.await(); // 计数器 1 → 0 → 触发回调 → 3 个线程同时被唤醒,开始游戏

面试回答:

“CountDownLatch 是’等待 N 个线程完成’(倒计时门栓),CyclicBarrier 是’N 个线程互相等待,都到了再同时继续’(集合点)。我们项目里用 CountDownLatch,主线程提交 5 个分片任务后调用 await() 阻塞等待,每个分片任务完成后调用 countDown(),等 5 个分片都完成了(计数器 = 0),主线程才被唤醒,继续汇总数据。CyclicBarrier 适合’多个线程互相等待’的场景(比如并发测试),而且可以复用(自动重置计数器),CountDownLatch 是一次性的。”


Q7:CountDownLatch.await 超时了怎么处理?项目里是怎么做的?

答: await(long timeout, TimeUnit unit) 可以设置超时时间,超时后返回 false(表示”等待超时,还有任务没完成”)。

超时的处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 主线程:等待所有分片完成,最多等 10 分钟
boolean finished = latch.await(10, TimeUnit.MINUTES);

if (!finished) {
// 超时了!还有分片没完成
log.error("导出超时:部分分片未完成");

// 方案 1:取消所有还在跑的任务
executor.shutdownNow(); // 发中断信号给所有线程

// 方案 2:返回"导出失败"给前端
response.getWriter().write("导出超时,请减少数据量后重试");
}

项目里的实际代码StatisticsSettingServiceImpl.java):

1
2
3
4
5
6
7
8
9
10
11
12
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(); // 关闭线程池,释放资源
}

为什么设置超时时间?(关键!)

1
2
3
4
如果不设置超时:
→ 某个分片任务卡死了(比如 DB 连接断了,一直在等待)
→ CountDownLatch 永远等不到(计数器永远到不了 0
→ 主线程永远阻塞 → 内存泄漏(线程不释放)

更好的方案:Future.get(timeout)(推荐!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 提交任务时,记录 Future
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < shardCount; i++) {
Future<?> future = executor.submit(() -> queryShard(i));
futures.add(future);
}

// 等所有 Future 完成(带超时)
for (Future<?> future : futures) {
try {
future.get(10, TimeUnit.MINUTES); // 每个任务最多等 10 分钟
} catch (TimeoutException e) {
future.cancel(true); // 超时 → 取消任务
log.error("分片任务超时", e);
}
}

优势: 可以知道”哪个分片超时了”,而 CountDownLatch 只能知道”有任务超时了”,不知道是哪个。

面试回答:

“CountDownLatch.await 可以设置超时时间,超时后返回 false。我们项目里设置了 10 分钟超时,如果超时了,会关闭线程池并返回’导出超时’给前端。更好的方案是用 Future.get(timeout) 代替 CountDownLatch,这样可以知道’哪个分片超时了’,方便排查问题。设置超时是为了防止’某个分片卡死导致主线程永远阻塞’的内存泄漏问题。”


第四层:FastExcel 非线程安全(Excel 处理题)

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

答: 因为 FastExcel(底层是 Apache POI 的 SXSSFWorkbook)非线程安全,多线程同时写一个 Excel 文件会导致文件损坏或数据错乱

FastExcel 的底层(Apache POI):

1
2
3
4
5
6
7
8
9
// FastExcel.write() 底层创建的是 SXSSFWorkbook(流式写出)
// SXSSFWorkbook 的内部状态(当前行号、临时文件句柄等)不是线程安全的

// 线程 1:写入行 1~1000
workbook.write(row1); // 内部:当前行号 = 1,写入临时文件

// 线程 2:同时写入行 1001~2000(还没等线程 1 写完!)
workbook.write(row1001); // 内部:当前行号 = 1001(被线程 2 改了!)
// 线程 1 再写 → 行号乱了 → 文件损坏!

项目里的方案:多线程查询 + 单线程汇总 + 单线程写出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
步骤 1:多线程并发查询 DB(快!)
├── 分片 1 线程:查询第 1~20 万条 → 结果存 List1
├── 分片 2 线程:查询第 20~40 万条 → 结果存 List2
└── ...

步骤 2:主线程汇总(快!)
allData = new ArrayList<>();
allData.addAll(List1);
allData.addAll(List2);
...

步骤 3:单线程 FastExcel 写出(慢,但必须串行)
FastExcel.write(outputStream, RequestCountExcelDO.class)
.sheet("导出")
.doWrite(allData); // 只有一个线程在写,安全

如果一定要并发写 Excel,怎么办?(方案:多个文件合并)

1
2
3
4
5
6
7
8
方案:每个线程写一个临时 Excel 文件,最后合并成一个文件

线程 1:写 temp1.xlsx(1~20 万条)
线程 2:写 temp2.xlsx(20~40 万条)
...

主线程:用 POI 的 Sheet 复制,把 temp2.xlsx、temp3.xlsx... 合并到最终文件
→ 删掉临时文件

缺点: 合并 Excel 很慢,而且占用双倍磁盘空间(临时文件 + 最终文件),性价比不高,不如单线程写出。

面试回答:

“FastExcel 底层是 Apache POI 的 SXSSFWorkbook,内部状态(当前行号、临时文件句柄)不是线程安全的,多线程同时写一个 Excel 会导致文件损坏。我们项目里用’多线程查询 + 单线程写出’的方案:多个线程并发查 DB(打满 I/O 瓶颈),CountDownLatch 等所有分片完成后,主线程汇总数据,最后单线程 FastExcel 写出。如果想并发写,可以让每个线程写临时文件,最后合并,但合并很慢,性价比不高。”


Q9:FastExcel 和 Apache POI 是什么关系?为什么不用原生的 POI?

答: FastExcel(和 EasyExcel)是 Apache POI 的封装,解决了 POI 的内存溢出(OOM) 问题。

Apache POI 的问题:

1
2
3
4
5
6
7
8
9
10
// POI 原生用法(危险!)
XSSFWorkbook workbook = new XSSFWorkbook(); // 把所有数据装载到内存!
XSSFSheet sheet = workbook.createSheet("sheet1");

for (RowData row : allData) { // 100 万条数据
XSSFRow row = sheet.createRow(i);
// 问题:所有行都留在内存里,100 万行 → 几个 GB 内存!
}

workbook.write(outputStream);

问题: XSSFWorkbook将所有数据装载到内存(DOM 模式),100 万行数据可能占用几个 GB 内存,直接 OOM。

FastExcel(EasyExcel)的解决方案:SXSSF(流式写出)

1
2
3
4
5
6
7
8
9
10
11
12
13
// FastExcel 底层用的是 SXSSFWorkbook(流式写出)
// 原理:数据先写临时文件(磁盘),内存里只保留最新的 100 行(可配置)

SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 内存里只保留 100 行
// ↑ 超过 100 行的数据刷到磁盘临时文件

for (RowData row : allData) { // 100 万条数据
SXSSFRow row = sheet.createRow(i);
// 行号 > 100 时,自动刷到磁盘临时文件(内存不涨)
}

workbook.write(outputStream); // 从临时文件读取数据,写出到 Excel
workbook.dispose(); // 删掉临时文件

效果: 100 万行数据导出,内存占用稳定在 200MB 左右(不会随着数据量增长),而 POI 原生用法可能占用 5GB+

面试回答:

“Apache POI 的原生用法(XSSFWorkbook)是把所有数据装载到内存,100 万行可能占用几个 GB 内存,容易 OOM。FastExcel(和 EasyExcel)底层用的是 SXSSFWorkbook(流式写出),数据先写临时文件(磁盘),内存里只保留最新的 100 行(可配置),100 万行导出内存稳定在 200MB 左右。我们项目直接用 FastExcel API(更简洁),底层自动用 SXSSF,不用自己操心内存问题。”


第五层:分片设计与数据汇总(数据处理题)

Q10:分片(Shard)是怎么设计的?分片数怎么算?

答: 分片数 = 总记录数 ÷ 每片大小(向上取整),每个分片负责查询”一段连续的数据”。

分片设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 查总记录数
long total = requestCountService.count(); // 比如 100 万

// 2. 定义每片大小(比如 20 万条)
final int BATCH_SIZE = 200_000;

// 3. 计算分片数(向上取整)
int shardCount = (int) Math.ceil((double) total / BATCH_SIZE);
// 100 万 ÷ 20 万 = 5 片

// 4. 每个分片查询的数据范围:
// 分片 0:OFFSET 0, LIMIT 200000(第 1~20 万条)
// 分片 1:OFFSET 200000, LIMIT 200000(第 20~40 万条)
// 分片 2:OFFSET 400000, LIMIT 200000(第 40~60 万条)
// ...

每个分片的查询参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
for (int i = 0; i < shardCount; i++) {
final int shardIndex = i;
executor.submit(() -> {
// 分页参数:pageNum = shardIndex + 1,pageSize = BATCH_SIZE
PageParam pageParam = PageParam.newPageInstance(
(long) (shardIndex + 1), // pageNum(从 1 开始)
(long) BATCH_SIZE // pageSize
);
List<RequestCountDO> batch = requestCountService.listRequestCount(pageParam);
shardResults.set(shardIndex, batch); // 存到对应位置
latch.countDown();
});
}

为什么要分片?(关键!)

1
2
3
4
5
6
7
8
9
10
11
不分片(单线程分页查询):
SELECT * FROM request_count LIMIT 0, 200000; // 第 1 页(20 万条)
SELECT * FROM request_count LIMIT 200000, 200000; // 第 2 页(20 万条)
...(重复 5 次)
总耗时 = 每页查询时间之和(比如 5 × 0.7s = 3.5s)

分片(多线程并发查询):
线程 1:SELECT * FROM request_count LIMIT 0, 200000; // 第 1 页
线程 2:SELECT * FROM request_count LIMIT 200000, 200000; // 第 2 页
...(同时进行)
总耗时 ≈ 最慢的那个分片(比如 0.9s)

每片大小怎么选?(调优题)

1
2
3
4
5
6
7
8
9
10
太大(比如 50 万条/片):
→ 单个分片查询很慢(DB 查询 50 万条要 2 秒)
→ 并发度低(分片数少,线程池利用率不高)

太小(比如 1 万条/片):
→ 分片数太多(100 万 ÷ 1 万 = 100 个分片)
→ 线程池频繁上下文切换(100 个任务抢 8 个线程)
→ 汇总时 `allData.addAll(shard)` 调用次数太多

最优:20 万 ~ 50 万条/片(根据机器性能和 DB 性能调优)

面试回答:

“分片数 = 总记录数 ÷ 每片大小(向上取整),每个分片负责查询一段连续的数据(OFFSET = shardIndex × BATCH_SIZE,LIMIT = BATCH_SIZE)。分片是为了让多线程并发查询 DB,把 I/O 等待时间重叠起来。每片大小选 20 万 ~ 50 万条(根据 DB 性能调优),太大会导致单分片查询慢、并发度低;太小会导致分片数太多,线程上下文切换频繁。”


Q11:汇总数据时,为什么用 CopyOnWriteArrayList 而不是 ArrayList

答: 项目里没有用 CopyOnWriteArrayList!看源码:

1
2
3
4
5
6
7
8
9
// 项目里用的是 ArrayList(主线程写,分片线程读,但读的是不同位置!)
List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount);
for (int i = 0; i < shardCount; i++) shardResults.add(null);

// 分片线程:shardResults.set(shardIndex, batch)
// ↑ 写入指定位置(线程安全,因为不同线程写不同位置)

// 主线程:shardResults.get(i)
// ↑ 读取指定位置(分片线程已经写完了,因为 CountDownLatch.await() 保证了顺序)

为什么 ArrayList 在这里是安全的?(关键!)

1
2
3
4
5
6
1. 每个分片线程写入的是**不同位置**(shardResults.set(shardIndex, ...))
→ 没有并发写同一个位置的问题

2. CountDownLatch.await() 保证了:
→ 主线程读取 shardResults 时,所有分片线程都已经写完(set 完成)
→ 没有"主线程读到 null"的问题

那什么时候需要用 CopyOnWriteArrayList

1
2
3
4
5
6
7
8
9
10
11
12
场景:多个线程**同时读写**同一个 List(增删改查都在并发)

// ❌ ArrayList 线程不安全
List<String> list = new ArrayList<>();
// 线程 1:list.add("A")
// 线程 2:list.add("B")
// 可能丢数据(两个线程同时操作内部数组,覆盖彼此的写入)

// ✅ CopyOnWriteArrayList 线程安全
List<String> list = new CopyOnWriteArrayList<>();
// 线程 1:list.add("A") → 内部:复制一份新数组,写入后再替换引用(慢,但安全)
// 线程 2:list.add("B") → 同上

CopyOnWriteArrayList 的原理:

1
2
3
4
5
6
7
写入时:
1. 复制一份新数组(长度 = 旧数组.length + 1)
2. 在新数组里写入数据
3. 把内部引用指向新数组(volatile 保证可见性)

读取时:
→ 直接读当前引用指向的数组(不加锁,很快)

优点: 读操作非常快(不加锁)
缺点: 写操作很慢(每次都要复制数组),适合读多写少的场景(比如白名单、配置文件)。

面试回答:

“我们项目里其实没用 CopyOnWriteArrayList,用的是 ArrayList,因为:① 每个分片线程写入的是不同位置(set(shardIndex, ...)),没有并发写冲突;② CountDownLatch.await() 保证了主线程读取时所有分片都已经写完。如果要多个线程同时读写同一个 List,才需要用 CopyOnWriteArrayList(写时复制新数组,读时直接读不加锁),适合读多写少的场景(比如白名单)。”


第六层:开放设计题(架构设计)

Q12:如果导出数据量很大(比如 1 亿条),你的方案能撑住吗?会出什么问题?

答: 1 亿条数据用当前方案撑不住,会出多个问题,需要升级架构。

当前方案的问题(1 亿条时):

1
2
3
4
5
6
7
8
9
10
11
12
问题 1:DB 分页查询太慢
→ SELECT * FROM table LIMIT 90000000, 100000(偏移量 9000 万!)
→ MySQL 要遍历前 9000 万条记录,才能找到第 9000 万~9100 万条
→ 查询时间随偏移量增大而急剧增加(偏移 9000 万时可能要 10 秒+)

问题 2:汇总数据占用内存太大
1 亿条数据,每条占 200 字节 → 20GB 内存!
→ 即使分片查询,汇总时也要把所有数据放在一个 List 里 → OOM

问题 3:单线程写出太慢
1 亿条数据,FastExcel 写出可能需要 10 分钟+
→ HTTP 请求早就超时了

升级方案 V2(解决 DB 分页慢的问题):用主键分批查询代替 LIMIT offset, size

1
2
3
4
5
6
7
8
9
10
// ❌ 慢:LIMIT 90000000, 100000(偏移量太大)
SELECT * FROM request_count ORDER BY id LIMIT 90000000, 100000;

// ✅ 快: WHERE id > lastId(利用索引,恒定时间)
long lastId = 0;
while (true) {
List<RequestCountDO> batch = requestCountService.listByIdRange(lastId, BATCH_SIZE);
if (batch.isEmpty()) break;
lastId = batch.get(batch.size() - 1).getId(); // 下一批的起始 ID
}

原理: LIMIT offset, size 的 offset 越大越慢(MySQL 要遍历前 offset 条记录);WHERE id > lastId LIMIT size 利用主键索引,无论 lastId 多大,查询时间恒定(毫秒级)。

升级方案 V3(解决汇总内存太大的问题): 边查边写(流式处理),不汇总**

1
2
3
4
5
6
7
8
当前方案:
1. 多线程查询所有分片 → 汇总到一个大 List(20GB 内存)
2. 单线程 FastExcel 写出这个大 List

V3 方案(流式):
1. 多线程查询分片(不变)
2. 每个分片查询完成后,**立即写出到临时文件**(不汇总)
3. 所有分片写完后,合并临时文件成一个 Excel

升级方案 V4(解决单线程写出慢的问题):异步导出 + 进度通知

1
2
3
4
5
1. 用户点击"导出" → 立即返回"导出任务已创建,任务 ID = xxx"
2. 后端异步执行导出(多线程查询 + 写出)
3. 导出完成后:
→ 方案 A:存到服务器,用户轮询"导出状态接口"下载
→ 方案 B:通过 WebSocket 推送"导出完成"通知,附带下载链接

面试回答模板:

“1 亿条数据用当前方案撑不住,会有三个问题:① DB 分页查询太慢(LIMIT offset, size 偏移量大时很慢),要解决得用’主键分批查询’(WHERE id > lastId)代替 LIMIT;② 汇总数据占用内存太大(1 亿条可能 20GB),要解决得用’边查边写’,不汇总全量数据;③ 单线程写出太慢,要解决得用’异步导出 + 进度通知’(用户点击导出后立即返回,后端异步执行,完成后通知用户下载)。”


Q13(附加):如果让你设计一个”百万级数据导出系统”,支持并发导出、进度通知、失败重试,你会怎么设计?

答: 这是一道系统设计题,要综合考虑性能、可靠性、用户体验。

完整方案:

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
┌─────────────────────────────────────────────────────┐
│ 导出系统架构 │
├─────────────────────────────────────────────────────┤
│ │
│ 1. 用户点击"导出"
│ → 创建导出任务(status=PROCESSING) │
│ → 返回任务 ID 给前端 │
│ │
│ 2. 后端异步执行导出(@Async 或 MQ) │
│ → 多线程分片查询 DB │
│ → 进度更新:每完成一个分片,更新任务进度(10% → 50% → 100%) │
│ → 推送给前端(WebSocket 或轮询接口) │
│ │
│ 3. 导出完成 │
│ → 更新任务状态(status=SUCCESS,downloadUrl=xxx)│
│ → 通知用户(WebSocket 推送 + 站内信) │
│ │
│ 4. 导出失败 │
│ → 更新任务状态(status=FAILED,errorMsg=xxx) │
│ → 重试:最多重试 3 次(指数退避) │
│ → 3 次还失败 → 通知用户"导出失败,请重试"
│ │
│ 5. 用户下载 │
│ → GET /export/download?taskId=xxx │
│ → 返回 Excel 文件流 │
│ │
└─────────────────────────────────────────────────────┘

数据库设计(导出任务表):

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE export_task (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, -- 谁导出的
task_type VARCHAR(50) NOT NULL, -- 导出类型(用户数据、订单数据...)
status VARCHAR(20) NOT NULL, -- PROCESSING / SUCCESS / FAILED
progress INT DEFAULT 0, -- 进度(0~100)
download_url VARCHAR(500), -- 下载链接(OSS/本地文件)
error_msg TEXT, -- 失败原因
retry_count INT DEFAULT 0, -- 已重试次数
created_at DATETIME,
updated_at DATETIME,
INDEX idx_user_status (user_id, status)
);

进度通知(WebSocket 推送):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 后端:更新进度时推送
@Autowired
private SimpMessagingTemplate messagingTemplate;

public void updateProgress(Long taskId, int progress) {
ExportTask task = exportTaskService.getById(taskId);

// 推送给指定用户
messagingTemplate.convertAndSendToUser(
String.valueOf(task.getUserId()),
"/export/progress",
Map.of("taskId", taskId, "progress", progress)
);
}

// 前端:订阅进度
stompClient.subscribe("/user/export/progress", (message) => {
console.log("导出进度:" + message.body.progress + "%");
updateProgressBar(message.body.progress);
});

面试回答模板:

“百万级数据导出系统,我会这样设计:① 用户点击导出后立即返回任务 ID,后端异步执行(@Async 或 MQ),不阻塞用户操作;② 导出任务存数据库(状态、进度、下载链接),支持查询导出状态;③ 多线程分片查询 DB(用主键分批代替 LIMIT offset),边查边写临时文件(不汇总全量数据,防 OOM);④ 进度通过 WebSocket 推送给前端(/user/export/progress),用户能看到’当前进度 60%’;⑤ 导出完成后通知用户下载,失败则重试 3 次(指数退避),还失败就通知用户’导出失败,请重试’。”


总结:简历上这句话的面试回答模板

面试官:”你的简历上写了’FastExcel + 线程池 + CountDownLatch 实现并发导出,性能提升 2.5 倍’,你能详细讲一下吗?”

回答模板(背下来!):

“好的。百万级数据导出的瓶颈在 DB 查询(I/O 阻塞),单线程导出要 3.5 秒(逐页查询,总耗时是各页之和)。

分片并发方案:先查总记录数,按每片 20 万条计算分片数(比如 100 万条 = 5 个分片),用自定义线程池(8 核心线程,I/O 密集型选 2×CPU)并发执行分片查询。

线程池设计:用 ArrayBlockingQueue 有界队列(长度 = 分片数 + 1)防止 OOM,配合 CallerRunsPolicy 实现背压(线程池满了就让 HTTP 线程自己执行,自然限速)。

等待所有分片完成:用 CountDownLatch(计数器 = 分片数),主线程调 await() 阻塞等待,每个分片完成后 countDown(),等所有分片完成(计数器 = 0)才继续汇总。

单线程写出:FastExcel 底层是 Apache POI 的 SXSSFWorkbook,非线程安全,多线程同时写会文件损坏,所以汇总后单线程写出。

性能提升:3.5s → 1.4s,提升 2.5 倍,瓶颈从 DB 查询转移到了写出。如果数据量更大(比如 1 亿条),会用’主键分批查询’代替 LIMIT offset,并改成’异步导出 + 进度通知’的方案。”


全部三篇面试拷打已完成!🎉
访问你的博客查看完整内容: