看负载均衡不能只看"怎么分发的",要看**"服务列表从哪来"** 、"如何保证线程安全" 、"如何感知服务健康状态" 以及**"算法的数学模型"**。
你可以把负载均衡 想象成一家火爆餐厅的"领位员"。
- 客户端(你):就是去吃饭的顾客。
- 服务实例(服务器):就是餐厅里的桌子。
- 负载均衡器(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.自定义算法
- 造人 :写一个类继承
AbstractLoadBalancerRule(这是IRule的默认实现,省事)。 - 定规矩 :重写
choose方法,把你的业务逻辑写进去。 - 上岗:告诉 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 # 你的类的全限定名
虽然代码很简单,但实际用的时候要注意这几个点:
- 线程安全 :
choose方法会被高并发调用,所以你的类里不要定义可变的成员变量 (除非加锁或用原子类)。上面的Random是线程安全的,但如果你定义个int count来计数,那就得小心了。 - 空指针保护 :一定要判断
allServers是否为空。如果注册中心挂了,或者没有可用实例,你的代码不能崩,要返回null让上层去处理重试。 - 元数据来源 :上面的代码是硬编码 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=RTi1
为了便于选择,我们计算累积权重 CWiCWi :
CWi=∑k=0iWkCWi=k=0∑iWk
选择过程就是生成一个 [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 的实现更加函数式,它利用了 AtomicInteger 和 ServiceInstanceListSupplier。
// 简化的 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),强烈建议使用 一致性哈希 或 最小活跃数,以减少网络抖动和长连接建立开销。
- 如果后端机器性能差异巨大 (比如新老机器混部),必须上 加权算法。