一次线程池事故复盘:一个 Dubbo 接口拖垮整个服务
一次线程池事故复盘:一个 Dubbo 接口拖垮整个服务
线上出过一次事故,一个对外 Dubbo 接口因为底层依赖变慢,最后把整个服务搞挂了。事故本身不复杂,每一环单独看都不算大问题,但叠在一起就炸了。记下来,主要是想把传导过程讲清楚,顺便梳理一下哪些是可复用的教训。
目录
1. 事故概览
现象
- 一个提供 Dubbo 接口的服务,平时 RT 在 50ms 以内。
- 某天开始,这个接口的 RT 突然飙升,Dubbo Provider 线程池被打满。
- 几分钟内,这个服务上所有 Dubbo 接口都开始超时、拒绝,告警刷屏。
- 重启进程能缓一阵,流量一进来又雪崩。最后是靠限流 + 把那个慢依赖降级才稳住。
影响
- 整个服务对所有上游不可用,不只是出问题那一个接口。
- 上游也被拖住,超时链向上传导。
- 有业务侧客诉。
事故的形状
第二级子任务依赖的接口变慢 → 共享线程池耗尽 → CallerRunsPolicy 让第一级子线程自己去跑第二级任务 → 第一级子线程被粘住回不来 → 主线程 join 没超时,永久等 → Dubbo 工作线程被同步占满 → 整个服务挂。
2. 故障现场
调用拓扑
Dubbo Consumer
│ Dubbo 协议
▼
┌──────────────────────────────────────────────┐
│ Provider 服务 (Dubbo 工作线程池) │
│ │
│ 主线程 (Dubbo Worker) │
│ │ │
│ │ supplyAsync(task1) ──────┐ │
│ │ ▼ │
│ │ 线程池 P (共享) 第一级子线程 T1 │
│ │ ┌──────────┐ │ │
│ │ │ worker... │ │ supplyAsync(task2)
│ │ │ worker... │ │──────┐ │
│ │ │ ...... │ ▼ │ │
│ │ └──────────┘ 第二级子线程 T2 │
│ │ │ │
│ │ f1.join() ← 无超时 │ 调外部接口 │
│ ▼ ▼ │
│ 阻塞等 T1 返回 耗时暴涨 │
└──────────────────────────────────────────────┘
代码(脱敏后简化)
// 共享线程池,拒绝策略 CallerRunsPolicy
private static final ExecutorService POOL =
new ThreadPoolExecutor(
8, 8,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(200),
new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
public Result dubboInvoke(Request req) {
// 主线程是 Dubbo 工作线程
CompletableFuture<T1Result> f1 =
CompletableFuture.supplyAsync(() -> doLevel1(req), POOL);
T1Result r1;
try {
// ❌ join 无超时
r1 = f1.join();
} catch (CompletionException e) {
return Result.fail(e.getCause());
}
return Result.ok(r1);
}
private T1Result doLevel1(Request req) {
// 第一级子线程:再往同一个池里提交第二级任务
CompletableFuture<T2Result> f2 =
CompletableFuture.supplyAsync(() -> doLevel2(req), POOL);
T2Result r2;
try {
// ❌ 同样无超时
r2 = f2.join();
} catch (CompletionException e) {
return T1Result.fail(e.getCause());
}
return T1Result.ok(r2);
}
private T2Result doLevel2(Request req) {
// 调外部接口,平时 30ms,故障时飙到 5~30s
return remoteClient.call(req);
}
线程池参数
| 参数 | 值 | 说明 |
|---|---|---|
| corePoolSize | 8 | |
| maxPoolSize | 8 | 跟核心一样,不弹性 |
| queue | LinkedBlockingQueue(200) | 有界 |
| 拒绝策略 | CallerRunsPolicy | 由提交者线程执行 |
| keepAlive | 0 |
maxPoolSize=8 看着小,但因为队列有 200,池子在队列满之前不会扩到 max;等 8 个核心线程都在跑、队列也堆满,新提交就直接走拒绝策略。这是后面要讲的关键。
3. 传导链:一次慢调用怎么变成全服务雪崩
把事故拆成 7 个节点,看"一次慢调用"是怎么一步步放大成"全服务不可用"的。
节点 1:第二级外部接口变慢
doLevel2 调的外部接口平时 30ms,依赖方那边出了问题(慢查询 + 自身抖动),单次耗时涨到 5~30s。这是触发源,但不是根因。一个能被依赖拖垮的系统,问题本来就在自己身上。
节点 2:第二级子线程 T2 被占住
T2 在 remoteClient.call 上同步阻塞 5~30s,线程池里的 T2 线程回不去,每个 T2 长期占着一个线程槽位。
节点 3:共享线程池耗尽
doLevel1 把 T2 提交到同一个 POOL。外部接口持续变慢后:
- 8 个核心线程很快都在跑
doLevel2; - 新提交的 T2 进 200 容量的队列;
- 队列很快堆满;
- 之后的
supplyAsync触发拒绝策略。
节点 4:CallerRunsPolicy 把任务甩回提交者
CallerRunsPolicy 的语义是"由调用者线程执行这个任务"。于是在 doLevel1(也就是 T1)里提交 T2 时,如果池满,T2 不再由池里的线程跑,而是直接在 T1 里同步执行 doLevel2。
这一步很要命。T1 本来提交完 T2 之后,只是阻塞在 join 上等结果(理论上 T2 可以由别的线程跑,T1 只是等),但拒绝策略让 T1 自己去跑那个慢调用,T1 直接被粘死 5~30s。
节点 5:第一级子线程 T1 被粘死
T1 要么阻塞在 f2.join() 上等池里的线程跑完 T2,要么被 CallerRunsPolicy 直接拉去跑 T2。两种情况都一样:T1 在合理时间内回不来。
节点 6:主线程 join 无超时,永久等
主线程(Dubbo 工作线程)的 f1.join() 没设超时。T1 不返回,主线程就一直卡着。这一步把"局部慢"变成了"主线程永久占用"——只要请求进来一次,Dubbo 工作线程就被永久吃掉一个,不还了。
节点 7:Dubbo Provider 线程池耗尽,雪崩
Dubbo Provider 默认用一个固定大小的线程池(常见 200)处理所有入站请求,所有 Dubbo 接口共享。这个接口的请求源源不断把 Dubbo Worker 粘死,池子很快就空了:
- 这个接口完全不可用;
- 同一服务上的其他接口也拿不到工作线程,全部排队/拒绝;
- 上游 Consumer 看到的是 timeout,可能重试,进一步放大流量;
- 对外就是这个服务"挂了"。
这里有个点要强调一下:Dubbo 的工作线程池是 Provider 全局共享的。任何一个接口把工作线程吃光,等于整个服务对所有上游不可用。这是"一个接口拖垮整个服务"的物理基础。
4. 根因:四层问题叠在一起
把传导链倒过来看,根因不是单点,是四层缺陷叠加。任何一层做对了,这次事故都不会发生。
4.1 表面根因:依赖变慢
外部接口从 30ms 涨到 5~30s。这是导火索,但分布式系统本来就该假设依赖会变慢——这不是真正的根因,只是触发条件。
4.2 直接根因:共享池耗尽 + CallerRunsPolicy 误配
第一,父子任务共用同一个线程池。
主线程 → T1 → T2 这条链,三层共用一个池。这种结构有个隐含假设:池子永远够大。但只要 T2 慢,T2 占着线程不还,T1 又在等 T2,T1 也占着线程不还,池子必然耗尽。
更阴险的是:T1 在等 T2 的结果,而 T2 想被调度执行又需要一个空闲线程。可线程已经被 T1 占着,于是就出现"线程等自己让出线程"的隐性死锁——只是因为有 200 的队列缓冲,这个死锁不会立刻发生,而是流量稍一上来就触发。
第二,CallerRunsPolicy 在这种结构里是反模式。
CallerRunsPolicy 的初衷是背压——让生产者自己干活,自然降低提交速度。在单层异步里这是合理的。但在"主→子→孙"多层结构里就变味了:
- 提交 T2 时池满 → T1 自己跑 T2 → T1 被粘死;
- T1 被粘死之后,连"背压"的效果都达不到——T1 本来是要把结果回给主线程的,现在它直接卡死。
背压的前提是"提交者还能继续往下走",而 join 模式下提交者就是要等结果,背压无从谈起。CallerRunsPolicy + 同步 join = 把异步退化成比同步更糟的同步。
4.3 设计根因:join 无超时 + 阻塞 Dubbo 工作线程
第三,f1.join() / f2.join() 都没有超时。
CompletableFuture.join() 是无限期等待。下游不返回,调用方线程永久阻塞。这种写法不应该出现在业务代码里——任何 join 都必须有超时,超时之后必须有兜底(默认值 / 降级 / 快速失败)。
第四,在 Dubbo 工作线程里同步等异步结果。
主线程是 Dubbo 工作线程,它在 f1.join() 上阻塞,等于把一个 Dubbo Worker 变成了等待者。Dubbo Worker 数量有限,每个被阻塞的 Worker 都是实打实减少的吞吐能力。在 RPC 入站线程里做长时间同步等待,本质是拿 RPC 框架的线程池当业务线程池用,而且没有任何隔离。
4.4 架构根因:没有隔离、没有熔断、没有有界等待
视角再拉高一点:
- 没有资源隔离:业务线程池和 RPC 入站线程池之间没隔离;不同业务之间没隔离;这个接口和其他接口之间也没隔离。一个慢依赖就能吃掉所有资源。
- 没有熔断降级:外部依赖变慢时没有快速失败机制,慢调用被无限接受,每个慢调用都吃掉一个线程几十秒。
- 没有有界等待:整条调用链上没有任何一个超时是生效的,任一环节都能无限期阻塞。
- 没有容量告警:线程池活跃度、队列长度、拒绝次数都没有有效告警,等雪崩了才发现。
四层根因一句话串起来:这个系统对"依赖变慢"这件事没有任何防御——既不限制等待时间,也不隔离资源,也不快速失败,于是依赖一变慢,全链路同步阻塞,最后把 RPC 线程池吃光。
5. 改进
分三层:立即止血、结构修复、架构加固。
5.1 立即止血
- 所有
join()加超时,超时后返回降级或快速失败。 - 调小外部接口的客户端超时,别让单次调用拖到几十秒。
- 临时调大 Dubbo Provider 线程池或换派发策略,短期缓解。
// 修复后的 join:必须带超时
T1Result r1;
try {
r1 = f1.orTimeout(500, TimeUnit.MILLISECONDS).join();
} catch (CompletionException e) {
if (e.getCause() instanceof TimeoutException) {
log.error("level1 timeout, req={}", req);
Monitor.recordOne("biz.dubboInvoke.level1_timeout");
return Result.degrade();
}
return Result.fail(e.getCause());
}
5.2 结构修复
- 父子任务不要共用线程池。每一层独立池,按层级命名,避免互相挤占。
private static final ExecutorService LEVEL1_POOL =
new ThreadPoolExecutor(8, 8, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(100),
new ThreadFactoryBuilder().setNameFormat("l1-%d").build(),
new ThreadPoolExecutor.AbortPolicy());
private static final ExecutorService LEVEL2_POOL =
new ThreadPoolExecutor(16, 32, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
new ThreadFactoryBuilder().setNameFormat("l2-%d").build(),
new ThreadPoolExecutor.AbortPolicy());
- 拒绝策略换成 AbortPolicy,显式捕获。让任务失败而不是静默退化为同步执行,失败之后走降级。
CompletableFuture<T2Result> f2;
try {
f2 = CompletableFuture.supplyAsync(() -> doLevel2(req), LEVEL2_POOL);
} catch (RejectedExecutionException e) {
log.error("level2 pool rejected, req={}", req);
Monitor.recordOne("biz.doLevel1.l2_rejected");
return T1Result.degrade();
}
- 重新想一下是不是真的需要"主→子→孙"这种多层异步。如果每层都还要 join 等结果,那异步没有任何收益,只多了线程切换和池耗尽的风险。要么真异步(用 CompletableFuture 串起来,不阻塞调用线程),要么直接同步,别停在中间的"伪异步"。
// 真异步:不在 Dubbo 工作线程上阻塞
public CompletableFuture<Result> dubboInvokeAsync(Request req) {
return CompletableFuture
.supplyAsync(() -> doLevel1(req), LEVEL1_POOL)
.thenComposeAsync(r1 ->
CompletableFuture.supplyAsync(() -> doLevel2(req), LEVEL2_POOL),
LEVEL1_POOL)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> Result.degrade());
}
5.3 架构加固
- Dubbo Provider 配独立业务线程池(
dispatch = message+threadpool = fixed),慢接口路由到独立池,避免拖垮其他接口;或者按接口维度做线程池隔离。 - 接熔断器(Sentinel / Resilience4j 都行)。对外部依赖设 RT 和异常比例阈值,触发熔断后快速失败,别让慢调用持续吃线程。
- 依赖客户端必须设超时,而且要小于上游调用链的超时预算。本例里外部接口的客户端超时应远小于 Dubbo Consumer 端配的 timeout。
- 线程池关键指标接监控告警:活跃线程数、队列长度、拒绝次数、最大等待时间。队列堆积或出现拒绝的时候就告警,别等 RPC 线程池打满才发现。
- 限流。Provider 侧对易出问题的接口单独限流,从入口控制并发,避免线程池被瞬时流量击穿。
6. 几条可复用的结论
- "伪异步"比同步更危险。提交到线程池再立刻
join等结果,不仅没拿到异步收益,还多了线程池耗尽的风险。要么真异步,要么直接同步。 - 任何 join 都必须有超时。
CompletableFuture.join()、Thread.join()、Future.get()、CountDownLatch.await()的无超时版本只该出现在受控的工具代码里,业务代码里见到就该改。 - 父子任务共用线程池是高危结构。下游一变慢就会出现"线程等线程让出线程"的隐性死锁。要么分层隔离,要么干脆同步。
- CallerRunsPolicy 不能用在多层 join 结构里。它只在"提交者拿到拒绝后还能继续干活"的单层异步场景下有意义。
- RPC 入站线程池是稀缺资源。别在 Dubbo 工作线程里做长耗时同步等待;要么真正异步返回,要么把重活搬到独立业务线程池并设好有界等待。
- 一个接口能拖垮整个服务,是因为没有隔离。任何 RPC 框架的 Provider 线程池默认都是共享的,慢接口必须做线程池隔离或限流。
- 依赖变慢是必然事件,系统要按"依赖一定会变慢"来设计。超时 + 熔断 + 降级 + 限流是标配,不是可选项。
7. 自检清单
落到一份日常 CR 和故障演练能用的清单:
- 所有
join()/get()/await()是否都设了超时? - 是否存在"提交线程池后立刻 join 等结果"的伪异步结构?
- 是否存在父子任务共用同一个线程池的调用链?
- 拒绝策略是否和调用结构匹配?(join 结构里禁用 CallerRunsPolicy)
- Dubbo 工作线程内是否有长耗时同步等待?是否搬到了独立业务线程池?
- 对外依赖的客户端是否设了超时?超时是否小于上游调用链的超时预算?
- 是否对慢/不稳定接口做了线程池隔离或限流?
- 是否接了熔断器,配了 RT 和异常比例阈值?
- 线程池的活跃度、队列长度、拒绝次数是否有监控告警?
- Dubbo Provider 线程池是否有打满告警?
这次事故没有什么新技术问题,每一环都是老面孔。但线上事故大多就是这样——很少有单点 bug 能直接搞垮服务,搞垮服务的通常是几个"看起来都没大问题"的设计缺陷,在某个触发条件下一起生效。复盘想拿到的不是"记住这次事故",而是把上面这些点变成写代码和 review 时的本能。
更多推荐



所有评论(0)