资讯动态

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

发布时间:2026/9/19 23:20:25 来源:尧图企业网站定制
一次线程池事故复盘一个 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 返回 耗时暴涨 │ └──────────────────────────────────────────────┘代码脱敏后简化// 共享线程池拒绝策略 CallerRunsPolicyprivatestaticfinalExecutorServicePOOLnewThreadPoolExecutor(8,8,0L,TimeUnit.MILLISECONDS,newLinkedBlockingQueue(200),newThreadFactoryBuilder().setNameFormat(biz-pool-%d).build(),newThreadPoolExecutor.CallerRunsPolicy());publicResultdubboInvoke(Requestreq){// 主线程是 Dubbo 工作线程CompletableFutureT1Resultf1CompletableFuture.supplyAsync(()-doLevel1(req),POOL);T1Resultr1;try{// ❌ join 无超时r1f1.join();}catch(CompletionExceptione){returnResult.fail(e.getCause());}returnResult.ok(r1);}privateT1ResultdoLevel1(Requestreq){// 第一级子线程再往同一个池里提交第二级任务CompletableFutureT2Resultf2CompletableFuture.supplyAsync(()-doLevel2(req),POOL);T2Resultr2;try{// ❌ 同样无超时r2f2.join();}catch(CompletionExceptione){returnT1Result.fail(e.getCause());}returnT1Result.ok(r2);}privateT2ResultdoLevel2(Requestreq){// 调外部接口平时 30ms故障时飙到 5~30sreturnremoteClient.call(req);}线程池参数参数值说明corePoolSize8maxPoolSize8跟核心一样不弹性queueLinkedBlockingQueue(200)有界拒绝策略CallerRunsPolicy由提交者线程执行keepAlive0maxPoolSize8看着小但因为队列有 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触发拒绝策略。节点 4CallerRunsPolicy 把任务甩回提交者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 工作线程就被永久吃掉一个不还了。节点 7Dubbo 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 又在等 T2T1 也占着线程不还池子必然耗尽。更阴险的是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必须带超时T1Resultr1;try{r1f1.orTimeout(500,TimeUnit.MILLISECONDS).join();}catch(CompletionExceptione){if(e.getCause()instanceofTimeoutException){log.error(level1 timeout, req{},req);Monitor.recordOne(biz.dubboInvoke.level1_timeout);returnResult.degrade();}returnResult.fail(e.getCause());}5.2 结构修复父子任务不要共用线程池。每一层独立池按层级命名避免互相挤占。privatestaticfinalExecutorServiceLEVEL1_POOLnewThreadPoolExecutor(8,8,0L,TimeUnit.MILLISECONDS,newLinkedBlockingQueue(100),newThreadFactoryBuilder().setNameFormat(l1-%d).build(),newThreadPoolExecutor.AbortPolicy());privatestaticfinalExecutorServiceLEVEL2_POOLnewThreadPoolExecutor(16,32,60L,TimeUnit.SECONDS,newLinkedBlockingQueue(200),newThreadFactoryBuilder().setNameFormat(l2-%d).build(),newThreadPoolExecutor.AbortPolicy());拒绝策略换成 AbortPolicy显式捕获。让任务失败而不是静默退化为同步执行失败之后走降级。CompletableFutureT2Resultf2;try{f2CompletableFuture.supplyAsync(()-doLevel2(req),LEVEL2_POOL);}catch(RejectedExecutionExceptione){log.error(level2 pool rejected, req{},req);Monitor.recordOne(biz.doLevel1.l2_rejected);returnT1Result.degrade();}重新想一下是不是真的需要主→子→孙这种多层异步。如果每层都还要 join 等结果那异步没有任何收益只多了线程切换和池耗尽的风险。要么真异步用 CompletableFuture 串起来不阻塞调用线程要么直接同步别停在中间的伪异步。// 真异步不在 Dubbo 工作线程上阻塞publicCompletableFutureResultdubboInvokeAsync(Requestreq){returnCompletableFuture.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 messagethreadpool 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 结构里禁用 CallerRunsPolicyDubbo 工作线程内是否有长耗时同步等待是否搬到了独立业务线程池对外依赖的客户端是否设了超时超时是否小于上游调用链的超时预算是否对慢/不稳定接口做了线程池隔离或限流是否接了熔断器配了 RT 和异常比例阈值线程池的活跃度、队列长度、拒绝次数是否有监控告警Dubbo Provider 线程池是否有打满告警这次事故没有什么新技术问题每一环都是老面孔。但线上事故大多就是这样——很少有单点 bug 能直接搞垮服务搞垮服务的通常是几个看起来都没大问题的设计缺陷在某个触发条件下一起生效。复盘想拿到的不是记住这次事故而是把上面这些点变成写代码和 review 时的本能。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价