概述
Dubbo 调用不带超时,下游一个卡住的慢方法就能把消费者线程池拖干,故障沿着调用链一路向上烧到网关。这篇讲清两件事:怎么把超时配到正确的层级,以及重试这把双刃剑什么时候必须关掉。
纲要
- 雪崩是怎么发生的:消费者线程被慢提供者挂住 → 线程池耗尽 → 上游超时 → 级联失败
- 超时机制:消费者本地计时到期即放弃等待,收益是"线程及时归还"
- 超时配置的四个层级:服务级、消费者级、全局级、方法级,以及各自的 yaml / 注解写法
- 默认值 1000ms 从哪来,为什么它经常不够用
- 重试机制:默认
retries = 2,一次调用变三次 - 重试的两个前提:幂等性 + 超时时间不能比"重试总耗时"短
timeout + retries叠加的算术:1000ms × 3 次 = 最坏等 3000ms- 两侧都配
retries会导致重试次数放大,工程里retries = 0就是正确示范 FailoverCluster重试时换实例,"自愈"与"重复写"是同一枚硬币的两面- 工程实读:
user-consumer/user-provider的真实配置 - 动手验证:用
Thread.sleep(5000)故意制造慢调用,观察超时异常文本 - 生产推荐配置表与实战坑清单
雪崩是怎么发生的
Dubbo 调用链路上,消费者线程发出请求后是阻塞等待响应的。提供者那边只要卡住,消费者线程就跟着卡住。
提供者卡住的常见原因跟业务代码质量关系不大:一条没走索引的慢 SQL、行锁等待、Full GC 停顿、下游第三方接口抖动。这些情况都可能让一个本来 20ms 的方法变成 10s。
问题在于消费者侧的 Tomcat 线程池是有限的(默认 200)。请求不断进来,每个请求都占着一个线程在等提供者,等不回来就不释放:
#mermaid-svg-a0cdCq5aLUIWmRP9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-a0cdCq5aLUIWmRP9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-a0cdCq5aLUIWmRP9 .error-icon{fill:#552222;}#mermaid-svg-a0cdCq5aLUIWmRP9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-a0cdCq5aLUIWmRP9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-a0cdCq5aLUIWmRP9 .marker.cross{stroke:#333333;}#mermaid-svg-a0cdCq5aLUIWmRP9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-a0cdCq5aLUIWmRP9 p{margin:0;}#mermaid-svg-a0cdCq5aLUIWmRP9 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-a0cdCq5aLUIWmRP9 .cluster-label text{fill:#333;}#mermaid-svg-a0cdCq5aLUIWmRP9 .cluster-label span{color:#333;}#mermaid-svg-a0cdCq5aLUIWmRP9 .cluster-label span p{background-color:transparent;}#mermaid-svg-a0cdCq5aLUIWmRP9 .label text,#mermaid-svg-a0cdCq5aLUIWmRP9 span{fill:#333;color:#333;}#mermaid-svg-a0cdCq5aLUIWmRP9 .node rect,#mermaid-svg-a0cdCq5aLUIWmRP9 .node circle,#mermaid-svg-a0cdCq5aLUIWmRP9 .node ellipse,#mermaid-svg-a0cdCq5aLUIWmRP9 .node polygon,#mermaid-svg-a0cdCq5aLUIWmRP9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-a0cdCq5aLUIWmRP9 .rough-node .label text,#mermaid-svg-a0cdCq5aLUIWmRP9 .node .label text,#mermaid-svg-a0cdCq5aLUIWmRP9 .image-shape .label,#mermaid-svg-a0cdCq5aLUIWmRP9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-a0cdCq5aLUIWmRP9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-a0cdCq5aLUIWmRP9 .rough-node .label,#mermaid-svg-a0cdCq5aLUIWmRP9 .node .label,#mermaid-svg-a0cdCq5aLUIWmRP9 .image-shape .label,#mermaid-svg-a0cdCq5aLUIWmRP9 .icon-shape .label{text-align:center;}#mermaid-svg-a0cdCq5aLUIWmRP9 .node.clickable{cursor:pointer;}#mermaid-svg-a0cdCq5aLUIWmRP9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-a0cdCq5aLUIWmRP9 .arrowheadPath{fill:#333333;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-a0cdCq5aLUIWmRP9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-a0cdCq5aLUIWmRP9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-a0cdCq5aLUIWmRP9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-a0cdCq5aLUIWmRP9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-a0cdCq5aLUIWmRP9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-a0cdCq5aLUIWmRP9 .cluster text{fill:#333;}#mermaid-svg-a0cdCq5aLUIWmRP9 .cluster span{color:#333;}#mermaid-svg-a0cdCq5aLUIWmRP9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-a0cdCq5aLUIWmRP9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-a0cdCq5aLUIWmRP9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-a0cdCq5aLUIWmRP9 .icon-shape,#mermaid-svg-a0cdCq5aLUIWmRP9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-a0cdCq5aLUIWmRP9 .icon-shape p,#mermaid-svg-a0cdCq5aLUIWmRP9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-a0cdCq5aLUIWmRP9 .icon-shape .label rect,#mermaid-svg-a0cdCq5aLUIWmRP9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-a0cdCq5aLUIWmRP9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-a0cdCq5aLUIWmRP9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-a0cdCq5aLUIWmRP9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 单次响应 10s+
迟迟不返回
上游 / 网关
user-consumer
Tomcat 线程池 200
user-provider
业务 + SQL
MySQL
慢 SQL / 行锁等待
200 个线程全被占住
新请求排队
上游开始大面积超时 / 504
故障沿调用链向上蔓延
级联失败(雪崩)
注意这条链上没有任何一个组件"坏了"------提供者没崩,数据库没挂,只是慢。慢就是故障,因为消费者拿不出线程去处理新请求,它自己变成了不可用服务,然后把压力继续往上传。这就是级联失败,也是"雪崩"这个词在 RPC 语境里的准确含义。
超时机制
Dubbo 给的解法很朴素:消费者发起调用时设一个等待阈值,超过这个值就不等了。
provider 方法耗时 5s consumer timeout=1000ms provider 方法耗时 5s consumer timeout=1000ms #mermaid-svg-6INss0aaZVZOxIHt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6INss0aaZVZOxIHt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6INss0aaZVZOxIHt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6INss0aaZVZOxIHt .error-icon{fill:#552222;}#mermaid-svg-6INss0aaZVZOxIHt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6INss0aaZVZOxIHt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6INss0aaZVZOxIHt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6INss0aaZVZOxIHt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6INss0aaZVZOxIHt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6INss0aaZVZOxIHt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6INss0aaZVZOxIHt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6INss0aaZVZOxIHt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6INss0aaZVZOxIHt .marker.cross{stroke:#333333;}#mermaid-svg-6INss0aaZVZOxIHt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6INss0aaZVZOxIHt p{margin:0;}#mermaid-svg-6INss0aaZVZOxIHt .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6INss0aaZVZOxIHt text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-6INss0aaZVZOxIHt .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-6INss0aaZVZOxIHt .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-6INss0aaZVZOxIHt .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-6INss0aaZVZOxIHt .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-6INss0aaZVZOxIHt #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-6INss0aaZVZOxIHt .sequenceNumber{fill:white;}#mermaid-svg-6INss0aaZVZOxIHt #sequencenumber{fill:#333;}#mermaid-svg-6INss0aaZVZOxIHt #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-6INss0aaZVZOxIHt .messageText{fill:#333;stroke:none;}#mermaid-svg-6INss0aaZVZOxIHt .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6INss0aaZVZOxIHt .labelText,#mermaid-svg-6INss0aaZVZOxIHt .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-6INss0aaZVZOxIHt .loopText,#mermaid-svg-6INss0aaZVZOxIHt .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-6INss0aaZVZOxIHt .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-6INss0aaZVZOxIHt .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-6INss0aaZVZOxIHt .noteText,#mermaid-svg-6INss0aaZVZOxIHt .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-6INss0aaZVZOxIHt .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6INss0aaZVZOxIHt .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6INss0aaZVZOxIHt .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6INss0aaZVZOxIHt .actorPopupMenu{position:absolute;}#mermaid-svg-6INss0aaZVZOxIHt .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-6INss0aaZVZOxIHt .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6INss0aaZVZOxIHt .actor-man circle,#mermaid-svg-6INss0aaZVZOxIHt line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-6INss0aaZVZOxIHt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 启动 1000ms 超时计时 慢 SQL 卡住,5s 后才返回 后续请求可以立刻拿到线程继续服务 queryById(1) t=0ms t=1000ms 计时到期,抛 TimeoutException,线程归还线程池 t=5000ms 才到响应,已被丢弃
关键收益不是"这次调用失败了",而是线程被及时归还。消费者不再被一个坏掉的下游成批拖死,失败被限制在单次调用范围内。
超时时间用 timeout 配置,单位毫秒,默认值 1000ms。
一个必须纠正的认知
素材里说"超过这个值就把连接关闭,不等了",这个描述会误导人。实际情况是:
- Dubbo 用的是长连接复用,单个调用超时不会关闭连接;
- 超时只是消费者本地的计时器到期 ,抛
TimeoutException,不再等这个响应; - 提供者那边的线程完全不知道消费者已经走了,方法会继续执行到底,SQL 该提交还是提交。
这条认知直接解释了一个线上高频困惑:为什么明明报了超时报错,数据库里数据却已经写进去了。下面讲重试和写操作时会再回到这一点。
超时时间不是越短越好
理论上把超时压到 100ms,故障时线程归还得更快。但网络本来就有抖动,有个别请求在极短时间内就是会更慢。超时设得过短,会把正常的网络抖动当成故障:
| 网络状况 | timeout=100ms 的结果 | timeout=3000ms 的结果 |
|---|---|---|
| 正常(P99 约 50ms) | 成功 | 成功 |
| 轻微抖动(单次 150ms) | 误判失败,抛超时异常 | 成功 |
| 提供者慢查询(5s) | 超时,快速失败,线程及时释放 | 超时,但线程被占 3s 才释放 |
所以真正的做法是:按接口的真实耗时分布(P99/P999)留出余量,而不是拍一个"看起来更快"的小数字。
超时的四个配置层级
Dubbo 的配置可以落在四个地方,粒度从粗到细。
| 层级 | 配置位置 | 作用范围 | 写法 |
|---|---|---|---|
| 服务级(提供方) | @DubboService |
该服务实现类的所有方法 | @DubboService(timeout = 3000) |
| 消费者级 | @DubboReference |
当前这个消费者引用 | @DubboReference(timeout = 3000) |
| 全局级 | 配置文件 dubbo.consumer / dubbo.provider |
该应用所有 Dubbo 调用 | dubbo.consumer.timeout: 3000 |
| 方法级 | @DubboService(methods = ...) / @DubboReference(methods = ...) |
指定的单个方法 | @Method(name = "queryById", timeout = 5000) |
服务级------由提供方声明自己这个服务的耗时上限:
java
package cn.mf.user.service.impl;
import cn.mf.user.domain.User;
import cn.mf.user.mapper.UserMapper;
import cn.mf.user.service.UserService;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
// 提供方最清楚自己的业务逻辑有多重,由它声明超时上限是更合理的做法
@DubboService(version = "2.0.0", timeout = 3000)
public class UserServiceImpl2 implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public String queryUsername(Long id) {
return userMapper.findById(id).getUsername();
}
@Override
public User queryById(Long id) {
User user = userMapper.findById(id);
user.setUsername(user.getUsername() + "v2.0");
return user;
}
}
消费者级------消费方覆写:
java
@DubboReference(version = "2.0.0", timeout = 3000, retries = 0)
private UserService userService;
全局级------yaml,一次管住整个应用:
yaml
dubbo:
consumer:
timeout: 3000 # 消费者全局超时,单位毫秒
retries: 0 # 消费者全局重试次数
provider:
timeout: 3000
方法级 ------给个别特殊方法开后门。queryById 要查用户详情加关联数据,比 queryUsername 慢一个量级,单独放宽:
java
package cn.mf.user.service.impl;
import cn.mf.user.domain.User;
import cn.mf.user.mapper.UserMapper;
import cn.mf.user.service.UserService;
import org.apache.dubbo.config.annotation.DubboService;
import org.apache.dubbo.config.annotation.Method;
import org.springframework.beans.factory.annotation.Autowired;
@DubboService(
version = "2.0.0",
timeout = 3000,
methods = {
@Method(name = "queryById", timeout = 5000, retries = 0),
@Method(name = "queryUsername", timeout = 1000)
}
)
public class UserServiceImpl2 implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public String queryUsername(Long id) {
return userMapper.findById(id).getUsername();
}
@Override
public User queryById(Long id) {
User user = userMapper.findById(id);
user.setUsername(user.getUsername() + "v2.0");
return user;
}
}
优先级
方向性结论是:粒度越细优先级越高,即方法级 > 接口级(服务级)> 全局级;同一粒度下,消费方配置优先于提供方配置 。也就是说,@DubboReference(timeout = 3000) 的消费者配置会盖掉提供方 @DubboService(timeout = 5000) 的声明。
这里有一个容易踩的细节,讲义和工程快照给出的信息是有出入的,需要说清楚:
- 讲义 3.2 节给出的消费者 yml 是
dubbo.consumer.timeout: 3000+retries: 0; - 但工程
user-consumer/src/main/resources/application.yml里只有check: false和retries: 0,没有timeout; - 也就是说讲义给的是"推荐应配成 3000"的写法,工程快照里这条还没落地,看到的是 Dubbo 的默认值 1000ms。
按项目惯例,讲配置取值以讲义为准,讲工程实际状态以代码为准,两者不一致时明确标注。具体的覆盖规则建议以官方文档核实------Dubbo 的配置覆盖在不同大版本之间调整过。
默认 1000ms 是个陷阱
Dubbo 把默认超时定成 1000ms,理由是 1 秒对绝大多数内存操作 + 一次索引命中的 SQL 已经足够。但真实业务里,一次调用往往要串起好几步:查用户、查用户资料、查关注关系、拼装返回对象。任何一步多花点时间,1 秒就不够了。
线上大量"偶发超时告警",追下去就是没人显式配过 timeout,全套在默认的 1000ms 上。 这个值的正确用法是:把它当成"必须显式覆盖的底线值",而不是"可以一直用下去的默认值"。
重试机制
超时把慢故障挡住了,但也引入一个新问题:网络抖动导致的偶发失败,本来重发一次就能成功,现在直接报错了。Dubbo 的重试就是补这个口子。
默认 retries = 2,含义是失败后再试 2 次 ,加上第一次,总共调用 3 次。这一点特别容易理解错:retries = 2 不是"总共 2 次",而是"总次数 = retries + 1 = 3 次"。
#mermaid-svg-5RGuul6rMUAssHwp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5RGuul6rMUAssHwp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5RGuul6rMUAssHwp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5RGuul6rMUAssHwp .error-icon{fill:#552222;}#mermaid-svg-5RGuul6rMUAssHwp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5RGuul6rMUAssHwp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5RGuul6rMUAssHwp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5RGuul6rMUAssHwp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5RGuul6rMUAssHwp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5RGuul6rMUAssHwp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5RGuul6rMUAssHwp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5RGuul6rMUAssHwp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5RGuul6rMUAssHwp .marker.cross{stroke:#333333;}#mermaid-svg-5RGuul6rMUAssHwp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5RGuul6rMUAssHwp p{margin:0;}#mermaid-svg-5RGuul6rMUAssHwp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5RGuul6rMUAssHwp .cluster-label text{fill:#333;}#mermaid-svg-5RGuul6rMUAssHwp .cluster-label span{color:#333;}#mermaid-svg-5RGuul6rMUAssHwp .cluster-label span p{background-color:transparent;}#mermaid-svg-5RGuul6rMUAssHwp .label text,#mermaid-svg-5RGuul6rMUAssHwp span{fill:#333;color:#333;}#mermaid-svg-5RGuul6rMUAssHwp .node rect,#mermaid-svg-5RGuul6rMUAssHwp .node circle,#mermaid-svg-5RGuul6rMUAssHwp .node ellipse,#mermaid-svg-5RGuul6rMUAssHwp .node polygon,#mermaid-svg-5RGuul6rMUAssHwp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5RGuul6rMUAssHwp .rough-node .label text,#mermaid-svg-5RGuul6rMUAssHwp .node .label text,#mermaid-svg-5RGuul6rMUAssHwp .image-shape .label,#mermaid-svg-5RGuul6rMUAssHwp .icon-shape .label{text-anchor:middle;}#mermaid-svg-5RGuul6rMUAssHwp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5RGuul6rMUAssHwp .rough-node .label,#mermaid-svg-5RGuul6rMUAssHwp .node .label,#mermaid-svg-5RGuul6rMUAssHwp .image-shape .label,#mermaid-svg-5RGuul6rMUAssHwp .icon-shape .label{text-align:center;}#mermaid-svg-5RGuul6rMUAssHwp .node.clickable{cursor:pointer;}#mermaid-svg-5RGuul6rMUAssHwp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5RGuul6rMUAssHwp .arrowheadPath{fill:#333333;}#mermaid-svg-5RGuul6rMUAssHwp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5RGuul6rMUAssHwp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5RGuul6rMUAssHwp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5RGuul6rMUAssHwp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5RGuul6rMUAssHwp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5RGuul6rMUAssHwp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5RGuul6rMUAssHwp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5RGuul6rMUAssHwp .cluster text{fill:#333;}#mermaid-svg-5RGuul6rMUAssHwp .cluster span{color:#333;}#mermaid-svg-5RGuul6rMUAssHwp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5RGuul6rMUAssHwp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5RGuul6rMUAssHwp rect.text{fill:none;stroke-width:0;}#mermaid-svg-5RGuul6rMUAssHwp .icon-shape,#mermaid-svg-5RGuul6rMUAssHwp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5RGuul6rMUAssHwp .icon-shape p,#mermaid-svg-5RGuul6rMUAssHwp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5RGuul6rMUAssHwp .icon-shape .label rect,#mermaid-svg-5RGuul6rMUAssHwp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5RGuul6rMUAssHwp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5RGuul6rMUAssHwp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5RGuul6rMUAssHwp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 成功
失败 / 超时
业务异常
RPC 层异常 / 超时
有余
用尽
consumer 发起调用
本次调用是否成功
返回结果给调用方
异常类型
直接抛出,不重试
retries 还有余额吗
FailoverCluster 重新选一个提供者
通常是另一个实例
重发请求
抛 RpcException
日志里带 Last error
有两个实现细节值得知道:
- 业务异常不重试。 Dubbo 默认的
FailoverCluster只在 RPC 层异常(网络、超时)时才走重试;方法内部抛出的业务异常会直接透传给调用方。所以"重试能掩盖一切问题"是错觉,它只对基础设施抖动有效。 - 重试会换实例。
FailoverCluster(默认集群容错策略)重试时会按负载均衡策略重新挑一个提供者。集群里有 3 个实例、其中一个网络不通时,重试打到其余健康实例上就成功了------这是重试能"自愈"的原因,也是它最容易被误用的地方。
重试的前提是幂等
查询接口重试完全没问题:select 执行三次和一次,对数据没有任何影响,只是多消耗点资源。
写操作重试会直接产生重复数据。 素材里的例子很典型:用户点一次"保存",consumer 调 provider 保存记录,网络抖动触发重试,retries = 2 让这次点击变成 3 次调用,数据库里就多了 3 条一样的记录。换成扣款、扣库存、发券,后果更严重------一次点击扣三次钱。
判定标准就一条:这个操作重复执行 N 次,结果和只执行 1 次一样吗?
| 操作类型 | 幂等 | 能否重试 |
|---|---|---|
| 按 ID 查询、列表查询 | 是 | 可以,默认 2 次即可 |
update ... set status = 1 where id = ? 覆盖式更新 |
是(结果收敛) | 可以,但仍建议 0~1 次 |
insert 保存订单、发券 |
否 | 禁止,必须 retries = 0 |
update ... set balance = balance - 100 |
否 | 禁止,必须 retries = 0 |
delete 删除 |
名义上幂等,但会放大误删风险 | 建议 0 次 |
工程 user-consumer 里的做法就是把 retries 设成 0:
java
@DubboReference(version = "2.0.0", retries = 0, loadbalance = "Random")
private UserService userService;
重试 + 超时的算术
这两个特性叠在一起,最坏耗时是要相乘的。设 timeout = 1000ms、retries = 2:
| 次序 | 时间窗口 | 消费者状态 |
|---|---|---|
| 第 1 次调用 | 0 ~ 1000ms | 等待响应中,占着线程 |
| 第 1 次超时 | 1000ms | 抛异常,换提供者 |
| 第 2 次调用 | 1000 ~ 2000ms | 再次等待 |
| 第 2 次超时 | 2000ms | 抛异常,换提供者 |
| 第 3 次调用 | 2000 ~ 3000ms | 最后一次等待 |
| 第 3 次超时 | 3000ms | RpcException 抛给调用方 |
| 最坏总耗时 | 3000ms | 消费者线程被占满 3 秒 |
消费者以为自己的超时是 1 秒,实际上一次失败调用最坏要占 3 秒线程。这和"超时是为了快速释放线程"的初衷是矛盾的------超时值必须按 timeout × (retries + 1) 这个总耗时来评估,而不是只看单个 timeout。
如果提供方的 timeout 是 5000ms、消费者也是 5000ms 加 retries = 2,一次最坏失败就要占住线程 15 秒。线程池 200 个线程,13 个并发卡住请求就能把整个消费者打死。
两侧都配 retries 的放大陷阱
重试次数在 Provider 和 Consumer 两侧都配置时会叠加放大。直觉上以为两边的 2 次会"取较大值",实际上观测到的调用次数远超预期(社区里流传的口径是会放大到 3 × 3 = 9 次)。
Dubbo 官方并没有把两侧 retries 定义成一个简单的相乘公式,不同版本的行为也有差异,所以这里不下精确结论。但结论方向是明确的:两侧都配会导致调用次数被放大,只在一侧配置就行。
工程里的做法正好是个正确示范:
user-provider的application.yml里完全没有timeout/retries,只配了协议、注册中心、包扫描;user-consumer侧统一配retries = 0(注解 + yml 各一份),把重试这件事收敛到消费方手里。
消费方比提供方更清楚"这次调用能不能重试"------同一个 UserService.queryById,被查询接口调用时重试无害,被下单流程调用时重试就是事故。把 retry 的决策权留给调用方,比在提供方一刀切合理得多。
yaml
server:
port: 18081
spring:
application:
name: user-consumer
logging:
level:
cn.mf: debug
pattern:
dateformat: HH:mm:ss:SSS
#配置dubbo服务消费者
dubbo:
registry:
address: nacos://127.0.0.1:8848
consumer:
check: false #关闭了启动检查
retries: 0 #消费者全局不重试,写操作安全优先
顺便说一个和优先级相关的疑点。素材视频里说"我在 yml 里配了 0,其实已经覆盖了注解上的 retries"。按 Dubbo 的配置优先级,注解属于接口级、yml 里的 dubbo.consumer 属于全局级,接口级应当优先。本工程里两处恰好都是 0,看不出差别;遇到两者不一致的场景,建议先按官方文档确认覆盖关系,再写配置,别靠猜。
工程里的真实配置
tree
dubbo-demo
├── pom.xml
├── user-api
│ ├── pom.xml
│ └── src/main/java/cn/mf/user
│ ├── domain/User.java
│ └── service/UserService.java
├── user-provider
│ ├── pom.xml
│ └── src/main
│ ├── java/cn/mf/user
│ │ ├── UserProviderApplication.java
│ │ ├── mapper/UserMapper.java
│ │ └── service/impl
│ │ ├── UserServiceImpl.java # @DubboService(version = "1.0.0")
│ │ └── UserServiceImpl2.java # @DubboService(version = "2.0.0")
│ └── resources/application.yml # protocol 20881 / registry nacos / 无 timeout、无 retries
└── user-consumer
├── pom.xml
└── src/main
├── java/cn/mf/user
│ ├── UserConsumerApplication.java
│ └── controller/UserController.java # @DubboReference(version="2.0.0", retries=0, loadbalance="Random")
└── resources/application.yml # consumer.check=false / retries=0
提供方的 yml 完整内容,注意里面确实没有超时和重试:
yaml
server:
port: 18080
spring:
datasource:
url: jdbc:mysql://localhost:3306/dubbo-demo?useSSL=false
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
application:
name: user-provider
logging:
level:
cn.mf: debug
pattern:
dateformat: HH:mm:ss:SSS
#配置dubbo提供者
#dubbo协议和访问端口
dubbo:
protocol:
name: dubbo
port: 20881
#注册中心的地址
registry:
address: nacos://127.0.0.1:8848
#dubbo注解的包扫描
scan:
base-packages: cn.mf.user.service
消费者端的引用声明:
java
package cn.mf.user.controller;
import cn.mf.user.domain.User;
import cn.mf.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
// version 指定调用的提供者版本;retries = 0 关闭重试;loadbalance 指定负载均衡策略
@DubboReference(version = "2.0.0", retries = 0, loadbalance = "Random")
private UserService userService;
/**
* 路径: /user/usernames/1
*/
@GetMapping("/usernames/{id}")
public String queryUsername(@PathVariable("id") Long id) {
return userService.queryUsername(id);
}
/**
* 根据 id 查询用户
*/
@GetMapping("/{id}")
public User queryById(@PathVariable("id") Long id) {
return userService.queryById(id);
}
}
端口口径:user-provider 是 18080,user-consumer 是 18081,Dubbo 协议端口 20881,注册中心 nacos://127.0.0.1:8848。测试超时要访问的是消费者端口,不是提供者端口。
动手验证:故意制造一次慢调用
光看配置说明不了问题,直接让提供方慢下来,看超时到底怎么表现。
第一步 ,在提供方的 UserServiceImpl2.queryById 里塞一个 5 秒的睡眠,模拟慢 SQL:
java
package cn.mf.user.service.impl;
import cn.mf.user.domain.User;
import cn.mf.user.mapper.UserMapper;
import cn.mf.user.service.UserService;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
@DubboService(version = "2.0.0")
public class UserServiceImpl2 implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public String queryUsername(Long id) {
return userMapper.findById(id).getUsername();
}
@Override
public User queryById(Long id) {
try {
// 仅用于验证超时,生产代码里不要出现
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
User user = userMapper.findById(id);
user.setUsername(user.getUsername() + "v2.0");
return user;
}
}
第二步,把消费者的超时压到 1000ms。改 yml 最省事,重启消费者即可生效:
yaml
dubbo:
registry:
address: nacos://127.0.0.1:8848
consumer:
check: false
retries: 0
timeout: 1000 # 故意配得比提供方耗时短,制造超时
第三步,启动 Nacos、Provider、Consumer,请求消费者:
bash
curl -i http://localhost:18081/user/1
消费者立刻返回 500,控制台和日志里能看到这类异常:
text
org.apache.dubbo.rpc.RpcException: Failed to invoke the method queryById in the service
cn.mf.user.service.UserService. Tried 1 times of the providers [192.168.1.10:20881] (1/1)
from the registry 127.0.0.1:8848 on the consumer 192.168.1.10 using the dubbo version 3.x.x.
Last error is: Invoke remote method timeout. method: queryById, provider:
dubbo://192.168.1.10:20881/cn.mf.user.service.UserService?version=2.0.0, ...
cause: Waiting server-side response timeout by scan timer. start time: 2026-09-30 12:00:00.000,
end time: 2026-09-30 12:00:01.001, client elapsed: 1 ms, timeout: 1000 ms
几个要读准的点:
Tried 1 times------ 因为retries = 0,只发了一次。把它改成 2,这里会变成Tried 3 times,而且异常抛出的时间点会从 1 秒左右推迟到 3 秒左右,这是前面算术表的直接证据。timeout: 1000 ms------ 生效的超时值,看到这个数字能立刻确认配置有没有被前面层级覆盖。Waiting server-side response timeout------ 明确说了是等待服务端响应超时,是客户端放弃等待,不是服务端出错。
第四步 ,把 timeout 改成 6000 再重启消费者,同样请求:
bash
curl -i http://localhost:18081/user/1
这次 5 秒后返回 200,username 带着 v2.0 后缀。同一段慢代码,唯一变量就是超时值,结论不用解释。
再补一个更扎心的观察:第三步超时报错的那一刻,看提供方的日志------queryById 依然把 Thread.sleep(5000) 跑完了,userMapper.findById(id) 也照常执行。消费者的超时对提供方没有任何打断作用。这就是前面说的那个认知。
生产推荐配置
| 接口类型 | 超时建议 | 重试建议 | 理由 |
|---|---|---|---|
查询类(queryById / queryUsername) |
3000ms 起,按 P99 留 2~3 倍余量 | 保留默认 2 或不显式配 | 幂等,重试能吃掉网络抖动,提升成功率 |
| 复杂查询 / 报表类 | 单独放宽到 5000~10000ms | 0~1 | 耗时波动大,重试收益低、放大资源占用 |
| 写类(保存 / 下单 / 扣款 / 发券) | 3000~5000ms,按业务链路定 | retries = 0,禁止重试 |
非幂等,重试直接产生重复数据 |
| 调用第三方 / 外部 HTTP | 独立线程池 + 独立超时,通常 1000~3000ms | 0,由业务侧用幂等键重试 | 外部不可控,不能拖垮自研主链路 |
| 内部高频小接口(缓存、配置读取) | 500~1000ms | 0 | 快速失败优于等待,这类接口慢本身就是 BUG |
配套的三条落地要求:
- 超时值必须上下对齐。 消费者超时 > 提供方超时 > 下游 SQL 超时,这样异常能在正确的层报出来。反过来配(消费者 1000ms、SQL 超时 5000ms)只会得到一个信息量极低的"永远超时"。
- 写接口的幂等靠服务端保证,不能靠"关掉重试"来兜底。 上游网关、MQ 消费、用户重复点击都可能造成重复请求,所以唯一索引、幂等键、状态机这些手段还是要有。关掉重试只是消除掉一个最容易自己制造重复请求的来源。
- 全局扫一遍有没有人配过
retries。 提供方和消费方都配了要立刻收敛到一侧,否则调用次数会被放大,日志量和下游压力一起翻倍。
实战坑清单
| 坑 | 现象 | 处理 |
|---|---|---|
没配 timeout,吃默认 1000ms |
偶发超时告警,重启后消失 | 显式配置,按 P99 设值,别依赖默认值 |
| 写操作开了重试 | 一次点击生成 3 条记录 / 扣 3 次款 | 写接口 retries = 0,配合服务端幂等键 |
两侧都配 retries |
调用次数被放大(有 3×3 的说法),下游压力与日志量暴涨 | 只在一侧配,本工程收敛在 consumer 侧 |
| 超时比下游 SQL 超时还短 | 每次都超时,看日志却发现提供方"执行正常" | 自下而上对齐:SQL 超时 < 提供方 timeout < 消费方 timeout |
只看单个 timeout,忽略重试 |
以为超时 1s,实际线程被占 3s | 按 timeout × (retries + 1) 评估最坏耗时 |
| 以为超时能终止对端执行 | 报超时了,但数据已经落库 | 超时只是消费者放弃等待;写操作必须幂等 |
| 重试导致日志翻倍 | 同一请求 3 份日志,排查时误导 | 日志里带 traceId,或直接关掉写操作重试 |
| 超时值设得极短 | 正常网络抖动被误判成失败,成功率下降 | 按耗时分布设值,别拍脑袋压到 100ms |
API 速览
| 项 | 说明 |
|---|---|
timeout |
超时时间,单位毫秒,默认 1000ms |
retries |
重试次数,默认 2(总调用次数 = retries + 1 = 3 次) |
@DubboService(timeout = ...) |
服务级(提供方)配置 |
@DubboReference(timeout = ..., retries = ...) |
消费者级配置 |
dubbo.consumer.timeout / dubbo.consumer.retries |
消费者全局配置(yml) |
dubbo.provider.timeout / dubbo.provider.retries |
提供方全局配置(yml) |
@Method(name = "...", timeout = ...) |
方法级配置,需配合 methods = {...} 使用 |
@DubboReference(methods = @Method(...)) |
消费者侧的方法级配置 |
cluster |
集群容错策略,默认 failover(失败切换,重试时换实例) |
| 重试触发条件 | 仅 RPC 层异常(网络 / 超时)触发,业务异常直接抛出 |
| 优先级 | 方法级 > 接口级 > 全局级;同粒度下消费方 > 提供方(以官方文档为准) |
| 默认端口(本工程) | provider 18080 / consumer 18081 / dubbo 协议 20881 / nacos 127.0.0.1:8848 |
官方文档
总结
超时解决的是"消费者被慢提供者拖死",重试解决的是"网络抖动导致偶发失败",两者都要有,但都必须配在正确的位置、用正确的值。
- 超时是消费者本地放弃等待,不会中断对端执行。报超时但数据已写入,不是 BUG,是语义如此。
- 默认 1000ms 必须显式覆盖。线上"偶发超时"十有八九是这个默认值在背锅。
- 重试的前提是幂等。查询可以重试,增删改一律
retries = 0。 - 最坏耗时是
timeout × (retries + 1),评估线程占用时按这个算。 retries只配一侧。提供方和消费方都配会把调用次数放大,本工程收敛在 consumer 侧是个好习惯。
配好这两个参数,调用链上再出现"某个下游变慢",影响范围会被限制在少数失败请求里,而不是一路烧到网关。