Java 项目实战: 交友项目 - Dubbo 负载均衡策略与实战验证

概述

消费者引用了一个服务,注册中心里挂着三台 Provider,这次调用到底该打哪一台?Dubbo 把这个决策内置成了可配置的策略,loadbalance 一行属性就能换。但它生效有个前提,工程里那个 loadbalance = "Random" 也藏着容易误读的细节,这篇把它讲透并跑一遍验证。

纲要

  • 前提:负载均衡只在「同一服务、同一版本、有多个实例」时才有效果
  • 内置策略:Random / RoundRobin / LeastActive / ConsistentHash / ShortestResponse
  • 「随机」不是均分:Random 与 RoundRobin 都带权重语义,权重决定了概率与配额
  • 五种策略的对比表:选择依据、优点、缺点、适用场景
  • 三种配置方式:消费者侧 @DubboReference、提供者侧 @DubboService、全局 yaml
  • 配置的层级与优先级,以及为什么「怎么选实例」应该由消费者决定
  • 权重怎么配:weight 属性、yaml、dubbo-admin 动态调整
  • Dubbo 的权重和注册中心(Nacos)的权重是两套机制------这条要分清楚
  • 实战:用 dubbo-demo 的两个 Provider 实例验证「策略切换前后调用分布的变化」
  • version 不同 ≠ 负载均衡,这是最容易混淆的一处
  • 对照:Dubbo 负载均衡与 SpringCloud Ribbon / LoadBalancer 的关系
  • 实战坑清单与下一篇衔接

前提:没有多实例,配什么策略都一样

先把这条摆在最前面,因为它是「配完看不到效果」的唯一原因。

负载均衡的输入是一个提供者列表 ,输出是列表里被选中的某一个。列表长度为 1 的时候,无论选谁都是它,策略写成 Random、RoundRobin 还是 LeastActive,调用链路完全一致。
#mermaid-svg-u58SxWXMM1XnnAIo{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-u58SxWXMM1XnnAIo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-u58SxWXMM1XnnAIo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-u58SxWXMM1XnnAIo .error-icon{fill:#552222;}#mermaid-svg-u58SxWXMM1XnnAIo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-u58SxWXMM1XnnAIo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-u58SxWXMM1XnnAIo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-u58SxWXMM1XnnAIo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-u58SxWXMM1XnnAIo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-u58SxWXMM1XnnAIo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-u58SxWXMM1XnnAIo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-u58SxWXMM1XnnAIo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-u58SxWXMM1XnnAIo .marker.cross{stroke:#333333;}#mermaid-svg-u58SxWXMM1XnnAIo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-u58SxWXMM1XnnAIo p{margin:0;}#mermaid-svg-u58SxWXMM1XnnAIo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-u58SxWXMM1XnnAIo .cluster-label text{fill:#333;}#mermaid-svg-u58SxWXMM1XnnAIo .cluster-label span{color:#333;}#mermaid-svg-u58SxWXMM1XnnAIo .cluster-label span p{background-color:transparent;}#mermaid-svg-u58SxWXMM1XnnAIo .label text,#mermaid-svg-u58SxWXMM1XnnAIo span{fill:#333;color:#333;}#mermaid-svg-u58SxWXMM1XnnAIo .node rect,#mermaid-svg-u58SxWXMM1XnnAIo .node circle,#mermaid-svg-u58SxWXMM1XnnAIo .node ellipse,#mermaid-svg-u58SxWXMM1XnnAIo .node polygon,#mermaid-svg-u58SxWXMM1XnnAIo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-u58SxWXMM1XnnAIo .rough-node .label text,#mermaid-svg-u58SxWXMM1XnnAIo .node .label text,#mermaid-svg-u58SxWXMM1XnnAIo .image-shape .label,#mermaid-svg-u58SxWXMM1XnnAIo .icon-shape .label{text-anchor:middle;}#mermaid-svg-u58SxWXMM1XnnAIo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-u58SxWXMM1XnnAIo .rough-node .label,#mermaid-svg-u58SxWXMM1XnnAIo .node .label,#mermaid-svg-u58SxWXMM1XnnAIo .image-shape .label,#mermaid-svg-u58SxWXMM1XnnAIo .icon-shape .label{text-align:center;}#mermaid-svg-u58SxWXMM1XnnAIo .node.clickable{cursor:pointer;}#mermaid-svg-u58SxWXMM1XnnAIo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-u58SxWXMM1XnnAIo .arrowheadPath{fill:#333333;}#mermaid-svg-u58SxWXMM1XnnAIo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-u58SxWXMM1XnnAIo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-u58SxWXMM1XnnAIo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-u58SxWXMM1XnnAIo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-u58SxWXMM1XnnAIo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-u58SxWXMM1XnnAIo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-u58SxWXMM1XnnAIo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-u58SxWXMM1XnnAIo .cluster text{fill:#333;}#mermaid-svg-u58SxWXMM1XnnAIo .cluster span{color:#333;}#mermaid-svg-u58SxWXMM1XnnAIo 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-u58SxWXMM1XnnAIo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-u58SxWXMM1XnnAIo rect.text{fill:none;stroke-width:0;}#mermaid-svg-u58SxWXMM1XnnAIo .icon-shape,#mermaid-svg-u58SxWXMM1XnnAIo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-u58SxWXMM1XnnAIo .icon-shape p,#mermaid-svg-u58SxWXMM1XnnAIo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-u58SxWXMM1XnnAIo .icon-shape .label rect,#mermaid-svg-u58SxWXMM1XnnAIo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-u58SxWXMM1XnnAIo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-u58SxWXMM1XnnAIo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-u58SxWXMM1XnnAIo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 服务列表
选一个
选一个
选一个
Consumer

@DubboReference
注册中心

Nacos :8848
负载均衡策略
Provider A

:20881
Provider B

:20882
Provider C

:20883

所以验证负载均衡,最少要满足三个条件:

text 复制代码
1. 同一个服务接口
2. 同一个 version(同一 group)
3. 至少两个 Provider 实例同时在线

第二条是最容易漏的。同一个接口的 1.0.0 和 2.0.0 属于两个不同的服务,消费者指定了 version 就只会在该版本内部挑实例,跨版本不会发生选择行为------那是「版本路由」,不是负载均衡。这一点下文单独展开。

五种内置策略

讲义在「Dubbo高级特性」一节列出的内置策略是四种:Random、RoundRobin、LeastActive、ConsistentHash。本工程的依赖是 dubbo-spring-boot-starter 2.7.8,该版本从 2.7.7 起另外提供了 ShortestResponse,一并放进来看会更完整。表格前四行是讲义口径,ShortestResponse 是版本补充。

策略 选择依据 优点 缺点 适用场景
Random(默认) 按权重随机,权重高者概率大 实现简单,长期分布收敛于权重比 单次调用可能连续命中同一台 通用默认;实例性能有差异时用权重表达
RoundRobin 按权重轮询,严格按配额依次下发 请求分布均匀、可预期 不看实例当前负载,慢实例会持续被打 各实例性能接近、耗时稳定的场景
LeastActive 选当前活跃调用数最少的实例,相同活跃数时随机 能感知实例快慢,慢实例因活跃数堆积被少选 需要统计活跃数,极端情况下可能短时倾斜 各实例耗时差异大、有明显快慢机器
ConsistentHash 一致性哈希,相同参数绑定到同一实例 天然会话粘性,可命中实例本地缓存 实例上下线会引起哈希环变动、请求重新分布 需要粘性、有本地缓存的场景
ShortestResponse 比较各实例的平均响应时间,选最短的 直接以耗时为目标函数 依赖滑动窗口统计,突发流量下可能滞后 响应时间是核心指标的在线服务

Random:默认策略,但它不是均分

名字叫「随机」,实际是按权重随机。Dubbo 把所有 Provider 的权重求和,再按权重给每个实例切出对应的数字区间,然后以权重和为基数随机一个值,落到哪个区间就打哪台。
#mermaid-svg-ghDG6fb3K187PWAT{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-ghDG6fb3K187PWAT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ghDG6fb3K187PWAT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ghDG6fb3K187PWAT .error-icon{fill:#552222;}#mermaid-svg-ghDG6fb3K187PWAT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ghDG6fb3K187PWAT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ghDG6fb3K187PWAT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ghDG6fb3K187PWAT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ghDG6fb3K187PWAT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ghDG6fb3K187PWAT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ghDG6fb3K187PWAT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ghDG6fb3K187PWAT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ghDG6fb3K187PWAT .marker.cross{stroke:#333333;}#mermaid-svg-ghDG6fb3K187PWAT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ghDG6fb3K187PWAT p{margin:0;}#mermaid-svg-ghDG6fb3K187PWAT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ghDG6fb3K187PWAT .cluster-label text{fill:#333;}#mermaid-svg-ghDG6fb3K187PWAT .cluster-label span{color:#333;}#mermaid-svg-ghDG6fb3K187PWAT .cluster-label span p{background-color:transparent;}#mermaid-svg-ghDG6fb3K187PWAT .label text,#mermaid-svg-ghDG6fb3K187PWAT span{fill:#333;color:#333;}#mermaid-svg-ghDG6fb3K187PWAT .node rect,#mermaid-svg-ghDG6fb3K187PWAT .node circle,#mermaid-svg-ghDG6fb3K187PWAT .node ellipse,#mermaid-svg-ghDG6fb3K187PWAT .node polygon,#mermaid-svg-ghDG6fb3K187PWAT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ghDG6fb3K187PWAT .rough-node .label text,#mermaid-svg-ghDG6fb3K187PWAT .node .label text,#mermaid-svg-ghDG6fb3K187PWAT .image-shape .label,#mermaid-svg-ghDG6fb3K187PWAT .icon-shape .label{text-anchor:middle;}#mermaid-svg-ghDG6fb3K187PWAT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ghDG6fb3K187PWAT .rough-node .label,#mermaid-svg-ghDG6fb3K187PWAT .node .label,#mermaid-svg-ghDG6fb3K187PWAT .image-shape .label,#mermaid-svg-ghDG6fb3K187PWAT .icon-shape .label{text-align:center;}#mermaid-svg-ghDG6fb3K187PWAT .node.clickable{cursor:pointer;}#mermaid-svg-ghDG6fb3K187PWAT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ghDG6fb3K187PWAT .arrowheadPath{fill:#333333;}#mermaid-svg-ghDG6fb3K187PWAT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ghDG6fb3K187PWAT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ghDG6fb3K187PWAT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ghDG6fb3K187PWAT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ghDG6fb3K187PWAT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ghDG6fb3K187PWAT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ghDG6fb3K187PWAT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ghDG6fb3K187PWAT .cluster text{fill:#333;}#mermaid-svg-ghDG6fb3K187PWAT .cluster span{color:#333;}#mermaid-svg-ghDG6fb3K187PWAT 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-ghDG6fb3K187PWAT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ghDG6fb3K187PWAT rect.text{fill:none;stroke-width:0;}#mermaid-svg-ghDG6fb3K187PWAT .icon-shape,#mermaid-svg-ghDG6fb3K187PWAT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ghDG6fb3K187PWAT .icon-shape p,#mermaid-svg-ghDG6fb3K187PWAT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ghDG6fb3K187PWAT .icon-shape .label rect,#mermaid-svg-ghDG6fb3K187PWAT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ghDG6fb3K187PWAT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ghDG6fb3K187PWAT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ghDG6fb3K187PWAT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} r ∈ [0, 2)
r ∈ [2, 5)
r ∈ [5, 10)
随机数 r ∈ [0, 10)
r 落在哪个区间
实例A

weight = 2
实例B

weight = 3
实例C

weight = 5

权重 2 / 3 / 5 的结果不是「每台各占三分之一」,而是大约 20% / 30% / 50%。权重越高,被随机到的概率越大。这也是为什么新上线的低配机器可以通过调低权重先接少量流量,而不是一刀切下线。

默认权重是 100,不显式配置时各实例等权,此时才退化成我们直觉里的「均匀随机」。

RoundRobin:按权重轮询,注意慢实例积压

轮询是「依次循环调用」,加权重之后变成按配额循环:权重 2 / 3 / 5 时,前 10 次调用会分配成 A 两次、B 三次、C 五次,之后循环。

它的问题在于只看配额,不看实例当下忙不忙。如果实例 C 因为 GC 或者慢 SQL 每次耗时 500ms,而 A 只要 20ms,轮询仍然会给 C 下发和权重成正比的请求,C 的请求队列越积越长,最终拖垮超时。碰到这种机器快慢明显不均的情况,轮询不是好选择。

LeastActive:用活跃数感知快慢

LeastActive 选的是「当前正在处理中的调用数」最少的实例。同一个时间窗口里,实例 C 有 3 个在途请求、实例 A 只有 1 个,那么新请求优先给 A。

它的巧妙之处在于活跃数是一个能间接反映快慢的指标:处理得慢的实例,请求堆在它身上来不及释放,活跃数自然居高不下,于是被少选。等它的活跃数降下来,又会重新参与分配------这是一个自动收敛的负反馈。

ConsistentHash:相同参数打到同一实例

一致性哈希把「参数」映射到哈希环上的位置,同一个参数值每次都落在同一台实例上。典型用途是本地缓存:实例 A 缓存了用户 1 的数据,只要用户 1 的请求始终进 A,缓存就一直有效;如果换成随机或轮询,请求散落到所有实例,每台都要各自加载一遍,缓存等于白做。

代价是实例上下线会引起重分布。哈希环上增删一个节点,落在受影响区间的那些参数会改投别的实例,缓存随之失效。所以它适合实例数量稳定的场景,不适合频繁扩缩容。

ShortestResponse:以平均响应时间为准

ShortestResponse 统计各实例在一段时间窗口内的平均响应时间,选最短的那台。它比 LeastActive 更直接地把「快慢」作为目标,但依赖统计窗口,流量突变或实例刚启动(样本不足)时判断会滞后。

策略怎么配:三种位置,层级不同

代码里能改的只有一行属性,位置不同含义不同。

消费者侧(本工程用的就是这种) 。user-consumer 的 UserController 里,引用声明上直接带策略:

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 {

    // 引用远程服务:loadbalance 指定消费者选实例时使用的策略
    @DubboReference(version = "2.0.0", retries = 0, loadbalance = "Random")
    private UserService userService;

    @GetMapping("/usernames/{id}")
    public String queryUsername(@PathVariable("id") Long id) {
        return userService.queryUsername(id);
    }

    @GetMapping("/{id}")
    public User queryById(@PathVariable("id") Long id) {
        return userService.queryById(id);
    }
}

提供者侧。在暴露服务时声明,作为「本服务建议使用的策略」注册到 URL 里:

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;

// version = 2.0.0,weight = 200 表示该实例接双倍流量
@DubboService(version = "2.0.0", weight = 200)
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;
    }
}

全局级。写在 yaml 里,对该应用下所有服务生效:

yaml 复制代码
dubbo:
  registry:
    address: nacos://127.0.0.1:8848
  consumer:
    loadbalance: RoundRobin   # 给本应用的所有引用兜底
    check: false
    retries: 0
  provider:
    weight: 100               # 提供者侧默认权重

Dubbo 的配置优先级是「更具体者优先」:方法级 > 接口/服务级 > 全局级 ;消费端与提供端都配了时,消费端优先(怎么挑实例是调用方的决策,供给方只提供建议值)。各版本对细节的排序处理可能有微调,实际项目里以官方配置文档为准;方向是明确的------越靠近具体调用点的配置越说了算。

也正因为这个方向,loadbalance 通常写在消费者侧。提供者侧写它只是「建议」,一旦某个消费者显式指定了自己的策略,提供者的建议就被覆盖。

权重:和 Nacos 的权重不是一回事

Random 和 RoundRobin 都吃权重,所以权重得单独说清楚。

配置权重有三个入口。跟随服务实现的注解最直观,注解值本身默认是 100:

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;

// weight = 200:该实例的权重是默认值的两倍,随机策略下大约接走两倍流量
@DubboService(version = "2.0.0", weight = 200)
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;
    }
}

提供者侧也可以给应用级默认值:

yaml 复制代码
dubbo:
  provider:
    weight: 100      # 本应用所有服务的默认权重,单个服务可用注解覆盖

第三个入口是 dubbo-admin 控制台,在线拖动权重,不重启即生效,适合临时压测和灰度调流。

真实用途有两个。日常是让配置更好的机器多接流量 :8 核的机器给 200,4 核的给 100,随机分配下前者拿到的请求量就是后者的两倍。灰度时则是把流量往新版本牵引:同版本的多个实例里,新扩容的那批先给低权重试水,观察监控无异常再逐步调高。

这里有个高频混淆点要澄清:

Dubbo 的 weight 和注册中心的权重是两套独立的机制。

Dubbo 的 weight 是服务级参数,由提供者在注册时写进服务 URL(形如 dubbo://ip:port/...&weight=200),只被 Dubbo 自己的负载均衡器读取,粒度到「服务/方法」。

Nacos 的实例权重是注册中心层面的字段,粒度到「实例」,被 Nacos 的消费者读取。在 SpringCloud 那一侧,NacosRule 用它做权重筛选,SpringCloud LoadBalancer 也读它。

这意味着,如果项目里同时用 Dubbo 和 Feign,你在 dubbo-admin 里调的权重对 Feign 调用毫无作用;反过来在 Nacos 控制台拖动实例权重,Dubbo 的 Random 策略也不会理你。两套都要配,也都要分别验证。Dubbo 服务的 weight 是随服务 URL 一起注册的元数据参数,而 Nacos 里那个实例权重是实例自身的字段,名字相同、载体不同------看到两个「权重」入口时先分清是哪一层,再决定改哪个。

实战:两个 Provider 实例,观察调用分布

工程结构如下,需要重点关注的只有三个模块:

tree 复制代码
dubbo-demo
├── pom.xml                       # 父工程,SpringBoot 2.3.9 + Dubbo 2.7.8
├── user-api                      # 接口 + 领域对象,Provider 和 Consumer 共同依赖
│   └── src/main/java/cn/mf/user
│       ├── service/UserService.java
│       └── domain/User.java
├── user-provider                 # 提供者,server.port=18080,dubbo.protocol.port=20881
│   └── 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")
└── user-consumer                 # 消费者,server.port=18081
    └── src/main/java/cn/mf/user
        ├── UserConsumerApplication.java
        └── controller/UserController.java

UserServiceImpl 的 queryById 会在用户名后拼 v1.0,UserServiceImpl2 拼 v2.0------这个标记就是观察「请求打到了哪个实例」的探针,非常便于验证。

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;

// 1.0.0 版本:返回值里拼上 v1.0
@DubboService(version = "1.0.0")
class UserServiceImpl 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() + "v1.0");
        return user;
    }
}
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;

// 2.0.0 版本:返回值里拼上 v2.0
@DubboService(version = "2.0.0")
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;
    }
}

准备一张演示表:

sql 复制代码
CREATE TABLE tb_user
(
    id       BIGINT PRIMARY KEY,
    username VARCHAR(50),
    address  VARCHAR(100)
);

INSERT INTO tb_user (id, username, address) VALUES (1, '项目', '北京');

先把 version 与负载均衡的区别讲清

工程里 Consumer 写的是 @DubboReference(version = "2.0.0", loadbalance = "Random")。这句话有两层约束同时生效:
#mermaid-svg-bHB0qkyWD4UwA6xW{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-bHB0qkyWD4UwA6xW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bHB0qkyWD4UwA6xW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bHB0qkyWD4UwA6xW .error-icon{fill:#552222;}#mermaid-svg-bHB0qkyWD4UwA6xW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bHB0qkyWD4UwA6xW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bHB0qkyWD4UwA6xW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bHB0qkyWD4UwA6xW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bHB0qkyWD4UwA6xW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bHB0qkyWD4UwA6xW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bHB0qkyWD4UwA6xW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bHB0qkyWD4UwA6xW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bHB0qkyWD4UwA6xW .marker.cross{stroke:#333333;}#mermaid-svg-bHB0qkyWD4UwA6xW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bHB0qkyWD4UwA6xW p{margin:0;}#mermaid-svg-bHB0qkyWD4UwA6xW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bHB0qkyWD4UwA6xW .cluster-label text{fill:#333;}#mermaid-svg-bHB0qkyWD4UwA6xW .cluster-label span{color:#333;}#mermaid-svg-bHB0qkyWD4UwA6xW .cluster-label span p{background-color:transparent;}#mermaid-svg-bHB0qkyWD4UwA6xW .label text,#mermaid-svg-bHB0qkyWD4UwA6xW span{fill:#333;color:#333;}#mermaid-svg-bHB0qkyWD4UwA6xW .node rect,#mermaid-svg-bHB0qkyWD4UwA6xW .node circle,#mermaid-svg-bHB0qkyWD4UwA6xW .node ellipse,#mermaid-svg-bHB0qkyWD4UwA6xW .node polygon,#mermaid-svg-bHB0qkyWD4UwA6xW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bHB0qkyWD4UwA6xW .rough-node .label text,#mermaid-svg-bHB0qkyWD4UwA6xW .node .label text,#mermaid-svg-bHB0qkyWD4UwA6xW .image-shape .label,#mermaid-svg-bHB0qkyWD4UwA6xW .icon-shape .label{text-anchor:middle;}#mermaid-svg-bHB0qkyWD4UwA6xW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bHB0qkyWD4UwA6xW .rough-node .label,#mermaid-svg-bHB0qkyWD4UwA6xW .node .label,#mermaid-svg-bHB0qkyWD4UwA6xW .image-shape .label,#mermaid-svg-bHB0qkyWD4UwA6xW .icon-shape .label{text-align:center;}#mermaid-svg-bHB0qkyWD4UwA6xW .node.clickable{cursor:pointer;}#mermaid-svg-bHB0qkyWD4UwA6xW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bHB0qkyWD4UwA6xW .arrowheadPath{fill:#333333;}#mermaid-svg-bHB0qkyWD4UwA6xW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bHB0qkyWD4UwA6xW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bHB0qkyWD4UwA6xW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bHB0qkyWD4UwA6xW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bHB0qkyWD4UwA6xW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bHB0qkyWD4UwA6xW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bHB0qkyWD4UwA6xW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bHB0qkyWD4UwA6xW .cluster text{fill:#333;}#mermaid-svg-bHB0qkyWD4UwA6xW .cluster span{color:#333;}#mermaid-svg-bHB0qkyWD4UwA6xW 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-bHB0qkyWD4UwA6xW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bHB0qkyWD4UwA6xW rect.text{fill:none;stroke-width:0;}#mermaid-svg-bHB0qkyWD4UwA6xW .icon-shape,#mermaid-svg-bHB0qkyWD4UwA6xW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bHB0qkyWD4UwA6xW .icon-shape p,#mermaid-svg-bHB0qkyWD4UwA6xW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bHB0qkyWD4UwA6xW .icon-shape .label rect,#mermaid-svg-bHB0qkyWD4UwA6xW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bHB0qkyWD4UwA6xW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bHB0qkyWD4UwA6xW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bHB0qkyWD4UwA6xW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① version 过滤

Consumer 固定 2.0.0
② loadbalance 选择
全部 Provider

1.0.0 与 2.0.0 各有实例
候选实例集

只剩 2.0.0 的实例
最终命中的那一台

version 决定「哪些实例有资格」,负载均衡决定「有资格的里面挑哪一个」。 顺序不能颠倒:版本不匹配的实例压根没进入候选列表,轮不到负载均衡去选它。

工程里 1.0.0 和 2.0.0 各只有一个实现类、跑在同一个 Provider 进程里。也就是说,Consumer 固定 2.0.0 之后,候选列表里只有一个实例,loadbalance = "Random" 此刻不会产生任何可见效果。这不是配置写错了,而是负载均衡的输入不够------只有一台机器,随机也好轮询也好,结果都一样。

同版本启动两个实例的正确做法

要看到效果,得让同一个 version 的两个实例同时在线。

做法是在 UserServiceImpl2 的返回值里带上自己的端口,用启动参数区分实例。先把标记改成实例级(dubbo.protocol.port 同时会被 Dubbo 读取并绑定端口):

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;

// 演示用:返回值带上本进程的 Dubbo 端口,便于肉眼分辨请求落到了哪个实例
@DubboService(version = "2.0.0")
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);
        String port = System.getProperty("dubbo.protocol.port", "20881");
        user.setUsername(user.getUsername() + "-port" + port);
        return user;
    }
}

然后打两个包,用不同的端口各起一个。这里统一用 -D 传参,Dubbo 与 Spring Boot 都能从系统属性里读到:

bash 复制代码
# 打包(跳过测试)
mvn -q -pl user-provider -am clean package -DskipTests

# 实例一:server 18080 / dubbo 协议 20881(与 yml 默认一致,直接起即可)
java -jar user-provider/target/user-provider-1.0-SNAPSHOT.jar

# 实例二:server 18082 / dubbo 协议 20882
java -Dserver.port=18082 -Ddubbo.protocol.port=20882 \
     -jar user-provider/target/user-provider-1.0-SNAPSHOT.jar

两个进程都声明 version = "2.0.0",注册中心里 UserService:2.0.0 就有了两个提供者。启动 Consumer:

bash 复制代码
java -jar user-consumer/target/user-consumer-1.0-SNAPSHOT.jar
# 默认端口 18081

连续调用,观察分布

Consumer 提供了 GET /user/{id},返回体里的 username 带端口标记,循环调用即可看到分布。

bash 复制代码
# 连续调用 12 次,观察 username 里的 -port 后缀
for i in $(seq 1 12); do
  curl -s "http://127.0.0.1:18081/user/1"
  echo
done

预期观察:

  • loadbalance = "Random" 时,12 次调用里两个 -port 后缀都会出现,且顺序是乱的,不会严格交替。次数比大致接近权重比(默认等权时约各占一半,但样本小会波动)。
  • 把 Consumer 改成 loadbalance = "RoundRobin" 重启后,同样的循环会看到严格交替(A、B、A、B......)------这是轮询「按顺序逐个下发」的直接体现,也是区分两种策略最快的方法。

对应的注解改动只有一处:

java 复制代码
// 验证轮询:改完必须重启 Consumer 才生效
@DubboReference(version = "2.0.0", retries = 0, loadbalance = "RoundRobin")
private UserService userService;

如果不想用 curl,也可以在 user-consumer 的测试目录下写一段把调用和统计放在一起的测试(user-consumer 需要先引入 spring-boot-starter-test):

java 复制代码
package cn.mf.user;

import cn.mf.user.domain.User;
import cn.mf.user.service.UserService;
import org.apache.dubbo.config.annotation.DubboReference;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@SpringBootTest
class LoadBalanceTest {

    @DubboReference(version = "2.0.0", retries = 0, loadbalance = "Random")
    private UserService userService;

    @Test
    void observeDistribution() {
        Map<String, Integer> counter = new ConcurrentHashMap<>();
        for (int i = 0; i < 100; i++) {
            User user = userService.queryById(1L);
            // username 形如 项目-port20881,取端口作为实例标识
            String instance = user.getUsername().substring(user.getUsername().lastIndexOf("-port"));
            counter.merge(instance, 1, Integer::sum);
        }
        counter.forEach((k, v) -> System.out.println(k + " => " + v));
    }
}

100 次之后打印出的计数就是各实例分到的请求数。把 loadbalance 换成 RoundRobin,计数会变成几乎完全相等;换成 LeastActive,如果其中一个实例被人工注入延迟,它的计数会明显低于另一个。

和 SpringCloud Ribbon / LoadBalancer 的关系

两者都属于客户端负载均衡------决策发生在调用方进程内,而不是中间加一个独立的负载均衡器。区别在作用的层次:

维度 Dubbo 负载均衡 SpringCloud Ribbon / LoadBalancer
作用层次 RPC 调用层(Dubbo 协议) HTTP 客户端层(RestTemplate / Feign)
策略配置位置 @DubboReference(loadbalance=...)、dubbo.consumer.loadbalance ribbon.NFLoadBalancerRuleClassName、LoadBalancerClient 自定义
内置策略 Random / RoundRobin / LeastActive / ConsistentHash / ShortestResponse RoundRobin / Random / 重试等,另有 NacosRule 做权重筛选
权重来源 Dubbo 服务 URL 上的 weight 注册中心实例权重(Nacos weight)

真实工程里如果同时用 Dubbo 和 Feign,就会同时存在两套负载均衡配置,各管各的调用链路,需要分别维护、分别验证。改动其中一套不会影响另一套,这一条比「哪个更好用」更值得记住。

实战坑清单

现象 根因 处理方式
配了策略但调用总是同一台 候选实例只有一个,负载均衡没有可选项 同版本起两个实例再验证
以为换了 version 就实现了负载均衡 version 是路由维度,不是负载均衡 明确「先版本过滤、后挑实例」的顺序
权重只在一侧配,看不到效果 消费端策略与提供端权重未同时生效 权重配在提供者,策略配在消费者
ConsistentHash 后扩缩容,缓存大面积失效 哈希环节点变动导致参数重新映射 粘性场景避免频繁上下线,或改用哈希槽方案
慢实例在 RoundRobin 下持续被打 轮询不考虑实例实时负载 换 LeastActive 或 ShortestResponse,或调低其权重
调了 dubbo-admin 的权重,Feign 调用没变化 混淆了 Dubbo 权重与注册中心权重 两套权重分别调整、分别验证
改完 loadbalance 没生效 注解在启动时解析,运行中不重载 重启 Consumer
循环调用「看起来」不均 随机策略单次不可预测,样本太小 放大调用次数(如 100 次)再看分布

API 速览

位置 属性 取值示例 作用
@DubboReference loadbalance "Random" 消费者侧指定选实例策略(常用位置)
@DubboReference version "2.0.0" 先做版本过滤,决定候选实例集合
@DubboReference retries 0 关掉重试,避免验证时重复调用干扰分布观察
@DubboService loadbalance "RoundRobin" 提供者侧给出的策略建议值,可被消费者覆盖
@DubboService weight 200 实例权重,影响 Random / RoundRobin 的分配比例
@DubboService version "2.0.0" 声明服务版本,同版本多实例才是负载均衡的候选
yml dubbo.consumer.loadbalance RoundRobin 消费者应用级默认策略
yml dubbo.provider.weight 100 提供者应用级默认权重
yml dubbo.protocol.port 20881 Dubbo 协议端口,多实例演示时每个进程一个
yml dubbo.registry.address nacos://127.0.0.1:8848 注册中心地址

官方文档

总结

Dubbo 的负载均衡不是「配了就生效」的功能,它是一个纯粹的选择函数,只在候选实例不止一个时才有输出。理解它抓住三条线就够了:

  • 候选集合由 version 决定,选择结果由 loadbalance 决定。 版本不匹配的实例进不了候选,也就谈不上被均衡。
  • Random 是带权重的默认值,权重是表达「实例能力强弱」和「灰度流量比例」的手段。 它和 Nacos 实例权重是并行的两套,别跨层去找效果。
  • 策略没有绝对优劣,只有匹配与否。 机器齐整用 RoundRobin,快慢不均用 LeastActive,要本地缓存粘性用 ConsistentHash,其余场景交给默认的 Random 就行。

验证方法也简单:同版本起两个实例,让返回值带上实例标识,循环调用看分布,换策略再看一次分布变了没有。比盯着配置文件猜要可靠得多。

下一篇按讲义顺序进入「SpringCloud 整合 Dubbo」,讲怎么在享受 SpringCloud 生态的同时用 Dubbo 的 RPC 性能把 Ribbon / Feign 替换掉。

相关推荐
SL_staff1 小时前
销售知识库7天落地实践:面向业务侧的零代码嵌入方案
java·开源·产品
GreatSQL社区1 小时前
主从复制SQL线程中断——字符集砸出的坑
java·数据库·算法
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 入门案例:服务提供者搭建总结
java·交友
hudd6681 小时前
JavaEE 初阶 - 多线程(进阶)
java·开发语言·java-ee
Pniubi2 小时前
区分Java创建线程池、多线程
java·线程·线程池·多线程·并发
程序员梅雨2 小时前
ZooKeeper面经总结
java·大数据·linux·分布式·后端·zookeeper·中间件
Yyyyyy~2 小时前
【Java】多线程初阶
java
蜗牛互联网2 小时前
Java 17 调用视觉 Responses API:JSON 约束与人工复核路由
java·人工智能·后端
SL_staff3 小时前
物模型三要素误用避坑指南:属性、事件、服务的正确建模逻辑
java