技术派面试拷打③: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 | |
问题: DB 查询是 I/O 操作(等待数据库返回),CPU 在等待期间是空闲的。单线程只能一条一条查,总耗时 = 页数 × 单页查询时间。
并发导出流程(快):
1 | |
优势: 5 个分片的 DB 查询同时进行(只要 DB 能扛住并发查询),总耗时 ≈ 最慢的那个分片的时间(而不是所有分片时间之和)。
项目里的实测数据:
1 | |
为什么提升不是 5 倍(分片数)? 因为:
- 线程池不是无限大:最多 8 个线程同时跑,5 个分片可以完全并行
- 写出是单线程:汇总后 FastExcel 写出也要时间,这部分没优化
- 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 的问题:
- 官方已停止维护(2023 年后不再更新)
- 有一些已知 Bug(比如大文件导出时内存泄漏)
- 性能不是最优(FastExcel 在某些场景下比 EasyExcel 快 20~30%)
FastExcel 的优势:
| 维度 | EasyExcel | FastExcel |
|---|---|---|
| 维护状态 | ❌ 已停止维护(2023) | ✅ 社区活跃维护 |
| 性能 | 基准 | 快 20~30% |
| 内存占用 | 较高 | 优化过,更低 |
| API 兼容性 | - | ✅ 完全兼容 EasyExcel |
| Bug 修复 | ❌ 不再修复 | ✅ 持续修复 |
项目里的依赖配置(pom.xml):
1 | |
FastExcel 的基本用法(和 EasyExcel 一模一样):
1 | |
面试回答:
“EasyExcel 官方已经停止维护了(2023 年后),有一些已知 Bug 和安全漏洞没人修。FastExcel 是社区维护的替代方案,API 完全兼容 EasyExcel(迁移成本为零),性能还更好(快 20~30%)。我们项目里直接用 FastExcel,不用改代码。”
第二层:线程池参数设计(Java 并发核心题)
Q3:线程池的 7 个参数,你项目里是怎么设计的?为什么?
答: 线程池的参数设计是面试必考,要根据任务类型(CPU 密集型 vs I/O 密集型)来选。
线程池的 7 个参数:
1 | |
项目里的参数设计(StatisticsSettingServiceImpl.java):
1 | |
为什么核心线程数选 8?(关键!)
1 | |
如果是 CPU 密集型任务(比如复杂计算):
1 | |
面试回答:
“我们项目里的任务是 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 | |
面试回答:
“ArrayBlockingQueue 是有界队列,构造时必须指定长度,可以防止任务无限堆积导致 OOM;LinkedBlockingQueue 默认是无界的(
Integer.MAX_VALUE),如果任务生产速度 > 消费速度,队列会无限增长,最终 OOM。我们项目里导出任务是重量级的(每个分片查询 20 万条),用 ArrayBlockingQueue 限制队列长度 = 分片数 + 1,配合 CallerRunsPolicy 实现背压,保护系统不被压垮。”
Q5:CallerRunsPolicy 是什么?四种拒绝策略对比?(必考)
答: CallerRunsPolicy 是让提交任务的线程自己执行这个任务,起到”背压”效果(提交速度 > 处理速度时,提交方会被阻塞,自然限速)。
四种拒绝策略对比:
| 拒绝策略 | 行为 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| AbortPolicy(默认) | 抛异常(RejectedExecutionException) |
失败快速感知 | 会抛异常,要自己处理 | 重要任务,不能丢 |
| CallerRunsPolicy ✅ | 让提交任务的线程自己执行 | 不抛异常;提交方变慢 = 自然限速(背压) | 提交方线程被占用(比如 HTTP 线程) | Web 场景(我们项目) |
| DiscardPolicy | 直接丢弃新任务,不抛异常 | 不抛异常 | 任务丢了,不知道 | 不重要的任务(比如日志记录) |
| DiscardOldestPolicy | 丢弃队列里最老的任务,然后重试提交 | 保留最新任务 | 老任务丢了 | 实时性要求高(只要最新数据) |
CallerRunsPolicy 的背压机制(关键!):
1 | |
效果: 提交速度 > 处理速度时,提交方(主线程)会被阻塞,自然限速,保护系统。
项目里的应用场景:
1 | |
面试回答:
“四种拒绝策略:AbortPolicy(抛异常)、CallerRunsPolicy(提交方自己执行,背压)、DiscardPolicy(直接丢弃)、DiscardOldestPolicy(丢弃最老的任务)。我们项目选 CallerRunsPolicy,因为导出任务是 Web 场景(HTTP 请求触发),如果线程池满了,让 HTTP 线程自己执行导出任务,HTTP 线程就被占用了,无法接收新请求,自然限速,保护系统不被压垮。这是最简单的背压实现方式。”
第三层:CountDownLatch 原理(并发工具题)
Q6:CountDownLatch 的原理是什么?和 CyclicBarrier 有什么区别?(必考)
答: CountDownLatch 是倒计时门栓,一个或多个线程等待 N 个线程完成后再继续;CyclicBarrier 是集合点,N 个线程都到达集合点后再同时继续。
CountDownLatch 的工作原理:
1 | |
CountDownLatch vs CyclicBarrier:
| 维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 核心语义 | 等待 N 个线程完成 | N 个线程互相等待,都到了再同时继续 |
| 是否可复用 | ❌ 一次性(计数器到 0 就完了) | ✅ 可复用(自动重置计数器) |
| 典型场景 | 主线程等子线程完成(我们项目) | 多个线程互相等待,都准备好了再同时开始(比如模拟并发测试) |
| 是否有回调 | ❌ 没有 | ✅ 支持(所有线程都到达后,先执行回调,再同时继续) |
CyclicBarrier 的例子(帮助理解):
1 | |
面试回答:
“CountDownLatch 是’等待 N 个线程完成’(倒计时门栓),CyclicBarrier 是’N 个线程互相等待,都到了再同时继续’(集合点)。我们项目里用 CountDownLatch,主线程提交 5 个分片任务后调用
await()阻塞等待,每个分片任务完成后调用countDown(),等 5 个分片都完成了(计数器 = 0),主线程才被唤醒,继续汇总数据。CyclicBarrier 适合’多个线程互相等待’的场景(比如并发测试),而且可以复用(自动重置计数器),CountDownLatch 是一次性的。”
Q7:CountDownLatch.await 超时了怎么处理?项目里是怎么做的?
答: await(long timeout, TimeUnit unit) 可以设置超时时间,超时后返回 false(表示”等待超时,还有任务没完成”)。
超时的处理逻辑:
1 | |
项目里的实际代码(StatisticsSettingServiceImpl.java):
1 | |
为什么设置超时时间?(关键!)
1 | |
更好的方案:Future.get(timeout)(推荐!)
1 | |
优势: 可以知道”哪个分片超时了”,而 CountDownLatch 只能知道”有任务超时了”,不知道是哪个。
面试回答:
“CountDownLatch.await 可以设置超时时间,超时后返回 false。我们项目里设置了 10 分钟超时,如果超时了,会关闭线程池并返回’导出超时’给前端。更好的方案是用
Future.get(timeout)代替 CountDownLatch,这样可以知道’哪个分片超时了’,方便排查问题。设置超时是为了防止’某个分片卡死导致主线程永远阻塞’的内存泄漏问题。”
第四层:FastExcel 非线程安全(Excel 处理题)
Q8:为什么数据查询用多线程,但 FastExcel 写入用单线程?
答: 因为 FastExcel(底层是 Apache POI 的 SXSSFWorkbook)非线程安全,多线程同时写一个 Excel 文件会导致文件损坏或数据错乱。
FastExcel 的底层(Apache POI):
1 | |
项目里的方案:多线程查询 + 单线程汇总 + 单线程写出
1 | |
如果一定要并发写 Excel,怎么办?(方案:多个文件合并)
1 | |
缺点: 合并 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 | |
问题: XSSFWorkbook 是将所有数据装载到内存(DOM 模式),100 万行数据可能占用几个 GB 内存,直接 OOM。
FastExcel(EasyExcel)的解决方案:SXSSF(流式写出)
1 | |
效果: 100 万行数据导出,内存占用稳定在 200MB 左右(不会随着数据量增长),而 POI 原生用法可能占用 5GB+。
面试回答:
“Apache POI 的原生用法(
XSSFWorkbook)是把所有数据装载到内存,100 万行可能占用几个 GB 内存,容易 OOM。FastExcel(和 EasyExcel)底层用的是SXSSFWorkbook(流式写出),数据先写临时文件(磁盘),内存里只保留最新的 100 行(可配置),100 万行导出内存稳定在 200MB 左右。我们项目直接用 FastExcel API(更简洁),底层自动用 SXSSF,不用自己操心内存问题。”
第五层:分片设计与数据汇总(数据处理题)
Q10:分片(Shard)是怎么设计的?分片数怎么算?
答: 分片数 = 总记录数 ÷ 每片大小(向上取整),每个分片负责查询”一段连续的数据”。
分片设计:
1 | |
每个分片的查询参数:
1 | |
为什么要分片?(关键!)
1 | |
每片大小怎么选?(调优题)
1 | |
面试回答:
“分片数 = 总记录数 ÷ 每片大小(向上取整),每个分片负责查询一段连续的数据(OFFSET = shardIndex × BATCH_SIZE,LIMIT = BATCH_SIZE)。分片是为了让多线程并发查询 DB,把 I/O 等待时间重叠起来。每片大小选 20 万 ~ 50 万条(根据 DB 性能调优),太大会导致单分片查询慢、并发度低;太小会导致分片数太多,线程上下文切换频繁。”
Q11:汇总数据时,为什么用 CopyOnWriteArrayList 而不是 ArrayList?
答: 项目里没有用 CopyOnWriteArrayList!看源码:
1 | |
为什么 ArrayList 在这里是安全的?(关键!)
1 | |
那什么时候需要用 CopyOnWriteArrayList?
1 | |
CopyOnWriteArrayList 的原理:
1 | |
优点: 读操作非常快(不加锁)
缺点: 写操作很慢(每次都要复制数组),适合读多写少的场景(比如白名单、配置文件)。
面试回答:
“我们项目里其实没用
CopyOnWriteArrayList,用的是ArrayList,因为:① 每个分片线程写入的是不同位置(set(shardIndex, ...)),没有并发写冲突;②CountDownLatch.await()保证了主线程读取时所有分片都已经写完。如果要多个线程同时读写同一个 List,才需要用CopyOnWriteArrayList(写时复制新数组,读时直接读不加锁),适合读多写少的场景(比如白名单)。”
第六层:开放设计题(架构设计)
Q12:如果导出数据量很大(比如 1 亿条),你的方案能撑住吗?会出什么问题?
答: 1 亿条数据用当前方案撑不住,会出多个问题,需要升级架构。
当前方案的问题(1 亿条时):
1 | |
升级方案 V2(解决 DB 分页慢的问题):用主键分批查询代替 LIMIT offset, size
1 | |
原理: LIMIT offset, size 的 offset 越大越慢(MySQL 要遍历前 offset 条记录);WHERE id > lastId LIMIT size 利用主键索引,无论 lastId 多大,查询时间恒定(毫秒级)。
升级方案 V3(解决汇总内存太大的问题): 边查边写(流式处理),不汇总**
1 | |
升级方案 V4(解决单线程写出慢的问题): 用 异步导出 + 进度通知
1 | |
面试回答模板:
“1 亿条数据用当前方案撑不住,会有三个问题:① DB 分页查询太慢(
LIMIT offset, size偏移量大时很慢),要解决得用’主键分批查询’(WHERE id > lastId)代替 LIMIT;② 汇总数据占用内存太大(1 亿条可能 20GB),要解决得用’边查边写’,不汇总全量数据;③ 单线程写出太慢,要解决得用’异步导出 + 进度通知’(用户点击导出后立即返回,后端异步执行,完成后通知用户下载)。”
Q13(附加):如果让你设计一个”百万级数据导出系统”,支持并发导出、进度通知、失败重试,你会怎么设计?
答: 这是一道系统设计题,要综合考虑性能、可靠性、用户体验。
完整方案:
1 | |
数据库设计(导出任务表):
1 | |
进度通知(WebSocket 推送):
1 | |
面试回答模板:
“百万级数据导出系统,我会这样设计:① 用户点击导出后立即返回任务 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,并改成’异步导出 + 进度通知’的方案。”
全部三篇面试拷打已完成!🎉
访问你的博客查看完整内容:
- ① RabbitMQ 异步解耦:https://whyalwaysme.lol/2026/06/08/2026-06-08-devlink-rabbitmq-qa/
- ② 策略模式 + WebSocket:https://whyalwaysme.lol/2026/06/08/2026-06-08-devlink-strategy-websocket-qa/
- ③ FastExcel 并发导出:https://whyalwaysme.lol/2026/06/08/2026-06-08-devlink-fastexcel-qa/