LeastActive 把新节点打满了?Dubbo active 计数的反直觉陷阱

场景:三台 Dubbo Provider 配置 LeastActive 负载均衡,灰度重启后新节点 CPU 冲到 90%、流量占 72%,其余两台仅 30% 路径AbstractClusterInvoker.invoke()initLoadBalance()LeastActiveLoadBalance.doSelect()RpcStatus.getActive()

【遗迹】异常堆栈:LeastActive 不均衡,新节点被打满

上篇讲了 Dubbo 路由规则 force=true 能把 Provider 全过滤掉,这篇来看另一个 Dubbo 集群层的反直觉问题。

Dubbo(2.7.23)三台 Provider 配置了 loadbalance=leastactive——这本意是"谁空闲发给谁"。但灰度重启后监控面板显示:新节点 CPU 飙升到 90%,流量占比 72%;剩下两台 CPU 仅 30%,加起来才 28% 的流量。

告警监控 + 三台 Provider 的 CPU/流量分布

翻到 LeastActiveLoadBalance 的源码才发现——RpcStatus.getActive() 返回的是"正在处理的请求数",不是"处理能力"。新节点刚启动时活跃数为 0,LeastActive 看它最"空闲",持续把请求塞给它——直到它被打满。

前置条件:LeastActive 的 active 计数依赖 ActiveLimitFilter

这个反直觉陷阱的前提是 Consumer 端已配置 actives 参数(开启了 ActiveLimitFilter),否则 RpcStatus.getActive() 永远返回 0,LeastActive 退化成加权随机。如果你的 Consumer xml 里有类似配置:

<dubbo:reference id="orderService" interface="..."
    actives="10" loadbalance="leastactive"/>

或者注解:

@DubboReference(actives = 10, loadbalance = "leastactive")

那你就处于这个陷阱的触发条件内。

【发掘】源码追踪:LeastActive 的"活性"测量什么

调用入口:AbstractClusterInvoker 的负载均衡选择

追根溯源:Consumer 发起 RPC 调用时,经过 AbstractClusterInvoker.invoke() 进入集群层的负载均衡选择(AbstractClusterInvoker.java#L253-L266):

public Result invoke(final Invocation invocation) throws RpcException {
    checkWhetherDestroyed();
    List<Invoker<T>> invokers = list(invocation);
    LoadBalance loadbalance = initLoadBalance(invokers, invocation);
    return doInvoke(invocation, invokers, loadbalance);
}

initLoadBalance() 根据 Consumer URL 的 loadbalance 参数实例化对应的策略实现。如果配了 leastactive,就走 LeastActiveLoadBalance.doSelect()LeastActiveLoadBalance.java#L40-L114):

LeastActiveLoadBalance.doSelect 核心选择逻辑

核心逻辑三行:

  1. L63: RpcStatus.getStatus(invoker.getUrl(), methodName).getActive()——获取每个 Provider 当前活跃请求数
  2. L69-L75: 找到活跃数最低的 Provider——每次找到更低值就重置选中的节点集合
  3. L95-L97: 如果只有一个 Provider 活跃数最低,直接返回,忽略权重

第三行是关键。当新节点启动后,它的 active=0,而稳定运行的旧节点可能 active=23。新节点成为唯一最"空闲"的 Provider——leastCount=1,直接返回,warmup 权重根本用不上。

active 计数器的来源:RpcStatus + ActiveLimitFilter

那么 getActive() 的值到底从哪来?RpcStatus 是一个全局计数器(RpcStatus.java#L94-L156):

public static boolean beginCount(URL url, String methodName, int max) {
    RpcStatus methodStatus = getStatus(url, methodName);
    for (int i; ; ) {
        i = methodStatus.active.get();
        if (i == Integer.MAX_VALUE || i + 1 > max) return false;
        if (methodStatus.active.compareAndSet(i, i + 1)) break;
    }
    return true;
}

private static void endCount(RpcStatus status, long elapsed, boolean succeeded) {
    status.active.decrementAndGet();
}

beginCount CAS 递增 active,endCount 递减。谁调用的?Consumer 端的 ActiveLimitFilterActiveLimitFilter.java#L50-L90):

public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
    int max = invoker.getUrl().getMethodParameter(methodName, ACTIVES_KEY, 0);
    if (!RpcStatus.beginCount(url, methodName, max)) { ... }
    return invoker.invoke(invocation);
}

public void onResponse(Result appResponse, ...) {
    RpcStatus.endCount(url, methodName, getElapsed(invocation), true);
}

整个链条:请求进来 → ActiveLimitFilter.beginCount → active++ → 向下执行 → 响应回来 → ActiveLimitFilter.endCount → active--LeastActiveLoadBalance 在每次选择时读到的 active,就是"当前这个 Provider 上有几个请求尚未返回"。

正反馈:为什么 LeastActive 会把冷启动节点打满

正反馈链条就此形成:

① 新节点启动,active=0
② LeastActive 选中它(唯一最低 active),发送请求
③ 请求执行期间 active=1,执行完毕 active 归零
④ 归零一瞬间,LeastActive 又选中它
⑤ 新节点冷缓存 → 单请求处理比旧节点慢 3 倍 →
   但 active 在请求间隙始终为 0 →
   框架以为它最空闲 → 持续发送请求
⑥ 旧节点永远 active=1 或 2 → 永远不会成为唯一最低 →
   稳定流量被新节点抢走

ActiveLimitFilter 的 beginCount/endCount 调用链

【路径】🔍 IDE 到达路径:三步验证 LeastActive 行为

第一步:确认 active 计数是否生效

Ctrl+Shift+N → 输入 ActiveLimitFilter → 回车
  → Ctrl+F → 输入 beginCount → 回车
      → 看第 55 行的 RpcStatus.beginCount(url, methodName, max)

文件: dubbo-rpc/dubbo-rpc-api/src/main/java/
        org/apache/dubbo/rpc/filter/ActiveLimitFilter.java:47

在 beginCount 处设断点。如果请求进来没有触发这个断点 →
Consumer 未配置 actives,LeastActive 退化为随机。

第二步:观察 LeastActive 的选择过程

Ctrl+Shift+N → 输入 LeastActiveLoadBalance → 回车
  → Ctrl+F → 输入 doSelect → 回车
      → 看第 63 行:RpcStatus.getStatus(...).getActive()

文件: dubbo-cluster/src/main/java/org/apache/dubbo/rpc/
        cluster/loadbalance/LeastActiveLoadBalance.java:63

设断点在第 63 行和第 95 行。
观察每次选择时各 Provider 的 active 值和 leastCount。
如果 leastCount=1 → 权重无效,直接返回。

第三步:对比 ShortestResponse 的估算方式

Ctrl+Shift+N → 输入 ShortestResponseLoadBalance → 回车
  → Ctrl+F → 输入 estimateResponse → 回车
      → 看第 63 行:succeededAverageElapsed * (active + 1)

文件: dubbo-cluster/src/main/java/org/apache/dubbo/rpc/
        cluster/loadbalance/ShortestResponseLoadBalance.java:63

对比 LeastActive 和 ShortestResponse 的"负载评判标准"差异。

IDE 三步入桩断点设置

【解读】为什么 active 不等同于"负载"

LeastActive:单一维度测量

LeastActiveLoadBalance 的评判标准只有一个维度:RpcStatus.getActive()。它是 Consumer 端记录的、到某个 Provider 的并发请求数

这个数字只回答了"当前有多少请求在路上",不回答: - 这些请求处理了多久? - Provider 的 CPU 是否吃满? - Provider 是否有缓存命中?

这就是单一维度的局限性。你以为是测"哪台机器最空闲",实际测的是"哪台机器上的请求最晚返回"。

ShortestResponse:双维度估算

Dubbo 2.7.x 内置了 ShortestResponseLoadBalanceShortestResponseLoadBalance.java#L40-L99):

ShortestResponseLoadBalance 的 estimateResponse 计算

核心公式:estimateResponse = succeededAverageElapsed × (active + 1)

两个维度相乘: - succeededAverageElapsed:历史平均成功响应时间——反映"这个 Provider 处理够不够快" - active + 1:当前并发数 +1——反映"还有多少个请求在排队"

如果一个 Provider 冷缓存导致响应慢(succeededAverageElapsed 高),即使 active 低,相乘后的 estimateResponse 也不会成为最低——不会被打满。

设计对比

LeastActive:
  getActive()                         ← 只问"几个在路上"
  = 单一维度,不感知处理能力

ShortestResponse:
  succeededAverageElapsed × (active+1)  ← 问"还要等多久"
  = 双维度,估算综合负载

负载均衡不是"谁空闲给谁",而是"谁能最快处理完给谁"。
LeastActive 理解成前者,ShortestResponse 实现了后者。

LeastActive vs ShortestResponse 算法流程图

不这么写的后果

回到 LeastActiveLoadBalance.doSelect() L95-L97:

if (leastCount == 1) {
    return invokers.get(leastIndexes[0]);
}

这段代码没有考虑权重。即使新节点处于 warmup 期(getWeight 返回 10 而非 100),只要它的 active 是唯一最低值,直接返回,warmup 权重形同虚设

对比来看,如果有多个节点同权(active 相同),走的是 L99-L113 的加权随机——那里会用到 getWeight 的 warmup 权重。但冷启动场景恰恰是新节点的 active 远低于其他节点,走的是唯一最低分支,warmup 机制被跳过了。

选型决策

策略 判断依据 冷启动行为 异构集群 默认开启
Random 权重概率 均匀,由 weight 控制 需手动配 weight
RoundRobin 权重轮询 自动慢启动 权重累积偏差
LeastActive active 并发数 新节点被打满 活性反转
ShortestResponse 响应时间×并发 自动抑制慢节点 自适应 ❌ 需指定
ConsistentHash 参数哈希 缓存倾斜 节点变更重哈希 ❌ 需指定

LeastActive 是 Dubbo 2.7.x 的默认负载均衡策略之一(与 Random 并列常用),但"默认"不意味着"通用"。选 LeastActive 的前提是:各 Provider 的处理能力均衡,且请求耗时不悬殊。一旦出现冷缓存、异构硬件、"长请求 + 短请求"混合场景,active 单一维度就会失效。

【收获】排查锚点

下次看到"一台 Provider 负载异常高 + 配置了 LeastActive",按以下顺序排查:

两步验证法

Step 1: Consumer 端是否配置了 actives?
  搜索项目中的 @DubboReference(actives=...) 或 xml
  的 <dubbo:reference actives="...">。
  如果没配 actives -> ActiveLimitFilter 未启用,
  LeastActive = 加权随机(所有 active 为 0)。
  那不关负载均衡的事——查其他原因。

Step 2: 确认 LeastActive 的选择倾斜
  在 LeastActiveLoadBalance.doSelect() 第 63 行设断点,
  观察三台 Provider 的 active 值。
  如果新节点 active 始终最低且 leastCount=1 ->
  走第 95 行直接返回,权重被跳过。

修复方案

// 方案 A:换 ShortestResponse
@DubboReference(actives = 10, loadbalance = "shortestresponse")

// 方案 B:保留 LeastActive + 手动配 weight
// 新节点 weight 降低,旧节点 weight 提高
// 但注意 warmup 阶段的 weight 在 leastCount=1 时无效!
// 所以方案 B 不彻底

// 方案 C:异构集群直接用 Random + 显式 weight
@DubboReference(loadbalance = "random")
// Provider 端配置 weight:
// <dubbo:provider weight="50"/> // 弱小机器
// <dubbo:provider weight="200"/> // 强大机器

排查锚点速查

异常现象 入口类 关键方法 排查优先级
某台 Provider 流量异常偏高 LeastActiveLoadBalance doSelect() L63 getActive 1️⃣ 先确认 active 值差异
active 值为 0 且所有节点同值 ActiveLimitFilter invoke() L55 beginCount 2️⃣ 检查 actives 是否配置
warmup 不生效 LeastActiveLoadBalance doSelect() L95-L97 3️⃣ leastCount=1 跳过权重
自动规避慢节点 ShortestResponseLoadBalance doSelect() L63 estimateResponse 4️⃣ 换策略验证

自检清单

□ 你的 Consumer 端配置了 actives 吗?
   —— 没有则 LeastActive=Random,排查方向错了
□ 各 Provider 的处理能力/硬件规格一致吗?
   —— 不一致且开 LeastActive → 快的节点被打满
□ 服务有"长请求+短请求"混合吗?
   —— 有混合 → LeastActive 失效风险高
□ 你知道 LeastActive 和 ShortestResponse 的区别吗?
   —— active 单维度 vs 响应时间×并发双维度
□ warmup 保护被跳过了吗?
   —— leastCount=1 时完全不看权重

"LeastActive 的 active 计数器不是负载指示器——它只告诉你此时此刻有几个请求在路上,不告诉你每个请求要跑多久。一个计数器不够的,加一个平均响应时间。"

排查锚点速查表

下篇我们聊 Dubbo 与高版本 Spring 集成时的兼容性问题。

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」