深度全面学习负载均衡Ribbon/Spring Cloud LoadBalancer

看负载均衡不能只看"怎么分发的",要看**"服务列表从哪来"** 、"如何保证线程安全""如何感知服务健康状态" 以及**"算法的数学模型"**。

你可以把负载均衡 想象成一家火爆餐厅的"领位员"

  • 客户端(你):就是去吃饭的顾客。
  • 服务实例(服务器):就是餐厅里的桌子。
  • 负载均衡器(Ribbon/SCLB):就是站在门口负责安排桌子的领位员。

你的问题其实就是:这个领位员到底按什么规矩来安排桌子?

  • 在 Spring Cloud 体系中,Ribbon 是上一代的霸主(虽然进入维护模式,但原理是通用的基石),Spring Cloud LoadBalancer (SCLB) 是新一代的响应式标准。

第一层:核心架构------谁在控制流量?

在代码跑起来之前,必须脑子里有一张图。负载均衡的核心不仅仅是算法,而是服务发现规则执行的解耦。

1. 核心组件模型

无论是 Ribbon 还是 SCLB,都遵循这个架构模式:

  • ServerList(服务列表源):负责从注册中心(Nacos/Eureka)拉取实例列表。
  • ServerListFilter(过滤器):负责剔除不健康的实例(比如熔断器认为挂掉的节点)。
  • IRule / LoadBalancer(策略核心):负责根据算法从过滤后的列表中挑一个。
2. 客户端负载均衡 vs 服务端负载均衡
  • 服务端(Nginx/F5):客户端发请求给 Nginx,Nginx 挑一个后端转发。
  • 客户端(Ribbon/SCLB) :客户端自己维护一份服务列表(缓存),自己算哈希、自己选 IP,然后直接发起 HTTP 请求。
    • 底层优势:去中心化,没有单点瓶颈。
    • 底层代价:每个客户端都要消耗内存存列表,且列表更新有延迟(最终一致性)。

第二层:轮询算法(Round Robin)------ 源码级的"原子"博弈

轮询是最基础的算法,但在高并发下,如何保证"不重复、不遗漏、线程安全"是门学问。

最老实的领位员

规矩 :不管来的是谁,也不管桌子大小,就按顺序来。

1号桌 → 2号桌 → 3号桌 → 回到1号桌......

  • 优点:绝对公平,谁也不偏袒。
  • 缺点死脑筋。如果3号桌是个瘸腿桌子(机器性能差),或者3号桌正在擦桌子(正在处理耗时任务),领位员还是把人往那领,结果就是3号桌那边怨声载道,1、2号桌却很闲。
  • 代码里 :这就是那个 AtomicInteger 在那不停地 +1,然后 % 总数
1. 数学原理

假设服务列表为 S=[S0,S1,...,Sn−1]S=[S0​,S1​,...,Sn−1​] ,第 ii 次请求的目标服务器 StargetStarget​ 为:

Starget=S(i(modn))Starget​=S(i(modn))​

2. Ribbon 源码深度剖析 (RoundRobinRule)

Ribbon 的实现非常经典,它利用了 AtomicInteger 的 CAS(Compare-And-Swap)机制来实现无锁的高性能计数。

核心代码逻辑(还原自 Ribbon 源码):
复制代码
public class RoundRobinRule extends AbstractLoadBalancerRule {
    // 核心:原子整数,保证多线程并发下的自增安全
    private AtomicInteger nextServerCyclicCounter;

    public RoundRobinRule() {
        this.nextServerCyclicCounter = new AtomicInteger(0);
    }

    @Override
    public Server choose(Object key) {
        // 1. 获取负载均衡器(包含服务列表)
        ILoadBalancer lb = getLoadBalancer();
        
        // 2. 获取所有服务(包括不健康的,后续会过滤)
        List<Server> allServers = lb.getAllServers();
        if (allServers == null || allServers.isEmpty()) return null;

        // 3. 核心算法:CAS 自增 + 取模
        // 这是一个死循环,直到 CAS 成功为止
        int current = nextServerCyclicCounter.get();
        int next = (current + 1) % allServers.size();
        
        // compareAndSet: 如果当前值还是 current,就更新为 next,返回 true
        // 如果中间被别的线程改了,compareAndSet 返回 false,循环重试
        while (!nextServerCyclicCounter.compareAndSet(current, next)) {
            current = nextServerCyclicCounter.get();
            next = (current + 1) % allServers.size();
        }

        // 4. 返回对应索引的服务器
        return allServers.get(next);
    }
}

洞察

  • 线程安全 :使用 AtomicInteger 避免了 synchronized 的重量级锁,利用 CPU 的 CAS 指令实现乐观锁。
  • 缺陷 :如果服务列表动态变化(比如扩缩容),单纯的取模会导致数据倾斜请求抖动。例如从 3 台扩容到 4 台,原本在 S2S2 的请求可能全部跳到 S0S0 ,导致缓存失效或连接断开。
3.自定义算法
  1. 造人 :写一个类继承 AbstractLoadBalancerRule(这是 IRule 的默认实现,省事)。
  2. 定规矩 :重写 choose 方法,把你的业务逻辑写进去。
  3. 上岗:告诉 Ribbon 别用默认的,用你写的这个。

场景:假设你有两台机器,A 是超级服务器(权重 8),B 是普通服务器(权重 2)。你希望 80% 的流量给 A,20% 给 B。

第一步:造人(写代码)

我们需要创建一个类,继承 AbstractLoadBalancerRule

复制代码
import com.netflix.loadbalancer.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

// 1. 继承基类,省去很多麻烦
public class CustomWeightedRule extends AbstractLoadBalancerRule {

    private Random random = new Random();

    @Override
    public Server choose(Object key) {
        // 2. 获取负载均衡器
        ILoadBalancer lb = getLoadBalancer();
        
        // 3. 获取所有服务实例列表(这里获取的是所有已知的,包括不健康的,通常我们需要过滤)
        // 为了演示简单,我们直接获取所有服务器。实际生产中建议用 lb.getReachableServers()
        List<Server> allServers = lb.getAllServers();
        
        if (allServers == null || allServers.isEmpty()) {
            return null;
        }

        // --- 核心逻辑开始 ---

        // 假设我们通过某种方式(比如从配置中心或元数据)获取每台机器的权重
        // 这里为了演示,我们手动模拟权重列表
        List<Server> weightedServers = new ArrayList<>();
        
        // 模拟:把权重高的服务器,在列表里多放几次
        // 比如 A(权重8), B(权重2) -> 列表变成 [A, A, A, A, A, A, A, A, B, B]
        // 这样随机选的时候,选到 A 的概率自然就是 80%
        for (Server server : allServers) {
            // 假设我们从元数据里读取权重,默认给 1
            int weight = 1; 
            // 实际代码可能是: server.getMetadata().get("weight")
            
            // 这里简单演示:如果是 192.168.1.100,权重设为 5,其他设为 1
            if ("192.168.1.100".equals(server.getHost())) {
                weight = 5;
            }

            for (int i = 0; i < weight; i++) {
                weightedServers.add(server);
            }
        }

        // 4. 从扩充后的列表中随机选一个
        if (weightedServers.isEmpty()) {
            return null;
        }
        
        // 随机一个索引
        int index = random.nextInt(weightedServers.size());
        Server chosen = weightedServers.get(index);
        
        System.out.println("选中了服务器: " + chosen.getHost() + " (权重策略)");
        return chosen;
        
        // --- 核心逻辑结束 ---
    }

    // 这个方法一般不需要动,它是用来初始化配置的
    @Override
    public void initWithNiwsConfig(IClientConfig clientConfig) {
        // 可以在这里读取配置文件里的参数
    }
}

上面的代码用了一个最笨但最有效的办法叫**"加权随机"**。我想让 A 被选中的概率是 80%,那我就在列表里放 8 个 A;想让 B 是 20%,就放 2 个 B。然后在这个大列表里随机抽一个,抽到 A 的概率自然就是 80% 了。

第二步:上岗(配置生效)

代码写好了,怎么让 Ribbon 知道用它?有两种方式,推荐方式一

方式一:在代码里配置(推荐,针对特定服务)

如果你只想对 user-service 这个服务用这个策略,可以在配置类里这么写:

复制代码
import com.netflix.loadbalancer.IRule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RibbonConfig {

    // 注意:Bean 的名字最好和服务名有关,或者直接返回 IRule 类型
    @Bean
    public IRule myCustomRule() {
        return new CustomWeightedRule();
    }
}

然后在启动类或者 @FeignClient 里指定这个配置:

复制代码
// 指定配置类
@FeignClient(name = "user-service", configuration = RibbonConfig.class)
public interface UserClient {
    // ...
}

方式二:在配置文件里写(全局生效)

如果你想让所有服务都用这个策略,可以在 application.yml 里写:

复制代码
user-service: # 服务名
  ribbon:
    NFLoadBalancerRuleClassName: com.yourpackage.CustomWeightedRule # 你的类的全限定名

虽然代码很简单,但实际用的时候要注意这几个点:

  1. 线程安全choose 方法会被高并发调用,所以你的类里不要定义可变的成员变量 (除非加锁或用原子类)。上面的 Random 是线程安全的,但如果你定义个 int count 来计数,那就得小心了。
  2. 空指针保护 :一定要判断 allServers 是否为空。如果注册中心挂了,或者没有可用实例,你的代码不能崩,要返回 null 让上层去处理重试。
  3. 元数据来源 :上面的代码是硬编码 IP 来演示权重。实际项目中,权重通常存在注册中心 (比如 Nacos 的元数据 Metadata 里)。你需要通过 server.getMetadata().get("weight") 来动态获取。
4.Ribbon的权重从Nacos读取

Nacos 的 SDK 内部其实已经实现了加权随机算法。它会根据你在 Nacos 控制台设置的权重(比如 A 是 10,B 是 1),在客户端内部维护一个加权列表。所以,我们不需要 在 Java 代码里去手动计算权重、写随机算法。我们只需要**"截胡"** Ribbon 的选路过程,直接告诉它:"别你自己瞎选了,直接问 Nacos 要一个实例,Nacos 给谁你就用谁。"

第一步:自定义规则类(核心代码)

我们需要写一个类继承 AbstractLoadBalancerRule,但在 choose 方法里,我们不写算法,而是直接调用 Nacos 的 API。

复制代码
package com.example.config;

import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.ribbon.NacosServer;
import com.alibaba.nacos.api.exception.NacosException;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.pojo.Instance;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.AbstractLoadBalancerRule;
import com.netflix.loadbalancer.BaseLoadBalancer;
import com.netflix.loadbalancer.Server;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;

@Slf4j
public class NacosWeightedRule extends AbstractLoadBalancerRule {

    @Autowired
    private NacosDiscoveryProperties nacosDiscoveryProperties;

    @Override
    public void initWithNiwsConfig(IClientConfig iClientConfig) {
        // 初始化配置,一般留空即可
    }

    @Override
    public Server choose(Object key) {
        try {
            // 1. 获取当前要调用的服务名称(比如 "user-service")
            BaseLoadBalancer loadBalancer = (BaseLoadBalancer) this.getLoadBalancer();
            String serviceName = loadBalancer.getName();

            // 2. 获取 Nacos 的命名服务
            NamingService namingService = nacosDiscoveryProperties.namingServiceInstance();

            // 3. 【核心关键】
            // 调用 Nacos 自带的 selectOneHealthyInstance
            // 这个方法内部已经根据你在 Nacos 控制台配置的权重,算好了选哪一个
            // 它返回的 Instance 就是已经加权过的结果
            Instance instance = namingService.selectOneHealthyInstance(serviceName);

            if (instance == null) {
                log.warn("Nacos 没有返回健康的实例: {}", serviceName);
                return null;
            }

            log.info("Ribbon 选择了 Nacos 推荐的实例: {}:{}", instance.getIp(), instance.getPort());
            
            // 4. 将 Nacos 的 Instance 包装成 Ribbon 的 Server 对象返回
            return new NacosServer(instance);

        } catch (NacosException e) {
            log.error("调用 Nacos 服务发现接口异常", e);
            return null;
        }
    }
}
  • 我们完全绕过了 Ribbon 自带的 RoundRobinRule(轮询)或 RandomRule(随机)。
  • 我们直接调用了 namingService.selectOneHealthyInstance(serviceName)
  • Nacos SDK 的魔法 :这个 SDK 方法内部已经读取了服务列表的 weight 字段,并且维护了一个加权随机算法。它每次调用,都会根据权重概率返回一个实例。
  • 所以,我们的自定义规则只需要做一个"传话筒",把 Nacos 选好的实例交给 Ribbon 即可。
第二步:配置生效

代码写好了,现在要告诉 Ribbon 启用它。上面写了,为了完整性,再写一遍

方式一:通过配置文件(推荐,简单)

在你的 application.yml 中,针对特定的服务(假设叫 user-service)进行配置

复制代码
user-service: # 这里的服务名必须和 @FeignClient(name="...") 或 RestTemplate 调用名一致
  ribbon:
    # 指定使用我们刚才写的类
    NFLoadBalancerRuleClassName: com.example.config.NacosWeightedRule

方式二:通过 Java 配置类

如果你更喜欢用代码配置,或者需要针对不同的服务用不同的规则:

复制代码
import org.springframework.cloud.netflix.ribbon.RibbonClient;
import org.springframework.context.annotation.Configuration;

@Configuration
// 指定针对 user-service 服务,使用 RibbonConfig 的配置
@RibbonClient(name = "user-service", configuration = RibbonConfig.class)
public class RibbonConfig {

    // 在配置类里定义 Bean,或者直接 new 都可以
    // 只要 Ribbon 能扫描到这个配置类即可
}
第三步:去 Nacos 控制台设置权重

代码和配置都好了,最后一步是去 Nacos 控制台验证。太简单、不说了

第四步:验证效果

启动你的消费者服务,疯狂发送请求。观察控制台日志(我在代码里加了 log.info):

复制代码
Ribbon 选择了 Nacos 推荐的实例: 192.168.1.100:8080
Ribbon 选择了 Nacos 推荐的实例: 192.168.1.100:8080
Ribbon 选择了 Nacos 推荐的实例: 192.168.1.100:8080
Ribbon 选择了 Nacos 推荐的实例: 192.168.1.101:8081
Ribbon 选择了 Nacos 推荐的实例: 192.168.1.100:8080
...

第三层:加权响应时间算法(WeightedResponseTimeRule)------ 动态反馈系统

这是 Ribbon 中最"智能"的算法。它不是静态配置权重,而是根据实时响应时间动态计算权重。

最精明的领位员

规矩 :手里拿着个小本本,记录每张桌子的上菜速度

  • 如果1号桌上菜巨快,领位员就会把70%的新客人都往1号桌领。

  • 如果3号桌总是上菜慢,领位员就很少往那领,除非别的桌都满了。

  • 优点谁行谁上。系统会自动把压力给到性能最好的机器,整体效率最高。

  • 代码里:这就是那个定时任务在后台算平均分,分高的权重就大。

1. 数学原理

这是一个反比加权 模型。

假设服务器 SiSi​ 的平均响应时间为 RTiRTi​ ,则其权重 WiWi​ 为:

Wi=1RTiWi​=RTi​1​

为了便于选择,我们计算累积权重 CWiCWi​ :

CWi=∑k=0iWkCWi​=k=0∑i​Wk​

选择过程就是生成一个 [0,CWtotal][0,CWtotal​] 之间的随机数 RR ,找到第一个满足 CWi≥RCWi​≥R 的服务器。

2. 源码逻辑深度剖析

这个算法由两部分组成:定时计算任务 + 随机选择逻辑

A. 动态权重计算(后台守护线程)

Ribbon 会启动一个 DynamicServerWeightTask 定时任务(默认每 30 秒,或者根据流量动态调整)。

复制代码
// 伪代码还原
class ServerWeight {
    public void maintainWeights() {
        List<Server> servers = allServerList;
        List<Double> weights = new ArrayList<>();
        double totalWeight = 0;

        for (Server server : servers) {
            // 1. 获取该服务器的平均响应时间 (RT)
            // Ribbon 内部维护了一个滑动窗口来统计 RT
            double responseTime = stats.getSingleServerStat(server).getResponseTimeAvg();
            
            // 2. 计算权重:响应时间越短,权重越大
            // 如果 RT 为 0,给一个默认大值
            double weight = (responseTime == 0) ? 1.0 : 1.0 / responseTime;
            
            totalWeight += weight;
            weights.add(totalWeight); // 存储累积权重
        }
        
        // 3. 更新全局的权重列表(volatile 保证可见性)
        accumulatedWeights = weights;
    }
}

B. 请求选择逻辑

复制代码
@Override
public Server choose(ILoadBalancer lb, Object key) {
    // 1. 检查权重是否初始化(刚开始没有统计数据,退化为轮询)
    if (maxTotalWeight < 0.001d) {
        return super.choose(lb, key); // 降级为轮询
    }

    // 2. 生成随机数 [0, maxTotalWeight)
    double randomWeight = random.nextDouble() * maxTotalWeight;

    // 3. 线性查找(因为 accumulatedWeights 是有序的)
    int n = 0;
    for (Double weight : accumulatedWeights) {
        if (weight >= randomWeight) {
            return upList.get(n); // 命中!
        }
        n++;
    }
    return null;
}
  • 反馈机制 :这实际上是一个简单的负反馈控制系统。某台机器慢了 -> 权重降低 -> 流量减少 -> 机器负载降低 -> 响应变快 -> 权重回升。
  • 平滑性:权重的更新是异步的,不会在请求路径上计算,保证了接口的低延迟。

第四层:Spring Cloud LoadBalancer (SCLB) ------ 响应式时代的进化

Spring Cloud LoadBalancer 是 Spring 官方推出的替代品,旨在解决 Ribbon 的阻塞模型问题,并更好地支持 Reactor(WebFlux)。

1. 核心架构变化
  • Ribbon :基于 ILoadBalancer 接口,阻塞式。
  • SCLB :基于 ReactorLoadBalancer<ServiceInstance> 接口,返回 Mono<ServiceInstance>(响应式流)。
2. 核心代码实现(轮询)

SCLB 的实现更加函数式,它利用了 AtomicIntegerServiceInstanceListSupplier

复制代码
// 简化的 SCLB 轮询实现逻辑
public class RoundRobinLoadBalancer implements ReactorLoadBalancer<ServiceInstance> {
    
    private final AtomicInteger position;
    private final String serviceId;
    private final ServiceInstanceListSupplier supplier;

    @Override
    public Mono<ServiceInstance> choose(Request request) {
        // 1. 获取服务列表(从注册中心)
        return supplier.get().next()
            .map(instances -> {
                // 2. 过滤空列表
                if (instances.isEmpty()) return null;
                
                // 3. 核心算法:位置自增 & 取模
                int pos = this.position.incrementAndGet() & Integer.MAX_VALUE; // 保证正数
                ServiceInstance instance = instances.get(pos % instances.size());
                
                return instance;
            });
    }
}
  • 懒加载与流式处理 :SCLB 只有在真正需要发起请求时(subscribe() 时)才会去获取服务列表,避免了 Ribbon 那种定期轮询更新列表的资源浪费。
  • 扩展性 :SCLB 的 Request 上下文更加丰富,可以传递 ReactiveLoadBalancer.ClientFilter 中的各种元数据,方便做更复杂的灰度路由。

第五层:一致性哈希(Consistent Hashing)------ 解决"缓存击穿"的终极武器

虽然你主要问了轮询和加权,但作为ai时代的程序员,必须知道在有状态服务(比如 Redis 客户端分片、RPC 会话保持)场景下,轮询是灾难。

认死理的领位员(解决"缓存"问题)

场景:假设你是个老顾客,你每次来都习惯坐靠窗的位置,而且你的茶水都提前泡好放在那了。

  • 普通领位员(轮询):不管你,今天按顺序把你安排到了大厅。你到了发现没茶水,还得重新泡,麻烦死了。

  • 一致性哈希领位员 :他记得你的特征(比如你的脸,或者你的会员号)。只要靠窗的桌子还在,他永远把你安排在那。

    • 只有当靠窗的桌子坏了(节点宕机),他才会把你安排到离靠窗最近的那张桌子。
  • 优点老客户体验好。你的"茶水"(缓存数据)不用重新弄,连接也不用重新建。

1. 痛点

轮询算法中,如果节点数 NN 变为 N+1N+1 ,映射关系变为 i(modN+1)i(modN+1) ,导致几乎所有的请求都重新映射,缓存命中率归零。

2. 数学原理

利用哈希环。

  • 将节点 IP 和 请求参数(如 UserID)都通过哈希函数(如 MD5)映射到一个 232232 的圆环上。
  • 请求顺时针寻找遇到的第一个节点。
  • 增减节点:只会影响环上逆时针方向相邻节点的数据,其他节点不受影响。
3. 虚拟节点(Virtual Node)

为了防止节点太少导致数据倾斜(比如 3 台机器在环上分布不均),通常给每台物理机器生成 100-1000 个虚拟节点

代码思路(伪代码):

复制代码
SortedMap<Long, Server> circle = new TreeMap<>(); // 哈希环

// 初始化:添加物理节点及其虚拟节点
for (Server server : servers) {
    for (int i = 0; i < 100; i++) { // 100个虚拟节点
        long hash = hashFunction.apply(server.getIp() + "#" + i);
        circle.put(hash, server);
    }
}

// 选择算法
public Server choose(String requestKey) {
    long hash = hashFunction.apply(requestKey);
    // 找到环上第一个大于等于 hash 的节点
    Map.Entry<Long, Server> entry = circle.ceilingEntry(hash);
    if (entry == null) {
        // 如果到了环尾,回到环头
        entry = circle.firstEntry();
    }
    return entry.getValue();
}
算法/框架 底层原理 适用场景 缺点
轮询 (Ribbon/SCLB) AtomicInteger CAS 自增 + 取模 无状态服务,机器性能均等 无法感知机器负载,扩容有抖动
加权响应时间 (Ribbon) 滑动窗口统计 RT + 反比加权 + 随机选择 机器性能不均,或对延迟敏感 权重更新有延迟,初期有冷启动问题
随机 (RandomRule) java.util.Random 简单的无状态服务 分配不如轮询均匀
一致性哈希 哈希环 + 虚拟节点 RPC 调用、Redis 分片、会话保持 实现复杂,环的管理开销大
最小连接数 维护活跃连接计数器 长连接服务(如 Dubbo, DB 连接池) 需要实时统计连接数,有锁竞争

总结一下

  • Ribbon/SCLB 就是那个领位员
  • 轮询:排队坐,最公平,但不管机器快慢。
  • 加权:谁快给谁,最智能,但计算稍微麻烦点。
  • 一致性哈希:熟人熟座,最省资源,适合那种"不能换人"的场景。
最后的建议
  • 如果是HTTP 微服务 (Spring Cloud),默认用 SCLB 轮询 即可,简单高效。
  • 如果是RPC 框架 (Dubbo/Feign),强烈建议使用 一致性哈希最小活跃数,以减少网络抖动和长连接建立开销。
  • 如果后端机器性能差异巨大 (比如新老机器混部),必须上 加权算法
相关推荐
小江的记录本2 小时前
【JEECG Boot】 JEECG Boot 数据字典管理——六大核心功能(内含:《JEECG Boot 数据字典开发速查清单》)
java·前端·数据库·spring boot·后端·spring·mybatis
小江的记录本2 小时前
【JEECG Boot】 JEECG Boot——Online表单 系统性知识体系全解
java·前端·spring boot·后端·spring·低代码·mybatis
WangJunXiang62 小时前
初识Flask框架
后端·python·flask
希望永不加班2 小时前
SpringBoot 邮件发送:文本邮件与 HTML 邮件
java·spring boot·后端·spring·html
devlei10 小时前
从源码泄露看AI Agent未来:深度对比Claude Code原生实现与OpenClaw开源方案
android·前端·后端
Accerlator11 小时前
2026 年 4 月 1 日电话面试
面试·职场和发展
努力的小郑11 小时前
Canal 不难,难的是用好:从接入到治理
后端·mysql·性能优化
Victor35612 小时前
MongoDB(87)如何使用GridFS?
后端
Victor35612 小时前
MongoDB(88)如何进行数据迁移?
后端