微服务与中间件排查 列表全部文章
微服务与中间件排查

一个慢接口引发的雪崩:Dubbo 超时 + 默认重试,把流量放大了 3 倍

下游一个慢接口,叠加 Dubbo 超时与默认 retries=2(最多调 3 次),流量被放大、provider 200 线程被占满并报 Thread pool is EXHAUSTED,故障从一个服务扩散到整条链路。附排查命令、超时分层、集群容错与限流降级的修法。

更多

那个周三下午,监控大盘上有一条曲线“很有个性”:订单服务调用商品服务的 QPS,从平时的 300 一路爬到 900,而用户侧的请求量一点没变。我盯着这条曲线愣了两秒,然后群里响了:“商品服务不可用了!”接着是订单服务、优惠券服务……像多米诺骨牌,一个接一个红了。

后来复盘我在白板上写了一行字:“系统不是被流量打垮的,是被我们自己‘好心’的重试打垮的。”Dubbo 的超时加重试,在下游正常时是保险,在下游变慢时就是放大器。这篇讲完整的排查过程和背后的机制。

一、现象:调用量翻了三倍,谁也没多点一下

告警内容一开始很散:订单服务接口超时增多、商品服务 CPU 不高但接口 RT 飙升、其他几个调用商品服务的下游也开始报错。我的第一直觉不是重启,而是先保留现场——抓 provider 的线程栈,保存日志。因为“Dubbo 线程池被占满”这个状态,重启后就看不到了。

先看 provider(商品服务)的日志,搜 Dubbo 特有的报错:

$ grep -E 'EXHAUSTED|RejectedExecution' /data/logs/product-service/app.log | tail -n 3
2026-09-16 14:32:09.552 WARN  [DubboServerHandler-10.0.1.21:20880-thread-201] o.a.d.c.t.s.f.AbortPolicyWithReport - [DUBBO] Thread pool is EXHAUSTED! Thread Name: DubboServerHandler-10.0.1.21:20880, Pool Size: 200 (active: 200, core: 200, max: 200, largest: 200), Task: 18436 (completed: 18236), Executor status:(isShutdown:false, isTerminated:false, isTerminating:false), in dubbo://10.0.1.21:20880!, dubbo version: 3.2.x, current host: 10.0.1.21
java.util.concurrent.RejectedExecutionException: Thread pool is EXHAUSTED! Thread Name: DubboServerHandler-10.0.1.21:20880, Pool Size: 200 (active: 200, core: 200, max: 200, largest: 200) ...

这行日志信息量很大,逐段解读:

  • Pool Size: 200 (active: 200, core: 200, max: 200, largest: 200):dubbo 协议默认使用 fixed 线程池,默认 200 个线程,200 个全部在忙(active: 200);
  • Task: 18436 (completed: 18236):已提交 18436 个任务,完成 18236 个,差值约 200,恰好等于正在执行的线程数——没有排队(默认 queues=0,即 SynchronousQueue,不排队),新任务直接被拒绝;
  • 官方 FAQ 对这个错误的解释是:服务端默认有 200 个业务线程,并发请求超过 200,新请求会被拒绝;原因可能是消费者并发过高,或者提供者有线程因为调用外部接口而阻塞。

再看 consumer(订单服务)那边,报错是另一种样子:

org.apache.dubbo.rpc.RpcException: Failed to invoke the method getSkuStock in the service com.demo.product.api.SkuService.
Tried 3 times of the providers [10.0.1.21:20880, 10.0.1.22:20880] (2/2) from the registry 10.0.2.5:8848
on the consumer 10.0.1.31 using the dubbo version 3.2.x.
Last error is: Invoke remote method timeout. method: getSkuStock, provider: dubbo://10.0.1.22:20880/com.demo.product.api.SkuService,
cause: client-side timeout: elapsed: 1003 ms, timeout: 1000 ms ...

最关键的一句是 Tried 3 times:一次业务调用,实际向 provider 发了 3 次请求。这就是流量翻三倍的证据。

二、排查路线图

先看第一张图:这是“放大效应”——下游一个慢接口,是怎么被超时和重试变成雪崩的:

网关300 QPS 订单服务 (consumer)timeout=1s retries=2 商品服务 (provider)200 线程 fixed 慢 SQL / 慢外部调用单次 3s 最多 3 次 → 900 QPS 需要的并发线程 ≈ QPS × 单次占用时间(provider 超时后仍在执行,不会被打断) 正常:300 × 0.02s ≈ 6 个线程   故障:900 × 3s ≈ 2700 个线程,池只有 200 → 拒绝 → 更多超时 → 更多重试 正反馈循环 慢 → 超时 → 重试 → 更慢 → 线程池耗尽 → 其他接口(哪怕很快)也拿不到线程 → 调用方全线告警

第二张图是我这次用的排查路线,从“确认是不是线程池被占满”,到“找到谁在慢”:

① 日志关键字EXHAUSTED /Tried 3 times ② jstackDubboServerHandler线程都卡在哪里 ③ 定位慢点SQL / HTTP / 锁哪个接口最慢 ④ 止血降级/限流/摘慢节点retries=0 ⑤ 根治超时分层 + 熔断+ 优化慢点 现场调用:telnet 到 20880 端口用 ls / ps / invoke 验证单个方法的耗时

三、动手:从线程栈到慢点

第 1 步:抓 provider 的线程栈,看 200 个线程在干什么

$ jps -l | grep product
8231 com.demo.product.ProductApplication

$ jstack 8231 > /tmp/product-jstack-$(date +%H%M%S).txt      # 先保存现场
$ grep -c 'DubboServerHandler' /tmp/product-jstack-*.txt
200
$ grep -A 12 'DubboServerHandler' /tmp/product-jstack-*.txt | grep -E '^\s+at ' | sort | uniq -c | sort -rn | head -8
    173         at java.net.SocketInputStream.socketRead0(Native Method)
    173         at com.mysql.cj.protocol.ReadAheadInputStream.fill(ReadAheadInputStream.java:107)
    168         at com.demo.product.service.impl.SkuServiceImpl.getSkuStock(SkuServiceImpl.java:64)
     ...

200 个 DubboServerHandler 线程,其中 173 个卡在 socketRead0——在等 MySQL 返回数据;栈里业务代码指向 SkuServiceImpl.getSkuStock 第 64 行。线程不是在“算”,是在“等”,这和 CPU 高的排查是两条路。一个慢 SQL(比如某次发布把索引搞丢了)拖慢了整个 getSkuStock,而这个方法调用量最大,几乎把线程池吃光。

顺带说明:Dubbo 的 AbortPolicyWithReport 在触发拒绝时,还会自动把线程栈 dump 到文件(默认在用户主目录,名字形如 Dubbo_JStack.log.日期时间),所以即使你没来得及手动 jstack,也可以去 ~ 目录里找这个文件。

第 2 步:确认是哪个方法在慢

用 Dubbo 自带的 telnet(dubbo 协议端口 20880)现场调一次,看单次耗时。ls 列服务,ps -l 看端口和连接,invoke 调用方法:

$ telnet 10.0.1.21 20880
Trying 10.0.1.21...
Connected to 10.0.1.21.
Escape character is '^]'.

dubbo>ls
PROVIDER:
com.demo.product.api.SkuService
com.demo.product.api.CategoryService

dubbo>ps -l
dubbo://10.0.1.21:20880

dubbo>invoke com.demo.product.api.SkuService.getSkuStock(10086)
{"skuId":10086,"stock":57}
elapsed: 3120 ms.

dubbo>invoke com.demo.product.api.CategoryService.getCategory(3)
{"id":3,"name":"数码"}
elapsed: 4 ms.

elapsed: 3120 ms 对比另一个接口 4ms,慢点一目了然。注意:telnet 调用会占用一个业务线程,线程池已满时可能拿不到线程,这时可以先摘掉流量再测。另外,QoS 端口(默认 22222)也提供 ls 等命令,默认只允许本机访问(qos-accept-foreign-ip 默认 false),远程使用要先在配置里明确允许,并用白名单收紧——不要把 QoS 或 20880 暴露到公网,invoke 能直接调你的业务方法。

第 3 步:回到根源,看 SQL

mysql> show full processlist;
| Id   | User | Time | State        | Info                                            |
| 5121 | app  |    3 | Sending data | select stock from t_sku_stock where sku_code = ? |
...  (一百多条相同的语句)
mysql> explain select stock from t_sku_stock where sku_code = 'S10086'\G
           type: ALL
           rows: 2481932
          Extra: Using where

type: ALL 全表扫描,240 多万行——前一天晚上一次表结构变更把 sku_code 上的索引带丢了。这部分展开就是慢查询那一篇的内容了,这里不再赘述。重点是:慢点本身只让这个接口变慢,但 Dubbo 的超时+重试+固定线程池,把它变成了整个链路的事故。

四、根因:Dubbo 里三个放大器

1. 默认 failover:retries=2 = 最多调 3 次

Dubbo 默认的集群容错策略是 Failover:失败自动切换,重试其它服务器;默认重试次数是 2(不含第一次),所以一次调用最多打出 3 次请求。超时也算失败,会触发重试。这对幂等的读接口是好事——某台机器抖一下,换一台就成功了。但当慢是所有机器的共同问题(比如共享数据库慢)时,重试就是纯粹的加压:调用量三倍,而且每次都还是慢的。

更糟的是,Dubbo 重试时会切换到另一台 provider,于是本来只有一台节点有问题,重试把压力均匀地涂到了所有节点上,整个集群一起被拖垮。

2. 超时 ≠ 取消:provider 端请求还在继续

consumer 等到 timeout(默认 1000ms)就放弃了,但provider 那个线程并不会被打断,它会继续执行完(占用着线程、数据库连接)才结束。所以“超时后重试”等于同一个任务在 provider 上同时存在两三份。需要的线程数≈ QPS × 每个请求实际占用时间,上面图中的估算:900 × 3s ≈ 2700,而线程池只有 200。

3. fixed 线程池 + 不排队 = 一个接口拖死全部接口

dubbo 协议默认 threadpool=fixed、threads=200、queues=0。所有服务、所有方法共用这一个池子。所以 getSkuStock 慢,占满 200 线程之后,连 4ms 就能返回的 getCategory 也会因为拿不到线程而被拒绝,提示 Thread pool is EXHAUSTED。这也是为什么故障会“横向”扩散:无关的接口一起挂。

4. 多层调用:放大是乘法

如果调用链是 A → B → C,每一层都是默认 retries=2,那么 C 被最坏调用 3 × 3 = 9 次;四层就是 27 次。链路越长,最底层的服务承受的放大越惊人。这就是很多“下游轻微抖动,全站雪崩”的数学解释。

五、止血与修复

止血(按优先级)

  1. 先切断放大:在 consumer 端把该接口的 retries 临时改 0。如果用了 Dubbo 的动态配置(配置中心里的 override 规则)不用重启就能生效;
  2. 降级 / 限流:对 getSkuStock 先限流或返回兜底(比如缓存中的库存、或“稍后再试”),让 provider 线程池喘口气;
  3. 处理慢点:这次是补回索引:alter table t_sku_stock add index idx_sku_code(sku_code);(大表要选低峰、用在线 DDL);
  4. 如果线程池已经耗尽出不来,滚动重启 provider之前,先保存 jstack 和日志;重启前先把流量摘掉,避免刚启动就被积压请求再次打满。

根治一:超时分层与重试策略

# consumer(订单服务)application.yml
dubbo:
  consumer:
    timeout: 1500     # 别用“越大越保险”的思路,要小于上游对用户的超时
    retries: 0        # 默认全局不重试,需要重试的接口在方法级别显式开
    check: false
  provider:
    threads: 200      # 一般不靠加线程来解决
    timeout: 3000
// 方法级:只有幂等的读方法允许重试,写方法一律 retries=0
@DubboReference(timeout = 1500, retries = 0,
    methods = {
        @Method(name = "getSkuStock", timeout = 800, retries = 1),   // 读,幂等,最多重试 1 次
        @Method(name = "deductStock", timeout = 3000, retries = 0)   // 写,绝不重试
    })
private SkuService skuService;

几条原则:

  • 非幂等接口(下单、扣库存、扣款、发券)必须 retries=0。超时不代表没执行,provider 可能已经扣了库存只是响应慢了,重试就会重复扣;这类接口如果要“安全重试”,要靠业务幂等(唯一请求号)而不是框架重试;
  • timeout 的优先级:Dubbo 的规则是方法级 > 接口级 > 全局,并且同级别下 consumer 优先于 provider。provider 上配的 timeout 相当于给 consumer 一个默认值,consumer 自己配了就以 consumer 为准。所以推荐:provider 给出合理默认,consumer 按自己调用场景覆盖;
  • timeout 要“分层递减”:用户请求总超时 > 网关 > 上游服务对下游 timeout × (1 + retries),否则上游都已经放弃了,下游还在重试;
  • 集群容错:写操作可以用 cluster="failfast"(快速失败,只调一次),异步通知类可以用 failsafe(忽略异常);读操作才用 failover 并控制次数。
@DubboReference(cluster = "failfast")   // 一次调用只发一次,失败立刻抛
private OrderWriteService orderWriteService;

根治二:隔离、限流与熔断

  • 限制单个服务/方法占用的线程数:provider 端可以对 service 或 method 配 executes(并发执行上限),超过就直接拒绝,保证“慢接口最多占 N 个线程”,不至于吃光 200 个;consumer 端可以用 actives 限制对某个方法的并发调用数;
  • 熔断降级:用 Sentinel 之类的组件(官方 introduction 里提到它覆盖流量控制、并发限制、熔断降级和系统自适应保护,并且有 Dubbo 适配)对 consumer 调用配置“慢调用比例熔断”:比如 1 秒内慢调用(> 500ms)占比超过 50% 就熔断 10 秒,期间直接走降级逻辑,给下游恢复的时间;
  • 重要接口独立线程池:核心的下单接口和大流量的查询接口,用不同 protocol 端口或不同服务分组隔离,避免互相拖累;
  • 根源优化:加索引、加缓存、把慢外部调用异步化。别指望“把线程池调成 1000”解决问题,那样只是让更多请求同时压垮数据库。

六、复盘

做法为什么
全局 retries=0,读接口按需开 1 次默认 retries=2 会把慢故障放大成 3 倍流量,链路越长越夸张(乘法)
非幂等接口 retries=0 + 业务幂等号超时不等于没执行,框架重试会造成重复扣款/重复下单
timeout 分层:用户 > 网关 > 服务,且下游总耗时(含重试)小于上游超时避免上游已放弃,下游还在白干
用 executes/actives 给方法限并发共用 200 线程池,一个慢方法能拖死所有方法
Sentinel 限流 + 慢调用熔断 + 降级兜底下游变慢时快速失败,避免线程和连接被拖光
日志报警关键字:Thread pool is EXHAUSTED、Tried 3 times前者是 provider 线程池打满,后者是 consumer 正在重试放大
监控 provider 线程池 active 数与接口 P99线程池占用上升是雪崩前兆,比错误率更早
变更表结构后核对索引(explain)这次的真正导火索是丢了一个索引

一句话备忘:慢 + 超时 + 重试 = 放大器;Dubbo 默认 retries=2(最多调 3 次)、200 个 fixed 线程共用;写接口 retries=0,读接口有限重试,timeout 分层,线程池要隔离,下游慢就熔断降级。

那次事故之后,我们的团队约定了一条“Dubbo 三不原则”:不用默认重试、不让写接口重试、不让一个接口独占线程池。我还在白板上补了一行小字:“重试不是保险,是杠杆——放在对的地方省力,放在错的地方翻车。”

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人