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

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

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

证据更新(2026-09-29):仓库中有并发分片查询与单线程 FastExcel 写出的实现,但没有连接真实数据库、走生产导出路径的可复现百万行端到端基准。旧版 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 能扛住并发查询),总耗时 ≈ 最慢的那个分片的时间(而不是所有分片时间之和)。

验证情况:并发设计可通过模拟延迟测试说明,但真实数据库与 Excel 输出的端到端耗时尚未测量。
为什么提升不是 5 倍(分片数)? 因为:

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

面试回答:

“单线程导出的瓶颈在 DB 查询(I/O 阻塞),CPU 在等待期间是空闲的。并发导出把数据分成多个分片,用多线程同时查询 DB,把 I/O 等待时间重叠起来,总耗时 ≈ 最慢的分片时间。目前没有可复核的真实数据库端到端耗时数据。之所以不是 5 倍(分片数),是因为写出是单线程的,而且 DB 并发查询也有上限。”


Q2:为什么项目里使用 FastExcel?

答: 当前仓库的 pom.xml 声明了 cn.idev:fastexcel:1.0.0,导出代码使用 FastExcel.write(...).sheet(...).doWrite(...)。选型可以讲 API 和项目实现,但“比 EasyExcel 快 20~30%”“内存更低”“零成本迁移”等说法需要同数据量、同环境的基准和兼容性验证;本项目尚无这类记录。

面试回答:

“项目代码使用 FastExcel 写出 Excel;并发只用于分片查询,写出仍在单线程完成。我能解释当前依赖和调用路径。库之间的性能与兼容性差异,需按实际导出场景测试后再下结论。”


第二层:线程池参数设计(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 永远不空闲)

4 核 CPU → 核心线程数 = 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:为什么数据查询用多线程,但 Excel 写入用单线程?

答: 仓库里的导出路径先把分片查询任务提交到线程池,等待完成后汇总结果,再由一个调用链执行 FastExcel.write(...).sheet(...).doWrite(allData)。这保证写入顺序由单个调用点控制。当前实现还会把分片结果汇总成 allData,内存用量随结果集增长;不能声称“百万行内存恒定”或已验证生产环境性能。

面试回答:

“我把并发放在查询阶段,写文件阶段由一个调用点完成。这样做减少了并发写同一工作簿的协调问题。代价是结果需要在内存中汇总,下一步应做真实数据的内存和端到端压测。”


Q9:FastExcel 与底层 Excel 写入机制有什么关系?

答: 项目直接依赖 FastExcel,并使用其公开写入 API。本仓库没有记录库内部实现的版本核查,也没有 FastExcel 与 Apache POI、EasyExcel 的对比实验。需要讨论底层流式机制时,应基于当前依赖的源码和具体版本说明,不能拿示意代码或未经运行的内存数字作为本项目结果。


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 实现并发导出,性能数据如何验证’,你能详细讲一下吗?”

回答模板(背下来!):

“好的。百万级数据导出的瓶颈在 DB 查询(I/O 阻塞),单线程导出要 串行分页查询的耗时由数据库与数据量决定。

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

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

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

单线程写出:当前实现汇总后由一个调用点执行 FastExcel 写出;尚未对并发写同一工作簿做兼容性验证。

性能提升:尚无可复核的生产路径基准,瓶颈从 DB 查询转移到了写出。如果数据量更大(比如 1 亿条),会用’主键分批查询’代替 LIMIT offset,并改成’异步导出 + 进度通知’的方案。”


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


技术派面试拷打③:FastExcel + 线程池 + CountDownLatch 并发导出
https://whyalwaysme.lol/2026/06/08/2026-06-08-devlink-fastexcel-qa/
作者
Cassiur
发布于
2026年6月8日
许可协议