背景与问题
线上接口“突然变慢”,很多时候不是代码逻辑错了,而是并发模型塌了。
我踩过一个很典型的坑:为了提升吞吐,把多个下游调用改成异步并发执行,代码看起来也很“高级”——CompletableFuture + 线程池。压测初期指标还不错,但上线后在流量高峰时,接口 RT 从几十毫秒一路飙到几秒,最后大量超时,业务方看到的就是:
- 成功率断崖式下降
- Tomcat 工作线程被占满
- 下游依赖也被连带打爆
- 监控上看像“雪崩”一样一层层扩散
这种问题最容易误判成“数据库慢”“网络抖动”或者“JVM Full GC”。但真相往往更朴素:线程池被误用,导致请求堆积、超时扩散、资源争抢,最终形成接口雪崩。
本文我从一个可运行的示例出发,带你完整走一遍:
- 如何复现线程池误用导致的故障
- 如何一步步定位
- 为什么会雪崩
- 怎么做止血和长期修复
一个常见的事故现场
比如有这样一个聚合接口:
- 一个请求进来
- 同时调用 3 个下游服务
- 最终拼装结果返回
开发时为了“并发提速”,通常会这么做:
- 每个请求提交多个异步任务到公共线程池
- 主线程
join()或get() - 默认认为线程池会自动兜底
问题就出在这里:如果请求入口线程数、下游慢调用时长、线程池大小、队列长度之间没设计清楚,线程池就会从“加速器”变成“缓慢放大器”。
现象复现
先看一个简化的故障链路。
flowchart LR
A[用户请求进入接口] --> B[接口提交多个异步任务]
B --> C[共享业务线程池]
C --> D[下游服务响应变慢]
D --> E[线程长期占用]
E --> F[线程池队列堆积]
F --> G[新请求继续提交任务]
G --> H[请求超时]
H --> I[Tomcat工作线程被阻塞]
I --> J[接口雪崩]
这个雪崩过程通常不是一瞬间发生,而是分阶段恶化:
- 下游开始变慢
- 线程池活跃线程打满
- 队列迅速堆积
- 请求线程等待异步结果,自己也被拖住
- 上游重试、流量放大
- 整体系统进入超时风暴
核心原理
线程池误用导致雪崩,本质上是有限并发资源被慢任务长期占用,且没有及时失败或限流。
1. 线程池不是无限吞吐器
一个线程池一般由几部分构成:
corePoolSize:核心线程数maximumPoolSize:最大线程数workQueue:任务队列RejectedExecutionHandler:拒绝策略
很多人只盯着线程数,却忽略了队列长度才是“延迟放大器”。
如果你配置成:
- 线程数不大
- 队列非常大,甚至无界队列
那表面上“不会拒绝任务”,实际上是在偷偷积压请求。结果就是:
- 不报错
- 不拒绝
- 但 RT 越来越长
- 最后统一超时
这比直接失败更危险。
2. 请求线程等待异步结果,会形成“双重占用”
很多聚合接口的写法是:
- Web 容器线程收到请求
- 把任务丢到业务线程池
- 再
future.get()等结果
这意味着一个请求会同时占用:
- 一个 Web 线程
- 若干个业务线程
一旦下游变慢,请求线程和业务线程就会一起堆积,资源消耗成倍上升。
3. 共享线程池是事故放大器
另一个常见坑是:多个业务共用一个线程池。
看起来省事,但后果很明显:
- A 接口突发流量
- 把线程池打满
- B 接口明明没问题,也拿不到线程
- 故障从局部扩散到全站
这就是典型的资源隔离缺失。
4. 无超时、无熔断、无背压,雪崩几乎必然发生
如果代码里是这种思路:
- 下游调用没有明确超时
- 线程池队列无限堆
- 被拒绝后也没有快速降级
- 上游还自动重试
那系统迟早会从“慢一点”走向“全面不可用”。
雪崩的时间线
sequenceDiagram
participant U as User
participant W as Web线程
participant P as 业务线程池
participant D as 下游服务
U->>W: 发起请求
W->>P: 提交异步任务A/B/C
P->>D: 调用下游
D-->>P: 响应变慢
W->>P: 等待future结果
Note over P: 活跃线程打满,队列堆积
U->>W: 更多请求到达
W->>P: 继续提交任务
Note over W,P: 请求线程与业务线程同时被占用
D-->>P: 部分超时
P-->>W: 返回超时/拒绝
W-->>U: 接口超时,大量失败
实战代码(可运行)
下面用一个纯 Java 示例复现“线程池误用”与“改造后”的差异。你可以直接运行。
1. 故障示例:无界堆积 + 请求线程阻塞等待
这个例子模拟一个聚合接口。每次请求会并发调用 3 个慢下游,每个下游耗时 200ms。我们故意使用一个配置不合理的线程池,并让很多请求同时打进来。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class BadThreadPoolDemo {
// 典型误用:线程少 + 大队列 + 请求线程阻塞等待
private static final ThreadPoolExecutor BIZ_POOL = new ThreadPoolExecutor(
8,
8,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10000),
new NamedThreadFactory("biz-bad"),
new ThreadPoolExecutor.AbortPolicy()
);
public static void main(String[] args) throws Exception {
int requestCount = 200;
ExecutorService webPool = Executors.newFixedThreadPool(50);
long begin = System.currentTimeMillis();
List<Future<String>> results = new ArrayList<>();
for (int i = 0; i < requestCount; i++) {
int reqId = i;
results.add(webPool.submit(() -> handleRequest(reqId)));
}
int success = 0;
int failed = 0;
for (Future<String> future : results) {
try {
future.get(5, TimeUnit.SECONDS);
success++;
} catch (Exception e) {
failed++;
}
}
long cost = System.currentTimeMillis() - begin;
System.out.println("total cost(ms): " + cost);
System.out.println("success: " + success + ", failed: " + failed);
printPoolStats();
webPool.shutdownNow();
BIZ_POOL.shutdownNow();
}
private static String handleRequest(int reqId) {
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> remoteCall("A", reqId), BIZ_POOL);
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> remoteCall("B", reqId), BIZ_POOL);
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> remoteCall("C", reqId), BIZ_POOL);
// 误区:请求线程在这里阻塞等待
return f1.join() + f2.join() + f3.join();
}
private static String remoteCall(String service, int reqId) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return service + "-" + reqId;
}
private static void printPoolStats() {
System.out.println("poolSize=" + BIZ_POOL.getPoolSize());
System.out.println("activeCount=" + BIZ_POOL.getActiveCount());
System.out.println("queueSize=" + BIZ_POOL.getQueue().size());
System.out.println("completedTaskCount=" + BIZ_POOL.getCompletedTaskCount());
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger counter = new AtomicInteger(1);
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable r) {
return new Thread(r, prefix + "-" + counter.getAndIncrement());
}
}
}
这个示例的问题在哪?
一个请求会提交 3 个任务。
200 个请求就是 600 个任务。
但线程池只有 8 个线程,意味着:
- 8 个任务执行中
- 其余大量任务进队列
- Web 线程还在
join()等待 - 请求越来越多时,请求线程也被拖死
这就是很真实的事故模型。
2. 改进示例:限时、隔离、快速失败
下面是一个更稳妥的改造版本:
- 业务线程池容量受控
- 队列较小,防止无限堆积
- 每个下游调用有超时
- 使用
CallerRunsPolicy或显式降级策略时要谨慎 - 出现超时直接返回降级结果
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class GoodThreadPoolDemo {
private static final ThreadPoolExecutor ORDER_QUERY_POOL = new ThreadPoolExecutor(
16,
16,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("order-query"),
new ThreadPoolExecutor.AbortPolicy()
);
public static void main(String[] args) throws Exception {
int requestCount = 200;
ExecutorService webPool = Executors.newFixedThreadPool(50);
long begin = System.currentTimeMillis();
List<Future<String>> results = new ArrayList<>();
for (int i = 0; i < requestCount; i++) {
int reqId = i;
results.add(webPool.submit(() -> safeHandleRequest(reqId)));
}
int success = 0;
int failed = 0;
for (Future<String> future : results) {
try {
future.get(3, TimeUnit.SECONDS);
success++;
} catch (Exception e) {
failed++;
}
}
long cost = System.currentTimeMillis() - begin;
System.out.println("total cost(ms): " + cost);
System.out.println("success: " + success + ", failed: " + failed);
printPoolStats();
webPool.shutdownNow();
ORDER_QUERY_POOL.shutdownNow();
}
private static String safeHandleRequest(int reqId) {
try {
CompletableFuture<String> f1 = asyncWithTimeout(() -> remoteCall("A", reqId), 300);
CompletableFuture<String> f2 = asyncWithTimeout(() -> remoteCall("B", reqId), 300);
CompletableFuture<String> f3 = asyncWithTimeout(() -> remoteCall("C", reqId), 300);
return CompletableFuture.allOf(f1, f2, f3)
.thenApply(v -> f1.join() + f2.join() + f3.join())
.exceptionally(ex -> "fallback-" + reqId)
.join();
} catch (RejectedExecutionException e) {
return "rejected-fallback-" + reqId;
}
}
private static CompletableFuture<String> asyncWithTimeout(Callable<String> task, long timeoutMs) {
return CompletableFuture.supplyAsync(() -> {
FutureTask<String> futureTask = new FutureTask<>(task);
futureTask.run();
try {
return futureTask.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (Exception e) {
futureTask.cancel(true);
throw new CompletionException(e);
}
}, ORDER_QUERY_POOL);
}
private static String remoteCall(String service, int reqId) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted-" + service + "-" + reqId;
}
return service + "-" + reqId;
}
private static void printPoolStats() {
System.out.println("poolSize=" + ORDER_QUERY_POOL.getPoolSize());
System.out.println("activeCount=" + ORDER_QUERY_POOL.getActiveCount());
System.out.println("queueSize=" + ORDER_QUERY_POOL.getQueue().size());
System.out.println("completedTaskCount=" + ORDER_QUERY_POOL.getCompletedTaskCount());
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger counter = new AtomicInteger(1);
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable r) {
return new Thread(r, prefix + "-" + counter.getAndIncrement());
}
}
}
说明:这个示例重点是演示“线程池治理思路”,真实项目中更推荐结合 HTTP 客户端自身超时、熔断组件、隔离舱模型来做,而不是只靠
FutureTask包装。
定位路径
线上出现接口雪崩时,我通常按下面这个顺序排。
第一步:先看是不是“慢请求堆积”
关注这些指标:
- 接口 RT p95 / p99
- 超时数
- Tomcat / Undertow 工作线程活跃数
- 线程池活跃线程数
- 线程池队列长度
- 拒绝任务数
- 下游依赖 RT 和超时率
如果你看到:
- 线程池
activeCount接近上限 - 队列持续增长
- 接口超时同步增长
那基本就能确认方向了。
第二步:抓线程栈
用 jstack 看线程状态很有效。
重点看两类线程:
- Web 请求线程
- 是否卡在
CompletableFuture.join()/Future.get()
- 是否卡在
- 业务线程池线程
- 是否卡在网络调用、数据库查询、锁等待
典型信号如下:
- 大量
WAITING/TIMED_WAITING - 请求线程在等异步结果
- 异步线程在等下游响应
这说明不是简单 CPU 打满,而是阻塞等待链路过长。
第三步:确认线程池配置
排查线程池时,不要只看“线程数是多少”,而要完整看:
- 谁在用这个线程池
- 每个请求会提交多少任务
- 任务平均耗时多长
- 队列是否过大
- 拒绝策略是什么
- 是否有监控
很多项目的问题是:
- 线程池是公共 Bean
- 所有业务都往里塞
- 队列还特别大
- 线上没有拒绝数监控
这就是典型隐患。
一个实用排查流程图
flowchart TD
A[接口RT飙升/大量超时] --> B{CPU是否打满?}
B -- 否 --> C[查看线程池活跃数与队列长度]
B -- 是 --> D[先排查热点代码/GC/死循环]
C --> E{线程池是否持续满载?}
E -- 是 --> F[抓jstack看线程在等什么]
E -- 否 --> G[检查数据库/网络/下游依赖]
F --> H{大量线程阻塞在Future.get或join?}
H -- 是 --> I[确认异步任务是否依赖慢下游]
H -- 否 --> J[检查锁竞争/连接池耗尽]
I --> K[修复线程池配置+加超时+隔离降级]
常见坑与排查
这一部分我尽量写得接地气一点,很多都是事故里反复出现的。
坑 1:用了无界队列,以为很稳定
比如:
Executors.newFixedThreadPool(20)
背后默认是无界 LinkedBlockingQueue。
短期看“不丢任务”,长期看就是“慢慢把延迟堆炸”。
排查特征
- 没有明显报错
- 线程池线程数很稳定
- 队列长度越来越长
- RT 越来越差
建议
- 不要迷信
Executors快捷工厂 - 显式使用
ThreadPoolExecutor - 给队列设置上限
坑 2:业务线程池和请求线程池形成互相等待
例如:
- Web 线程提交异步任务
- 再同步等待结果
- 异步任务执行依赖慢下游
- Web 线程迟迟不释放
这会造成“双重阻塞”。
排查特征
- Tomcat 线程数持续高位
- 业务线程池也满
- 两边同时告警
建议
- 减少“伪异步”写法
- 能同步算清楚容量时,不一定要异步
- 如果必须异步,确保有超时和隔离
坑 3:共享线程池污染全局
看起来是 A 接口故障,最后 B、C、D 一起挂。
排查特征
- 不同业务的错误同时上升
- 共享线程池活跃数拉满
- 某个热点接口请求量异常高
建议
- 按业务、按下游做隔离池
- 高风险依赖单独隔离
- 聚合接口不要与核心写链路共池
坑 4:拒绝策略选错
有些人为了“不丢任务”,把拒绝策略设成:
new ThreadPoolExecutor.CallerRunsPolicy()
它的效果是:线程池满了,提交任务的线程自己执行。
在某些后台消费场景可接受,但在 Web 请求场景里可能很危险,因为:
- 请求线程本来应该尽快返回
- 结果它被迫去执行慢任务
- 整体 RT 更差
建议
- 面向接口请求时,优先考虑快速失败或降级
CallerRunsPolicy不是万能兜底
坑 5:只配线程池,不配调用超时
线程池只是“装任务的地方”,不是“解决慢调用的地方”。
如果下游调用没有超时:
- 线程会一直卡着
- 再大的线程池也会被耗尽
建议
下游调用必须分层设置超时:
- 连接超时
- 读超时
- 总超时
安全/性能最佳实践
这里给一套我更推荐的落地原则,不求最炫,但求线上稳。
1. 线程池参数要按任务类型设计
先区分任务类型:
- CPU 密集型:线程数接近 CPU 核数
- IO 密集型:线程数可以更高,但要根据平均阻塞时间估算
如果是远程调用型任务,重点不是一味加线程,而是平衡:
- 平均耗时
- 峰值并发
- 可接受排队时长
- 下游承载能力
一个简化估算思路:
所需线程数 ≈ 峰值QPS × 平均任务耗时(秒)× 每请求异步任务数
这个值不是最终配置,只是帮助你建立容量意识。
2. 队列要小而明确
我的经验是:
- 队列过大:容易拖出长尾,问题隐藏更深
- 队列过小:可能频繁拒绝,需要配合降级
对接口型场景,通常宁愿:
- 小队列
- 早拒绝
- 快降级
也不要让请求排队几秒后再超时。
3. 按业务隔离线程池
至少做到这几层隔离之一:
- 按接口类型隔离
- 按下游依赖隔离
- 按读写链路隔离
特别是这些场景一定要单独隔离:
- 不稳定第三方接口
- 耗时不确定的批量查询
- 非核心降级业务
4. 超时、熔断、限流要一起上
只配线程池不够,至少还需要:
- 超时:慢调用必须及时终止
- 熔断:下游持续异常时快速失败
- 限流:高峰期保护自己和下游
- 降级:返回缓存、默认值、部分结果
这是抗雪崩的完整闭环。
5. 监控指标一定要补齐
线程池最少监控这些:
- 当前线程数
- 活跃线程数
- 队列长度
- 任务完成数
- 拒绝数
- 平均执行时间
- 最大执行时间
如果能打到 Prometheus / Micrometer 就更好了。
没有监控,很多线程池问题只能靠事故后复盘。
推荐的治理结构
classDiagram
class WebController {
+handleRequest()
}
class AggregationService {
+queryAll()
}
class IsolatedExecutorA {
+submit()
}
class IsolatedExecutorB {
+submit()
}
class DownstreamA {
+call()
}
class DownstreamB {
+call()
}
class FallbackHandler {
+fallback()
}
WebController --> AggregationService
AggregationService --> IsolatedExecutorA
AggregationService --> IsolatedExecutorB
IsolatedExecutorA --> DownstreamA
IsolatedExecutorB --> DownstreamB
AggregationService --> FallbackHandler
这个结构的重点是:不要让所有下游共享一个“超级线程池”。
止血方案
如果你现在已经在线上遇到雪崩,优先级不是“写出更优雅的并发代码”,而是先止血。
临时止血的顺序
1. 先限流
先把流量打下来,避免堆积继续扩大。
2. 缩短超时
如果下游慢调用拖得太久,先把超时收紧,减少线程占用时长。
3. 关闭非核心异步分支
聚合接口里如果有“可有可无”的附加信息,先降级掉。
4. 拆共享线程池
哪怕先临时复制几个线程池,把高风险依赖隔离出去,也比共池扩散强。
5. 观察拒绝数和成功率
止血阶段不要只看报错数,更要看:
- RT 是否回落
- 队列是否清空
- 拒绝是否在可控范围
- 成功率是否恢复
边界条件:不是所有问题都该怪线程池
这点也很重要。
如果你看到接口雪崩,不代表根因一定在线程池。还可能是:
- 数据库连接池耗尽
- Redis 连接阻塞
- 某个锁竞争严重
- Full GC 导致停顿
- 下游服务本身已故障
- 网络抖动导致大量超时
线程池往往是“症状放大器”,但未必是“第一推动力”。
所以排查时一定要把:
- 线程池
- 连接池
- 下游依赖
- JVM 状态
结合起来看。
总结
线程池误用引发的接口雪崩,最容易出现在这类场景:
- 聚合接口并发调用多个下游
- 请求线程同步等待异步结果
- 共享线程池没有隔离
- 队列过大
- 下游无超时、无熔断、无降级
真正的修复思路不是“把线程数调大”,而是建立一套完整的并发治理模型:
- 线程池显式配置,不用默认工厂
- 小队列,避免无界堆积
- 按业务/下游隔离线程池
- 每个远程调用必须有超时
- 拒绝后快速失败或降级
- 补齐线程池监控
- 压测时模拟慢下游,而不是只测理想路径
如果你只能记住一句话,那就是:
对接口型服务来说,最危险的不是“失败得太快”,而是“排队太久才失败”。
早点拒绝、及时降级,往往比“硬扛住所有请求”更能保护系统。