深度全面学习负载均衡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−1S=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,CWtotal0,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),强烈建议使用 一致性哈希最小活跃数,以减少网络抖动和长连接建立开销。
  • 如果后端机器性能差异巨大 (比如新老机器混部),必须上 加权算法
相关推荐
止语Lab1 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活1 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困1 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn1 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530141 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908401 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端
Conan在掘金1 小时前
鸿蒙报错速查:Function lacks ending return statement,返回路径漏 return 就炸,根因 + 真解法
后端
nVisual2 小时前
数据中心机柜负载均衡与三相相位监控方案
运维·网络·负载均衡·数据中心布线·综合布线管理软件
人间凡尔赛2 小时前
AI-Native 云原生架构:2026 年从容器编排到智能体编排的范式革命
后端·云原生·架构
IT_陈寒2 小时前
Vue的响应式让我加班到凌晨3点,原来问题出在这
前端·人工智能·后端