Java 项目实战: 交友项目 - Dubbo 超时与重试

概述

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 侧是个好习惯。

配好这两个参数,调用链上再出现"某个下游变慢",影响范围会被限制在少数失败请求里,而不是一路烧到网关。

相关推荐
BD_Marathon1 小时前
ChatPromptTemplate的高级特性
android·java·javascript
要开心吖ZSH1 小时前
定时任务是用外部调度(xxl-job)还是本地化( @Scheduled)更香?以号源刷新任务为例?
java·xxl-job·定时任务·scheduled
nagualky1231 小时前
GPT-4 系列退役在即:一套可回滚的模型迁移验收法
java·开发语言·ai·架构
今天的砖头有点烫手啊1 小时前
Java 8 Stream 原理拆解:惰性求值与并行流陷阱
java·jvm
starzy19901 小时前
Flink 任务调度 Graph 源码深度剖析:从 StreamGraph 到 ExecutionGraph 的三层演进
java·大数据·flink
Eyfcom1 小时前
〖实战〗快运系统 Oracle 性能优化:从“能用”到“扛住双11”的 Java + Spring Boot 实战
java·oracle·性能优化·快递管理系统
m0_587383001 小时前
碰碰好评卡功能落地全流程实战指南 附核心逻辑与常见问题解决方案
java·前端·数据库·后端·struts·需求分析
cheems95271 小时前
从统一响应到 Swagger:一篇讲清微服务接口文档设计未命名
java·微服务·架构·状态模式
Y3815326621 小时前
SerpBase + RAG 实战:用搜索 API 给知识库接上实时数据,回答不再过期
java·服务器·数据库