Dubbo服务治理全过程:路由、负载均衡、Cluster容错与重试预算
Dubbo 服务治理不是“配置一个 random 和 retries”。它要回答:本次逻辑调用有哪些候选实例、哪些实例不允许进入、每次物理尝试选谁、失败后是否再次尝试、超时预算怎样消耗、重复副作用如何阻止、灰度规则怎样安全发布、Provider 过载时怎样拒绝而不是被拖死。
本页把治理放回完整调用链,严格区分“逻辑调用”和“物理尝试”。这是理解重试放大、超时叠加、路由为空和流量倾斜的基础。
一、学习目标
- 区分 Directory、Router、LoadBalance、Cluster 和 Invoker。
- 解释一次逻辑调用为什么可能产生多次 Provider 请求。
- 推导 Random、RoundRobin、LeastActive、ShortestResponse 和 ConsistentHash 的选择过程。
- 区分 Failover、Failfast、Failsafe、Failback、Forking、Broadcast 等 Cluster 行为。
- 设计端到端超时预算,而不是给每层随意填同一个 timeout。
- 解释重试为什么同时放大 QPS、并发、尾延迟和重复副作用。
- 设计标签灰度、条件路由、权重预热和动态规则发布。
- 根据路由前后候选数、每尝试日志、线程池和下游指标排查生产故障。
二、五层对象各自只回答一个问题
| 对象 | 回答的问题 | 不负责什么 |
|---|---|---|
| Directory | 当前有哪些远程 Invoker | 不决定失败后重试几次 |
| Router | 本次调用允许哪些候选 | 不从最终候选中随机选一台 |
| LoadBalance | 本次物理尝试选哪个 Invoker | 不决定是否再尝试 |
| Cluster Invoker | 一次逻辑调用怎样组织一个或多个尝试 | 不传输网络帧 |
| Protocol Invoker | 怎样把一次尝试通过具体协议发出去 | 不维护全局业务幂等 |
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执行
假设业务代码只写了一行:
stockService.query("SKU-1");它是一次逻辑调用。若 Failover 配置 retries=2,通常含义是“首次调用失败后最多再重试 2 次”,理论最多产生:
总尝试次数 = 1 + retries = 3每次尝试可能选择不同 Provider,也可能因候选不足、路由结果或实现版本再次选中同一 Provider。不能把 retries=2 理解为总共只调用两次。
3.1 重试放大怎样计算
如果入口 QPS 为 10,000,20% 的调用触发两次额外尝试,粗略额外请求量为:
额外QPS = 10,000 × 20% × 2 = 4,000
下游尝试QPS约为 14,000若网关、业务服务、Dubbo 和数据库客户端都各自重试,最坏尝试数接近各层尝试次数的乘积,而不是相加:
网关2次 × 服务A的Dubbo3次 × 服务B的HTTP2次 = 最多12次下游尝试实际会受超时和失败类型限制,但容量设计必须认识这种乘法风险。
3.2 超时为什么产生UNKNOWN
Consumer 超时只说明等待窗口结束,不说明 Provider 没执行:
- 请求可能尚未发送成功。
- 请求可能已到 Provider 队列。
- Provider 可能正在执行。
- 数据库事务可能已经提交。
- 只有响应在网络中丢失。
写操作超时后正确状态往往是 UNKNOWN,需要幂等键和事实查询收敛,不能直接当成失败后重新扣减。
四、负载均衡算法不是“平均分流”这么简单
| 算法 | 主要信号 | 优点 | 主要风险 |
|---|---|---|---|
| Random / Weighted Random | 静态或动态权重 | 简单、无全局轮次状态、通用 | 小样本不保证绝对均匀 |
| RoundRobin | 轮次和权重 | 长周期分配直观 | 不感知慢实例实时压力 |
| LeastActive | 当前活跃调用数 | 倾向并发较少实例 | 活跃数是客户端局部视角 |
| ShortestResponse | 历史响应与活跃信息 | 尝试避开慢实例 | 历史数据滞后,易受窗口影响 |
| ConsistentHash | 调用参数哈希和哈希环 | 相同Key尽量固定实例 | 负载不均、热点和节点变动 |
具体算法名称、默认值和实现细节随 Dubbo 版本演进。选型前应查看锁定版本的扩展清单和源码,不能把某个新版本算法写进老版本配置后认为已经生效。
五、加权随机原理
假设三个实例有效权重分别为 5、3、2,总权重为 10。构造连续区间:
Provider A: [0, 5)
Provider B: [5, 8)
Provider C: [8, 10)在 [0,10) 生成随机数,落在哪段就选择哪个实例。因此长期概率约为 50%、30%、20%,但十次请求不保证正好 5、3、2。
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 中减少。
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 的多个虚拟节点放到哈希环上,再对选定调用参数计算哈希,顺时针找到第一个虚拟节点。
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,任一成功就返回。它用额外资源换尾延迟:
入口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:串行链路会远超入口预算。
入口预算1200ms
- 网关与网络100ms
- 本地处理100ms
- 库存首次尝试300ms
- 必要时一次重试200ms
- 数据库提交300ms
- 安全余量200msflowchart 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也不能替代幂等
库存扣减请求:
public class StockDeductRequest {
private String requestId;
private String orderNo;
private String skuId;
private int count;
// getter and setter
}数据库中使用唯一请求号,并让业务更新与幂等记录处于同一本地事务:
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:
if (!repository.exists(requestId)) {
repository.deduct();
repository.insertLog(requestId);
}两个并发请求可能同时查到不存在。应让唯一约束或条件更新成为原子裁决,并在重复请求中返回第一次结果。超时后使用 requestId 查询事实状态。
十六、Router链:先决定“允许谁”,再决定“选谁”
路由可以按顺序组合多类规则:
- 服务身份和协议兼容过滤。
- 应用、机房、区域路由。
- Tag 路由。
- Condition 路由。
- 灰度、参数或附件规则。
- Dubbo 3 特定版本的 Mesh/流量治理规则。
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灰度全过程
一种典型灰度:
- 发布少量新 Provider,并赋予
gray标签。 - 只有满足条件的 Consumer 请求携带
gray路由标记。 - Tag Router 把灰度请求限制在灰度实例。
- 无灰度标记的稳定流量继续访问 stable 实例。
- 对比成功率、P99、业务指标和资源指标。
- 分阶段扩大或撤销规则。
强制路由与降级路由
若灰度标签没有匹配实例:
- 强制语义可以直接失败,防止灰度请求污染稳定集群。
- 非强制语义可能回退到无标签实例,提高可用性但破坏隔离。
选哪一种取决于业务:数据格式不兼容时宁可失败也不能回退;纯算法灰度可能允许回退。规则必须明确,不要依赖默认值。
十八、动态配置和优先级不能只靠背表
Dubbo 有应用级、服务级、方法级配置,来源可能包括注解/XML、Consumer、Provider、配置中心和治理规则。通常越具体的服务/方法规则越有针对性,动态治理规则还可能覆盖静态值,但精确优先级与字段合并受 Dubbo 版本、配置模型和生效侧影响。
生产排查应取得“最终有效 URL/配置”证据:
- 记录配置来源和规则版本。
- 查看 Consumer 侧最终 Invoker URL。
- 确认规则匹配的是应用、接口还是方法。
- 确认规则在 Consumer 侧还是 Provider 侧生效。
- 观察配置推送后新调用是否携带新值。
不要只看配置中心页面中的一行 timeout 就断言已经生效。
十九、Sticky与一致性哈希不是一回事
Sticky 通常尝试在候选仍可用时复用上一次选中的 Invoker;一致性哈希根据调用参数映射实例。
| 能力 | 决定依据 | 典型目的 |
|---|---|---|
| Sticky | Consumer引用上次选择 | 减少实例切换 |
| ConsistentHash | 指定参数的Hash | 相同业务Key亲和 |
两者都会降低流量自由迁移能力。慢实例、热点或本地状态问题可能被放大,不应作为分布式 Session 或缓存一致性的基础。
二十、Provider保护:拒绝比排队到雪崩更健康
Provider 容量受 CPU、线程池、数据库连接、下游配额和锁竞争共同约束。
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 某方法在途数,还是连接数。
| 参数/能力 | 典型作用侧 | 主要限制对象 | 解决的问题 | 边界 |
|---|---|---|---|---|
threads | Provider | 业务线程池线程数 | 控制同时执行业务的线程总量 | 不能超过下游容量太多 |
queues | Provider | 业务线程池等待队列 | 吸收短突发或快速拒绝 | 大队列会制造长尾和UNKNOWN |
executes | Provider方法级 | 某服务/方法并发执行数 | 防止单个慢方法占满Provider | 配置语义和粒度需按版本核对 |
actives | Consumer方法级常见 | 某Consumer对某方法的在途调用数 | 防止调用方无限压下游 | 多Consumer总量仍可能很大 |
connections | Consumer到Provider | 连接数量 | 控制长连接和复用 | 连接多不等于处理能力强 |
accepts | Provider端 | 接入连接数 | 防止连接风暴 | 不等于业务请求限流 |
可以把它们放到调用链里理解:
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 进程里可能同时暴露“核心短查询”和“慢导出/批处理”:
| 方法 | 耗时 | 风险 |
|---|---|---|
queryOrderStatus | 20ms | 核心查询,应该稳定 |
exportOrderReport | 30s | 慢任务,容易占线程 |
syncHospitalAssets | 5s | 外部接口慢,容易堆积 |
如果所有方法共用同一个线程池和无限队列,慢导出会占住线程,核心查询也排队超时。正确思路是:
- 高成本方法单独限并发或拆服务。
- 核心方法保留独立容量。
- 慢任务优先异步化,使用 MQ、XXL-JOB 或批处理队列。
- Consumer 侧用
actives控制自己不要打爆下游。 - Provider 侧用
executes、线程池、队列和限流兜底。 - 业务写入仍用幂等键和状态机,不把并发限制当正确性保证。
20.2 JDK 8 Demo:方法级并发隔离模型
下面 Demo 不依赖 Dubbo,只用最小代码模拟 actives 和 executes 的差别:actives 发生在 Consumer 侧,控制“我这个调用方最多压多少请求”;executes 发生在 Provider 侧,控制“这个方法最多同时执行多少个”。
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 线程池满,不要直接改大线程数。按下面链路取证:
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
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 流量严重打偏
- 比较 Directory 原始候选和 Router 后候选。
- 查看每个实例有效权重,不只看配置权重。
- 确认是否处于 warmup。
- 检查 Tag、Condition、区域和 Sticky 规则。
- 按 Consumer 分组观察;全局均匀不代表单个 Consumer 均匀。
- 检查实例是否频繁上下线导致哈希环或统计重置。
24.2 P99升高且QPS突然放大
- 比较入口逻辑调用 QPS 与 Provider 尝试 QPS。
- 统计每次逻辑调用的 attempt 次数分布。
- 查网关、Dubbo、HTTP、数据库是否多层重试。
- 查看 Provider active、queue、reject、线程栈和数据库连接池。
- 先关闭或收紧非必要重试,避免故障继续放大。
- 对过载错误使用退避、熔断或快速失败。
24.3 写请求出现重复
不要只搜索 Dubbo retries:还要检查用户重复提交、网关重试、定时补偿、MQ重复消费和人工重放。以业务幂等键查询所有尝试,确认第一次是否已经提交,再修复唯一裁决和结果复用。
24.4 灰度流量进入稳定集群
检查请求附件中的 tag、Filter 是否透传、Tag Router 强制/回退语义、规则版本和灰度实例注册信息。若协议不兼容,应立即使用强隔离并撤销错误规则。
24.5 Provider线程池满
- 按接口/方法看 active、执行耗时和队列。
- 获取多次线程栈,区分 CPU、锁、DB连接、慢SQL和下游等待。
- 查 Consumer 重试和 Forking 放大。
- 查数据库、Redis和下游容量。
- 用限流、隔离和快速拒绝止损。
- 修复真正慢点后再评估线程与队列,不要直接无限扩容。
二十五、必须具备的可观测性
- 逻辑调用次数与物理尝试次数。
- 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,而不是只会背几个策略名称。
