一次线程池事故复盘:一个 Dubbo 接口拖垮整个服务

线上出过一次事故,一个对外 Dubbo 接口因为底层依赖变慢,最后把整个服务搞挂了。事故本身不复杂,每一环单独看都不算大问题,但叠在一起就炸了。记下来,主要是想把传导过程讲清楚,顺便梳理一下哪些是可复用的教训。


目录

  1. 事故概览
  2. 故障现场
  3. 传导链:一次慢调用怎么变成全服务雪崩
  4. 根因:四层问题叠在一起
  5. 改进
  6. 几条可复用的结论
  7. 自检清单

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);
}

线程池参数

参数说明
corePoolSize8
maxPoolSize8跟核心一样,不弹性
queueLinkedBlockingQueue(200)有界
拒绝策略CallerRunsPolicy由提交者线程执行
keepAlive0

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 立即止血

  1. 所有 join() 加超时,超时后返回降级或快速失败。
  2. 调小外部接口的客户端超时,别让单次调用拖到几十秒。
  3. 临时调大 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 结构修复

  1. 父子任务不要共用线程池。每一层独立池,按层级命名,避免互相挤占。
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());
  1. 拒绝策略换成 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();
}
  1. 重新想一下是不是真的需要"主→子→孙"这种多层异步。如果每层都还要 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 架构加固

  1. Dubbo Provider 配独立业务线程池(dispatch = message + threadpool = fixed),慢接口路由到独立池,避免拖垮其他接口;或者按接口维度做线程池隔离。
  2. 接熔断器(Sentinel / Resilience4j 都行)。对外部依赖设 RT 和异常比例阈值,触发熔断后快速失败,别让慢调用持续吃线程。
  3. 依赖客户端必须设超时,而且要小于上游调用链的超时预算。本例里外部接口的客户端超时应远小于 Dubbo Consumer 端配的 timeout。
  4. 线程池关键指标接监控告警:活跃线程数、队列长度、拒绝次数、最大等待时间。队列堆积或出现拒绝的时候就告警,别等 RPC 线程池打满才发现。
  5. 限流。Provider 侧对易出问题的接口单独限流,从入口控制并发,避免线程池被瞬时流量击穿。

6. 几条可复用的结论

  1. "伪异步"比同步更危险。提交到线程池再立刻 join 等结果,不仅没拿到异步收益,还多了线程池耗尽的风险。要么真异步,要么直接同步。
  2. 任何 join 都必须有超时CompletableFuture.join()Thread.join()Future.get()CountDownLatch.await() 的无超时版本只该出现在受控的工具代码里,业务代码里见到就该改。
  3. 父子任务共用线程池是高危结构。下游一变慢就会出现"线程等线程让出线程"的隐性死锁。要么分层隔离,要么干脆同步。
  4. CallerRunsPolicy 不能用在多层 join 结构里。它只在"提交者拿到拒绝后还能继续干活"的单层异步场景下有意义。
  5. RPC 入站线程池是稀缺资源。别在 Dubbo 工作线程里做长耗时同步等待;要么真正异步返回,要么把重活搬到独立业务线程池并设好有界等待。
  6. 一个接口能拖垮整个服务,是因为没有隔离。任何 RPC 框架的 Provider 线程池默认都是共享的,慢接口必须做线程池隔离或限流。
  7. 依赖变慢是必然事件,系统要按"依赖一定会变慢"来设计。超时 + 熔断 + 降级 + 限流是标配,不是可选项。

7. 自检清单

落到一份日常 CR 和故障演练能用的清单:

  • 所有 join() / get() / await() 是否都设了超时?
  • 是否存在"提交线程池后立刻 join 等结果"的伪异步结构?
  • 是否存在父子任务共用同一个线程池的调用链?
  • 拒绝策略是否和调用结构匹配?(join 结构里禁用 CallerRunsPolicy)
  • Dubbo 工作线程内是否有长耗时同步等待?是否搬到了独立业务线程池?
  • 对外依赖的客户端是否设了超时?超时是否小于上游调用链的超时预算?
  • 是否对慢/不稳定接口做了线程池隔离或限流?
  • 是否接了熔断器,配了 RT 和异常比例阈值?
  • 线程池的活跃度、队列长度、拒绝次数是否有监控告警?
  • Dubbo Provider 线程池是否有打满告警?

这次事故没有什么新技术问题,每一环都是老面孔。但线上事故大多就是这样——很少有单点 bug 能直接搞垮服务,搞垮服务的通常是几个"看起来都没大问题"的设计缺陷,在某个触发条件下一起生效。复盘想拿到的不是"记住这次事故",而是把上面这些点变成写代码和 review 时的本能。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐