那个周三下午,监控大盘上有一条曲线“很有个性”:订单服务调用商品服务的 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 次请求。这就是流量翻三倍的证据。
二、排查路线图
先看第一张图:这是“放大效应”——下游一个慢接口,是怎么被超时和重试变成雪崩的:
第二张图是我这次用的排查路线,从“确认是不是线程池被占满”,到“找到谁在慢”:
三、动手:从线程栈到慢点
第 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 wheretype: 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 次。链路越长,最底层的服务承受的放大越惊人。这就是很多“下游轻微抖动,全站雪崩”的数学解释。
五、止血与修复
止血(按优先级)
- 先切断放大:在 consumer 端把该接口的
retries临时改 0。如果用了 Dubbo 的动态配置(配置中心里的 override 规则)不用重启就能生效; - 降级 / 限流:对
getSkuStock先限流或返回兜底(比如缓存中的库存、或“稍后再试”),让 provider 线程池喘口气; - 处理慢点:这次是补回索引:
alter table t_sku_stock add index idx_sku_code(sku_code);(大表要选低峰、用在线 DDL); - 如果线程池已经耗尽出不来,滚动重启 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 三不原则”:不用默认重试、不让写接口重试、不让一个接口独占线程池。我还在白板上补了一行小字:“重试不是保险,是杠杆——放在对的地方省力,放在错的地方翻车。”
参考资料
- Apache Dubbo FAQ:Thread Pool Resource Exhaustion——服务端默认 200 个业务线程,超过后请求被拒绝,以及排查建议(access log、jstack、增加线程/扩容)。
- Apache Dubbo:Cluster 扩展——Failover(默认,重试 2 次)、Failfast、Failsafe、Failback、Forking 等集群容错策略。
- Apache Dubbo:配置加载与覆盖原则——方法级、接口级、全局配置的覆盖顺序,以及 consumer 优先于 provider。
- Apache Dubbo:QoS 概览——QoS 默认端口 22222、默认不允许远程 IP 访问及白名单配置。
- Apache Dubbo:Telnet 命令参考——ls、ps、invoke、status 等命令的用法。
- Sentinel 官方介绍——流量控制、并发限制、熔断降级与系统自适应保护。
一起聊聊