Skip to content

Dubbo服务治理全过程:路由、负载均衡、Cluster容错与重试预算

Dubbo 服务治理不是“配置一个 random 和 retries”。它要回答:本次逻辑调用有哪些候选实例、哪些实例不允许进入、每次物理尝试选谁、失败后是否再次尝试、超时预算怎样消耗、重复副作用如何阻止、灰度规则怎样安全发布、Provider 过载时怎样拒绝而不是被拖死。

本页把治理放回完整调用链,严格区分“逻辑调用”和“物理尝试”。这是理解重试放大、超时叠加、路由为空和流量倾斜的基础。

一、学习目标

  1. 区分 Directory、Router、LoadBalance、Cluster 和 Invoker。
  2. 解释一次逻辑调用为什么可能产生多次 Provider 请求。
  3. 推导 Random、RoundRobin、LeastActive、ShortestResponse 和 ConsistentHash 的选择过程。
  4. 区分 Failover、Failfast、Failsafe、Failback、Forking、Broadcast 等 Cluster 行为。
  5. 设计端到端超时预算,而不是给每层随意填同一个 timeout。
  6. 解释重试为什么同时放大 QPS、并发、尾延迟和重复副作用。
  7. 设计标签灰度、条件路由、权重预热和动态规则发布。
  8. 根据路由前后候选数、每尝试日志、线程池和下游指标排查生产故障。

二、五层对象各自只回答一个问题

对象回答的问题不负责什么
Directory当前有哪些远程 Invoker不决定失败后重试几次
Router本次调用允许哪些候选不从最终候选中随机选一台
LoadBalance本次物理尝试选哪个 Invoker不决定是否再尝试
Cluster Invoker一次逻辑调用怎样组织一个或多个尝试不传输网络帧
Protocol Invoker怎样把一次尝试通过具体协议发出去不维护全局业务幂等
mermaid
flowchart TD
    A["接口代理收到一次逻辑调用"] --> B["Cluster Invoker"]
    B --> C["Directory列出当前Invoker快照"]
    C --> D["Router链过滤候选"]
    D --> E["LoadBalance为第1次尝试选实例"]
    E --> F["Protocol Invoker发送RPC"]
    F --> G{"Cluster是否认为需要再次尝试"}
    G -- "是" --> H["在预算内重新列举或选择候选"]
    H --> F
    G -- "否" --> I["返回Result或抛异常"]

负载均衡发生在 Consumer 本地,不是注册中心替 Consumer 转发。Cluster 通常位于 LoadBalance 外层,因为 Cluster 要决定是否产生下一次尝试,每一次尝试才需要选择实例。

三、逻辑调用、尝试和Provider执行

假设业务代码只写了一行:

java
stockService.query("SKU-1");

它是一次逻辑调用。若 Failover 配置 retries=2,通常含义是“首次调用失败后最多再重试 2 次”,理论最多产生:

text
总尝试次数 = 1 + retries = 3

每次尝试可能选择不同 Provider,也可能因候选不足、路由结果或实现版本再次选中同一 Provider。不能把 retries=2 理解为总共只调用两次。

3.1 重试放大怎样计算

如果入口 QPS 为 10,000,20% 的调用触发两次额外尝试,粗略额外请求量为:

text
额外QPS = 10,000 × 20% × 2 = 4,000
下游尝试QPS约为 14,000

若网关、业务服务、Dubbo 和数据库客户端都各自重试,最坏尝试数接近各层尝试次数的乘积,而不是相加:

text
网关2次 × 服务A的Dubbo3次 × 服务B的HTTP2次 = 最多12次下游尝试

实际会受超时和失败类型限制,但容量设计必须认识这种乘法风险。

3.2 超时为什么产生UNKNOWN

Consumer 超时只说明等待窗口结束,不说明 Provider 没执行:

  1. 请求可能尚未发送成功。
  2. 请求可能已到 Provider 队列。
  3. Provider 可能正在执行。
  4. 数据库事务可能已经提交。
  5. 只有响应在网络中丢失。

写操作超时后正确状态往往是 UNKNOWN,需要幂等键和事实查询收敛,不能直接当成失败后重新扣减。

四、负载均衡算法不是“平均分流”这么简单

算法主要信号优点主要风险
Random / Weighted Random静态或动态权重简单、无全局轮次状态、通用小样本不保证绝对均匀
RoundRobin轮次和权重长周期分配直观不感知慢实例实时压力
LeastActive当前活跃调用数倾向并发较少实例活跃数是客户端局部视角
ShortestResponse历史响应与活跃信息尝试避开慢实例历史数据滞后,易受窗口影响
ConsistentHash调用参数哈希和哈希环相同Key尽量固定实例负载不均、热点和节点变动

具体算法名称、默认值和实现细节随 Dubbo 版本演进。选型前应查看锁定版本的扩展清单和源码,不能把某个新版本算法写进老版本配置后认为已经生效。

五、加权随机原理

假设三个实例有效权重分别为 5、3、2,总权重为 10。构造连续区间:

text
Provider A: [0, 5)
Provider B: [5, 8)
Provider C: [8, 10)

[0,10) 生成随机数,落在哪段就选择哪个实例。因此长期概率约为 50%、30%、20%,但十次请求不保证正好 5、3、2。

mermaid
flowchart TD
    A["读取候选有效权重"] --> B["计算总权重"]
    B --> C{"权重是否相同"}
    C -- "不同" --> D["在总权重区间取随机值"]
    D --> E["依次扣减权重直到落入区间"]
    C -- "相同" --> F["在候选下标范围随机选择"]
    E --> G["返回Invoker"]
    F --> G

为什么默认场景常适合Random

  • 不需要维护全局轮次。
  • 大流量下概率自然收敛。
  • 权重可表达机器规格或发布状态。
  • 多 Consumer 各自随机,不容易因统一轮次同时打向同一实例。

六、权重预热为什么必要

新 Provider 刚启动时可能还在:

  • JIT 编译与类加载。
  • 连接池建立。
  • 本地缓存预热。
  • 磁盘页缓存升温。
  • 外部连接握手。

如果立即按满权重接流量,新实例 P99 上升并触发重试,反而在上线时制造故障。Warmup 会根据启动时间和预热窗口把配置权重折算为较小有效权重,再逐步恢复。

权重不是容量控制的替代品。把故障实例权重调低只能减少新流量,不能清理已排队请求,也不能解决数据库瓶颈。

七、加权轮询原理与边界

普通轮询按 A、B、C、A、B、C 顺序选择;加权轮询让高权重实例在一个周期中出现更多次。不同 Dubbo 版本的加权轮询状态维护和“平滑”实现可能不同,因此重点应理解状态而非死背某个字段。

轮询不感知实例处理速度。若 A 每次 50ms、B 每次 2s,但权重相同,B 仍持续接收相近请求数,活跃请求和队列会堆积。此时应先解决慢实例或健康治理,而不是期望轮询自动避开。

八、LeastActive怎样近似感知压力

Active 指某个 Consumer 视角下,某服务方法发出但尚未完成的调用数。调用前增加,响应或异常结束后在 finally 中减少。

mermaid
flowchart TD
    A["读取每个Invoker当前active"] --> B["找到最小active值"]
    B --> C["保留所有最小active候选"]
    C --> D{"候选是否只有一个"}
    D -- "是" --> E["直接选择"]
    D -- "否" --> F["再按权重随机选择"]

为什么 active 能反映速度:同样到达率下,慢请求完成得晚,未完成调用数更容易积累。

边界:

  • 它通常是单个 Consumer 的局部统计,不是 Provider 全局并发。
  • Consumer 刚启动时没有历史状态。
  • 请求复杂度不同,active=1 不代表资源成本相同。
  • Provider 可能被其他 Consumer 或非 Dubbo 流量打满。

九、ShortestResponse为什么不能预测未来

最短响应类算法依据近期完成请求的耗时统计,并可能结合活跃数筛选。它只能用历史近似未来:

  • 实例刚发生 Full GC,历史仍可能很好。
  • 慢请求尚未完成,统计样本还没更新。
  • 少量异常值会影响窗口。
  • 新实例没有稳定样本。

所以最短响应不是熔断器,也不是健康检查。它只能改善选择概率,无法保证不选慢实例。

十、一致性哈希全过程

一致性哈希把 Provider 的多个虚拟节点放到哈希环上,再对选定调用参数计算哈希,顺时针找到第一个虚拟节点。

mermaid
flowchart TD
    A["Provider地址生成多个虚拟节点"] --> B["虚拟节点按Hash排序成环"]
    C["提取配置的调用参数"] --> D["计算参数Hash"]
    D --> E["在环上找顺时针第一个节点"]
    B --> E
    E --> F["选择节点对应Invoker"]

适合:同一用户尽量访问同一 Provider 本地缓存、无状态路由后的局部亲和。

不适合盲用的原因:

  • 热点 key 会把流量集中到单实例。
  • Provider 本地状态丢失后仍需后端事实源恢复。
  • 节点增删会迁移部分 key,引发缓存穿透和抖动。
  • 选择哪个参数参与哈希必须稳定;DTO 序列化字符串变化可能改变路由。
  • 它不是 Session 一致性或数据一致性的保证。

十一、Cluster容错策略逐个讲清

11.1 Failover

首次失败后,在允许的失败类型、重试次数和超时条件下选择其他 Invoker 再尝试。常用于幂等读。它提升瞬时故障成功率,但增加延迟和下游压力。

一般会尽量避免重复选择已尝试实例;候选不足时仍可能再次选择。业务不能假设“每次重试一定是不同机器”。

11.2 Failfast

一次尝试失败立即返回,不在 Cluster 层继续尝试。适合非幂等写、参数校验、明确业务拒绝。Failfast 不代表网络绝对只发送一次:上游网关、业务代码或用户仍可能重试,所以 Provider 端仍要幂等。

11.3 Failsafe

调用失败后记录日志并返回空结果或默认结果,避免异常影响主链。只适合审计旁路、非关键通知等允许丢失或有独立补偿的场景。

如果把支付校验、扣库存失败吞掉,上游可能继续返回成功,形成数据错误。Failsafe 是业务语义选择,不是“更稳定”的开关。

11.4 Failback

失败后先返回或记录,再由后台任务重试。它需要持久化、幂等、重试上限、死信和告警才能称为可靠最终一致。仅依靠进程内定时重试,进程重启后任务可能丢失,不能替代 MQ 或 Outbox。

11.5 Forking

并行调用多个 Provider,任一成功就返回。它用额外资源换尾延迟:

text
入口1次逻辑调用 → 同时产生N次Provider执行

未被采用的请求不一定能真正取消,Provider 仍可能完成执行。因此只适合无副作用读,并必须评估 QPS 放大和成本。

11.6 Broadcast

依次或按版本实现调用所有 Provider,常用于刷新每个实例的本地缓存。部分成功、部分失败时不能简单用一个布尔值描述结果;操作应幂等,并记录每个实例的完成状态。

Provider 数量增加时广播成本线性增长。大集群更适合通过配置版本、事件或集中缓存失效机制收敛。

11.7 Available、Mergeable等版本化策略

Dubbo 某些版本还提供 Available、Mergeable、ZoneAware 等 Cluster 扩展或组合能力。它们的可用性、配置名和语义随版本变化:

  • Available 倾向选择当前可用 Invoker,但“isAvailable”为客户端近似,不等于业务健康。
  • Mergeable 可合并多个 Provider 返回,适合特定聚合,不适合通用写调用。
  • ZoneAware 与多注册中心、区域路由相关,应结合版本和部署拓扑理解。

生产选型必须查看当前版本扩展清单,不能把“Dubbo 曾经有”当作“当前默认支持且语义不变”。

十二、哪些失败可以重试

失败默认是否适合重试原因
连接建立失败幂等操作可考虑Provider可能未收到
明确发送前失败幂等操作可考虑副作用概率较低,但要有证据
Consumer等待超时写操作不应盲重试Provider可能已提交
Provider业务异常通常不应换实例重试参数或业务条件不会因换机器改变
限流/过载拒绝立即重试常更糟会放大过载,应退避或快速失败
序列化异常不应重试契约问题不会自动恢复
短暂网络断连幂等读可预算内重试可能绕过单实例故障

框架对具体异常的分类会随版本和 Cluster 实现不同。业务可靠性不能只依赖默认异常分类,应通过压测和故障注入验证。

十三、端到端超时预算

假设网关对订单接口的 SLO 是 1200ms,订单服务内部要完成校验、库存、优惠和数据库提交。不能给每个下游都配置 1200ms:串行链路会远超入口预算。

text
入口预算1200ms
- 网关与网络100ms
- 本地处理100ms
- 库存首次尝试300ms
- 必要时一次重试200ms
- 数据库提交300ms
- 安全余量200ms
mermaid
flowchart TD
    A["入口建立Deadline"] --> B["扣除已消耗时间"]
    B --> C["为当前下游分配剩余预算的一部分"]
    C --> D["一次尝试执行"]
    D --> E{"失败且剩余预算足够"}
    E -- "是" --> F["退避并执行有限重试"]
    F --> B
    E -- "否" --> G["快速返回并进入补偿或降级"]

经典 Dubbo 配置中的 timeout 可能更接近单次调用/接口超时,不同版本和协议对 deadline 传播支持不同。若没有端到端 Deadline,应在业务入口记录截止时间,并避免每层独立重试把总耗时无限拉长。

十四、重试必须有预算、退避和抖动

合理重试至少需要:

  • 最大尝试次数。
  • 总时间预算。
  • 仅重试可恢复异常。
  • 指数退避。
  • 随机抖动,避免所有 Consumer 同时再次请求。
  • 重试配额或令牌预算,故障时限制重试流量比例。
  • 每次尝试可观测。

立即重试同一故障通常不会给下游恢复时间。若 1000 个 Consumer 同时每 100ms 重试,可能形成周期性流量脉冲。

十五、写接口:Failfast也不能替代幂等

库存扣减请求:

java
public class StockDeductRequest {
    private String requestId;
    private String orderNo;
    private String skuId;
    private int count;

    // getter and setter
}

数据库中使用唯一请求号,并让业务更新与幂等记录处于同一本地事务:

sql
CREATE TABLE stock_deduct_log (
    request_id VARCHAR(64) PRIMARY KEY,
    order_no VARCHAR(64) NOT NULL,
    sku_id VARCHAR(64) NOT NULL,
    result_code VARCHAR(32) NOT NULL,
    created_at TIMESTAMP NOT NULL
);

不能使用以下 check-then-act:

java
if (!repository.exists(requestId)) {
    repository.deduct();
    repository.insertLog(requestId);
}

两个并发请求可能同时查到不存在。应让唯一约束或条件更新成为原子裁决,并在重复请求中返回第一次结果。超时后使用 requestId 查询事实状态。

十六、Router链:先决定“允许谁”,再决定“选谁”

路由可以按顺序组合多类规则:

  • 服务身份和协议兼容过滤。
  • 应用、机房、区域路由。
  • Tag 路由。
  • Condition 路由。
  • 灰度、参数或附件规则。
  • Dubbo 3 特定版本的 Mesh/流量治理规则。
mermaid
flowchart TD
    A["Directory原始候选10个"] --> B["协议与可用性过滤剩8个"]
    B --> C["区域路由保留同机房5个"]
    C --> D["Tag路由保留gray 2个"]
    D --> E["LoadBalance从2个中选1个"]

出现 No provider 时必须记录每个 Router 前后的候选数量。注册中心有 10 个地址,不代表路由后仍有地址。

十七、Tag灰度全过程

一种典型灰度:

  1. 发布少量新 Provider,并赋予 gray 标签。
  2. 只有满足条件的 Consumer 请求携带 gray 路由标记。
  3. Tag Router 把灰度请求限制在灰度实例。
  4. 无灰度标记的稳定流量继续访问 stable 实例。
  5. 对比成功率、P99、业务指标和资源指标。
  6. 分阶段扩大或撤销规则。

强制路由与降级路由

若灰度标签没有匹配实例:

  • 强制语义可以直接失败,防止灰度请求污染稳定集群。
  • 非强制语义可能回退到无标签实例,提高可用性但破坏隔离。

选哪一种取决于业务:数据格式不兼容时宁可失败也不能回退;纯算法灰度可能允许回退。规则必须明确,不要依赖默认值。

十八、动态配置和优先级不能只靠背表

Dubbo 有应用级、服务级、方法级配置,来源可能包括注解/XML、Consumer、Provider、配置中心和治理规则。通常越具体的服务/方法规则越有针对性,动态治理规则还可能覆盖静态值,但精确优先级与字段合并受 Dubbo 版本、配置模型和生效侧影响。

生产排查应取得“最终有效 URL/配置”证据:

  1. 记录配置来源和规则版本。
  2. 查看 Consumer 侧最终 Invoker URL。
  3. 确认规则匹配的是应用、接口还是方法。
  4. 确认规则在 Consumer 侧还是 Provider 侧生效。
  5. 观察配置推送后新调用是否携带新值。

不要只看配置中心页面中的一行 timeout 就断言已经生效。

十九、Sticky与一致性哈希不是一回事

Sticky 通常尝试在候选仍可用时复用上一次选中的 Invoker;一致性哈希根据调用参数映射实例。

能力决定依据典型目的
StickyConsumer引用上次选择减少实例切换
ConsistentHash指定参数的Hash相同业务Key亲和

两者都会降低流量自由迁移能力。慢实例、热点或本地状态问题可能被放大,不应作为分布式 Session 或缓存一致性的基础。

二十、Provider保护:拒绝比排队到雪崩更健康

Provider 容量受 CPU、线程池、数据库连接、下游配额和锁竞争共同约束。

mermaid
flowchart TD
    A["RPC请求到达Provider"] --> B{"限流或并发许可"}
    B -- "拒绝" --> C["快速返回明确过载错误"]
    B -- "允许" --> D{"线程池是否有执行能力"}
    D -- "无" --> E["拒绝并记录queue/reject指标"]
    D -- "有" --> F["执行业务并访问下游"]
    F --> G["finally释放active和许可"]

为什么不能只加线程

数据库只有 50 个连接,把 Provider 线程从 200 加到 1000,只会让更多线程等待连接、占用内存并增加上下文切换。队列加大则把失败改造成长尾延迟和超时重试。

保护组合包括:

  • 入口限流和并发限制。
  • 接口/方法线程池隔离,是否支持取决于部署和版本。
  • 有界队列与明确拒绝。
  • Consumer 总预算和熔断。
  • Provider 到数据库/下游的容量匹配。
  • 过载错误不立即重试。

20.1 方法级治理:threads、queues、executes、actives不要混

Dubbo 里很多“限制并发”的配置看起来相似,但作用位置不同。面试和排查时必须先问:限制的是 Provider 线程池、Provider 某方法执行数、Consumer 某方法在途数,还是连接数。

参数/能力典型作用侧主要限制对象解决的问题边界
threadsProvider业务线程池线程数控制同时执行业务的线程总量不能超过下游容量太多
queuesProvider业务线程池等待队列吸收短突发或快速拒绝大队列会制造长尾和UNKNOWN
executesProvider方法级某服务/方法并发执行数防止单个慢方法占满Provider配置语义和粒度需按版本核对
activesConsumer方法级常见某Consumer对某方法的在途调用数防止调用方无限压下游多Consumer总量仍可能很大
connectionsConsumer到Provider连接数量控制长连接和复用连接多不等于处理能力强
acceptsProvider端接入连接数防止连接风暴不等于业务请求限流

可以把它们放到调用链里理解:

mermaid
flowchart TD
    A["Consumer发起调用"] --> B{"actives是否允许"}
    B -- "不允许" --> C["Consumer本地快速失败或等待"]
    B -- "允许" --> D["选择Provider并取得连接"]
    D --> E{"Provider accepts/connections是否可用"}
    E -- "不可用" --> F["连接失败或被拒绝"]
    E -- "可用" --> G["请求到达Provider"]
    G --> H{"executes是否允许该方法执行"}
    H -- "不允许" --> I["Provider方法级拒绝"]
    H -- "允许" --> J{"线程池threads/queues是否可承载"}
    J -- "否" --> K["线程池拒绝"]
    J -- "是" --> L["执行业务并访问DB/Redis/下游"]

为什么要方法级治理?因为一个 Provider 进程里可能同时暴露“核心短查询”和“慢导出/批处理”:

方法耗时风险
queryOrderStatus20ms核心查询,应该稳定
exportOrderReport30s慢任务,容易占线程
syncHospitalAssets5s外部接口慢,容易堆积

如果所有方法共用同一个线程池和无限队列,慢导出会占住线程,核心查询也排队超时。正确思路是:

  1. 高成本方法单独限并发或拆服务。
  2. 核心方法保留独立容量。
  3. 慢任务优先异步化,使用 MQ、XXL-JOB 或批处理队列。
  4. Consumer 侧用 actives 控制自己不要打爆下游。
  5. Provider 侧用 executes、线程池、队列和限流兜底。
  6. 业务写入仍用幂等键和状态机,不把并发限制当正确性保证。

20.2 JDK 8 Demo:方法级并发隔离模型

下面 Demo 不依赖 Dubbo,只用最小代码模拟 activesexecutes 的差别:actives 发生在 Consumer 侧,控制“我这个调用方最多压多少请求”;executes 发生在 Provider 侧,控制“这个方法最多同时执行多少个”。

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class DubboMethodIsolationDemo {

    static class ConsumerActiveLimiter {
        private final Semaphore permits;

        ConsumerActiveLimiter(int maxActive) {
            this.permits = new Semaphore(maxActive);
        }

        String call(ProviderExecuteLimiter provider, String method) throws Exception {
            if (!permits.tryAcquire(100, TimeUnit.MILLISECONDS)) {
                return "consumer reject: actives limit";
            }
            try {
                return provider.invoke(method);
            } finally {
                permits.release();
            }
        }
    }

    static class ProviderExecuteLimiter {
        private final Semaphore slowMethodPermits = new Semaphore(1);

        String invoke(String method) throws Exception {
            if ("exportReport".equals(method)) {
                if (!slowMethodPermits.tryAcquire()) {
                    return "provider reject: executes limit for " + method;
                }
                try {
                    Thread.sleep(300);
                    return "ok: " + method;
                } finally {
                    slowMethodPermits.release();
                }
            }
            Thread.sleep(20);
            return "ok: " + method;
        }
    }

    public static void main(String[] args) throws Exception {
        final ConsumerActiveLimiter consumer = new ConsumerActiveLimiter(2);
        final ProviderExecuteLimiter provider = new ProviderExecuteLimiter();
        ExecutorService pool = Executors.newFixedThreadPool(4);

        for (int i = 0; i < 4; i++) {
            pool.submit(new Runnable() {
                public void run() {
                    try {
                        System.out.println(consumer.call(provider, "exportReport"));
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
        }

        pool.shutdown();
        pool.awaitTermination(2, TimeUnit.SECONDS);
    }
}

这个 Demo 表达两个重点:

现象原理
Consumer 侧可能先拒绝调用还没到 Provider,就被本地 actives 保护住
Provider 侧也可能拒绝请求到了 Provider,但目标方法超过 executes 保护

真实 Dubbo 的配置名、默认值、Filter 和异常类型要按版本确认,但容量思想不变:限制越靠前,越能少浪费资源;限制越靠近业务方法,越能精准保护热点方法

20.3 Provider线程池满的证据链

遇到 Provider 线程池满,不要直接改大线程数。按下面链路取证:

mermaid
flowchart TD
    A["Provider线程池满"] --> B["按接口/方法拆active和P99"]
    B --> C["查queue、reject和等待时间"]
    C --> D["连续抓线程栈"]
    D --> E{"线程主要卡在哪里"}
    E -- "DB连接等待" --> F["查连接池、慢SQL和事务"]
    E -- "锁等待" --> G["查synchronized/Lock/行锁"]
    E -- "下游RPC/HTTP" --> H["查下游P99、超时和重试"]
    E -- "CPU" --> I["查火焰图、GC和热点代码"]
    F --> J["限流、隔离、优化瓶颈后再调容量"]
    G --> J
    H --> J
    I --> J

要保存的证据:

证据说明
方法维度 QPS、P95、P99判断是哪几个方法拖垮
active/executes/actives判断是Provider执行满还是Consumer压太多
queue/reject判断是快速拒绝还是排队堆积
Consumer attempt分布判断Failover是否放大
线程栈判断线程到底在等什么
DB/Redis/下游指标判断真正瓶颈是否在共享依赖

调参原则:

调整何时做不能解决什么
增大线程CPU有余量、下游容量充足、线程确实不够不能解决慢SQL和连接池不足
减小队列P99过高、超时后仍执行不能提升真实吞吐
限制慢方法单个方法拖垮全服务不能替代业务拆分
降低Consumer并发调用方把下游打满不能解决所有Consumer总量
拆服务/拆线程池慢任务和核心查询混部增加部署与治理成本

二十一、Cluster容错、熔断和降级的区别

能力触发点作用
Cluster容错单次逻辑调用内部决定重试、并行、吞错或广播
熔断一段时间错误率/慢调用达到阈值暂停真实调用并快速失败,之后半开探测
降级业务允许的替代路径返回缓存、默认值或关闭非核心能力
限流请求进入前或执行前控制速率/并发不超过容量

Dubbo Mock 可提供固定或自定义 fallback,但不能给核心写操作返回假成功。支付、库存、资产入库等链路应返回明确状态,并用事务、消息或补偿收敛。

二十二、JDK 8 Demo:加权随机与有限Failover

java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;

public class DubboGovernanceDemo {

    static final class Node {
        final String name;
        final int weight;
        int failuresBeforeSuccess;

        Node(String name, int weight, int failuresBeforeSuccess) {
            this.name = name;
            this.weight = weight;
            this.failuresBeforeSuccess = failuresBeforeSuccess;
        }

        String invoke() {
            if (failuresBeforeSuccess-- > 0) {
                throw new RuntimeException("temporary failure: " + name);
            }
            return "OK:" + name;
        }
    }

    static Node weightedRandom(List<Node> nodes, Set<String> tried, Random random) {
        List<Node> candidates = new ArrayList<Node>();
        int total = 0;
        for (Node node : nodes) {
            if (!tried.contains(node.name)) {
                candidates.add(node);
                total += node.weight;
            }
        }
        if (candidates.isEmpty()) {
            candidates.addAll(nodes);
            for (Node node : nodes) total += node.weight;
        }

        int offset = random.nextInt(total);
        for (Node node : candidates) {
            offset -= node.weight;
            if (offset < 0) return node;
        }
        throw new IllegalStateException("no candidate");
    }

    static String failover(List<Node> nodes, int retries, Random random) {
        Set<String> tried = new HashSet<String>();
        RuntimeException last = null;
        for (int attempt = 1; attempt <= retries + 1; attempt++) {
            Node selected = weightedRandom(nodes, tried, random);
            tried.add(selected.name);
            System.out.println("attempt=" + attempt + ", node=" + selected.name);
            try {
                return selected.invoke();
            } catch (RuntimeException ex) {
                last = ex;
            }
        }
        throw last;
    }

    public static void main(String[] args) {
        List<Node> nodes = Arrays.asList(
                new Node("provider-A", 5, 1),
                new Node("provider-B", 3, 1),
                new Node("provider-C", 2, 0));
        System.out.println(failover(nodes, 2, new Random(7)));
    }
}

预期会在最多 1 + retries = 3 次内结束,并打印每次物理尝试。该 Demo 用固定随机种子便于复现;真实 Dubbo 还会处理 Router、调用异常分类、超时、可用状态和 RpcContext。

二十三、商业场景选型

场景推荐组合为什么
商品详情只读查询Random/LeastActive + 有限Failover幂等,可绕过瞬时实例故障
库存扣减Failfast + retries=0 + 幂等键防止UNKNOWN后重复副作用
用户画像本地缓存ConsistentHash + 后端事实源提高Key亲和,但不能依赖本地状态永久正确
刷新少量实例缓存幂等Broadcast所有实例需要执行,必须记录部分失败
非核心埋点MQ优先,必要时Failsafe不阻塞主链,但要接受语义边界
核心通知最终一致Outbox/MQ,不只用进程内Failback进程重启后任务仍可恢复
新算法灰度Tag Router + 可回退策略小流量验证并可快速撤销
新数据协议灰度强制Tag/Version隔离防止回退到不兼容旧实例

二十四、生产排查Runbook

24.1 流量严重打偏

  1. 比较 Directory 原始候选和 Router 后候选。
  2. 查看每个实例有效权重,不只看配置权重。
  3. 确认是否处于 warmup。
  4. 检查 Tag、Condition、区域和 Sticky 规则。
  5. 按 Consumer 分组观察;全局均匀不代表单个 Consumer 均匀。
  6. 检查实例是否频繁上下线导致哈希环或统计重置。

24.2 P99升高且QPS突然放大

  1. 比较入口逻辑调用 QPS 与 Provider 尝试 QPS。
  2. 统计每次逻辑调用的 attempt 次数分布。
  3. 查网关、Dubbo、HTTP、数据库是否多层重试。
  4. 查看 Provider active、queue、reject、线程栈和数据库连接池。
  5. 先关闭或收紧非必要重试,避免故障继续放大。
  6. 对过载错误使用退避、熔断或快速失败。

24.3 写请求出现重复

不要只搜索 Dubbo retries:还要检查用户重复提交、网关重试、定时补偿、MQ重复消费和人工重放。以业务幂等键查询所有尝试,确认第一次是否已经提交,再修复唯一裁决和结果复用。

24.4 灰度流量进入稳定集群

检查请求附件中的 tag、Filter 是否透传、Tag Router 强制/回退语义、规则版本和灰度实例注册信息。若协议不兼容,应立即使用强隔离并撤销错误规则。

24.5 Provider线程池满

  1. 按接口/方法看 active、执行耗时和队列。
  2. 获取多次线程栈,区分 CPU、锁、DB连接、慢SQL和下游等待。
  3. 查 Consumer 重试和 Forking 放大。
  4. 查数据库、Redis和下游容量。
  5. 用限流、隔离和快速拒绝止损。
  6. 修复真正慢点后再评估线程与队列,不要直接无限扩容。

二十五、必须具备的可观测性

  • 逻辑调用次数与物理尝试次数。
  • attempt 序号、最终选中 Provider、是否重试。
  • Router 各阶段候选数。
  • 每实例有效权重、active、响应时间、成功率。
  • Consumer timeout、连接失败、业务异常、过载拒绝分类。
  • Provider active、线程池队列、拒绝、执行时间。
  • 灰度 tag、规则版本、配置来源。
  • 幂等命中和重复执行拦截次数。
  • 入口 Deadline 与每次尝试剩余预算。

仅记录“Dubbo调用失败”无法判断是无地址、路由为空、选中慢实例、Provider业务异常还是重试预算耗尽。

二十六、常见错误及后果

错误后果
认为 retries=2 总共调用2次低估一次额外尝试和容量
写接口使用Failover但没有幂等重复扣减、重复创建
Provider过载错误立即重试雪崩和重试风暴
Forking用于写接口多实例同时产生副作用
RoundRobin会自动避开慢实例慢实例持续积压
ConsistentHash保证数据一致本地状态丢失或迁移后数据错误
配置中心有规则就认为已生效Consumer最终URL可能未更新
只看注册中心地址数Router后候选可能为零
线程池满就增加线程和队列把数据库瓶颈放大成长尾和OOM

二十七、面试标准回答

Dubbo 一次调用先由 Cluster Invoker 组织逻辑调用,Directory 提供当前 Invoker 快照,Router 按标签、条件、区域等过滤候选,LoadBalance 为每一次物理尝试选择一个 Provider,Protocol Invoker 再发送请求。Failover 的 retries=N 通常表示首次失败后再尝试 N 次,所以总尝试最多 N+1;每次超时都不代表 Provider 没执行,写接口应 Failfast、关闭框架重试并使用业务幂等键和事实查询。Random按有效权重做概率选择,LeastActive使用Consumer局部未完成调用近似实例压力,ConsistentHash提供参数亲和但不保证数据一致。治理还必须包含端到端Deadline、退避抖动、路由前后候选监控、权重预热、Provider限流和线程池保护,否则负载均衡与重试会在故障时反向放大流量。

二十八、关联知识点

本章小结

Dubbo 治理是一条“Directory列举 → Router过滤 → LoadBalance选一次 → Protocol发送 → Cluster决定下一步”的执行链。负载算法只能改善选择概率,容错策略只能组织尝试;它们都不能消除网络不确定性、替代业务幂等或突破下游容量。能推导尝试数、超时预算、路由候选和Provider资源,才能在生产中真正驾驭Dubbo,而不是只会背几个策略名称。