一、核心概念
负载均衡(Load Balancing)是分布式系统中的核心机制,用于在多个服务提供者之间分配请求流量。
1. 为什么需要负载均衡
在RPC框架中,一个服务可能有多个提供者节点:
提高可用性:某个节点故障时,请求可以转发到其他节点;
提升性能:将请求分散到多个节点,避免单点过载;
支持扩展:可以动态增加服务节点来应对流量增长。
2. 负载均衡与容错的区别
负载均衡:在正常情况下,如何选择一个最优的服务节点;
容错机制:在异常情况下,如何处理失败的请求。
两者配合使用,构成完整的服务调用保障体系。
3. 负载均衡器接口设计
java
public interface LoadBalancer {
ServiceMetaInfo select(Map<String, Object> requestParams,
List<ServiceMetaInfo> serviceMetaInfoList);
}
接口说明:
requestParams:请求参数,可用于某些算法(如一致性哈希);
serviceMetaInfoList:可用的服务节点列表;
返回值:选中的服务节点。
二、随机算法(Random)
1. 算法原理
从服务列表中随机选择一个节点。
2. 实现代码
java
public class RandomLoadBalancer implements LoadBalancer {
private final Random random = new Random();
@Override
public ServiceMetaInfo select(Map<String, Object> requestParams,
List<ServiceMetaInfo> serviceMetaInfoList) {
int size = serviceMetaInfoList.size();
if (size == 0) {
return null;
}
if (size == 1) {
return serviceMetaInfoList.get(0);
}
return serviceMetaInfoList.get(random.nextInt(size));
}
}
3. 算法特点
优点:实现简单,分布均匀(长期来看);
缺点:短期内可能不均匀,无法保证顺序;
适用场景:服务节点性能相近,无状态服务。
三、轮询算法(Round Robin)
1. 算法原理
按顺序依次选择服务节点,循环往复。
2. 实现代码
java
public class RoundRobinLoadBalancer implements LoadBalancer {
private final AtomicInteger currentIndex = new AtomicInteger(0);
@Override
public ServiceMetaInfo select(Map<String, Object> requestParams,
List<ServiceMetaInfo> serviceMetaInfoList) {
if (serviceMetaInfoList.isEmpty()) {
return null;
}
int size = serviceMetaInfoList.size();
if (size == 1) {
return serviceMetaInfoList.get(0);
}
// 取模算法轮询
int index = currentIndex.getAndIncrement() % size;
return serviceMetaInfoList.get(index);
}
}
3. AtomicInteger 详解
为什么使用 AtomicInteger?
在多线程环境下,普通的 int 类型不是线程安全的:
java
// 错误示例:线程不安全
private int currentIndex = 0;
int index = currentIndex++ % size; // 可能导致多个线程获得相同的index
AtomicInteger 如何保证线程安全?
java
// 正确示例:线程安全
private final AtomicInteger currentIndex = new AtomicInteger(0);
int index = currentIndex.getAndIncrement() % size;
getAndIncrement() 方法是原子操作:获取当前值;将值加1;返回原始值。这三步在底层通过 CAS(Compare-And-Swap)机制保证原子性,不会被其他线程打断。
4. 执行示例
假设有3个服务节点:[Node-A, Node-B, Node-C]
java
请求1: currentIndex=0 → 0%3=0 → 选择 Node-A → currentIndex变为1
请求2: currentIndex=1 → 1%3=1 → 选择 Node-B → currentIndex变为2
请求3: currentIndex=2 → 2%3=2 → 选择 Node-C → currentIndex变为3
请求4: currentIndex=3 → 3%3=0 → 选择 Node-A → currentIndex变为4
...循环往复
5. 算法特点
优点:分布均匀,实现简单,可预测;
缺点:不考虑节点性能差异;
适用场景:服务节点性能相近,需要均匀分配。
四、一致性哈希算法(Consistent Hash)
1. 算法原理
一致性哈希是一种特殊的哈希算法,主要解决分布式系统中节点动态增减时的数据重新分配问题。
2. 核心概念
哈希环(Hash Ring):将哈希值空间(0 ~ 2^32-1)组织成一个虚拟的环形结构。
虚拟节点(Virtual Nodes):为每个真实节点创建多个虚拟节点,提高分布均匀性。
java
真实节点: 192.168.1.1:8080
虚拟节点:
- 192.168.1.1:8080#0 → hash值: 12345
- 192.168.1.1:8080#1 → hash值: 67890
- 192.168.1.1:8080#2 → hash值: 23456
...共100个虚拟节点
3. 实现代码
java
public class ConsistentHashLoadBalancer implements LoadBalancer {
// TreeMap 自动按 key 排序,用于实现哈希环
private final TreeMap<Integer, ServiceMetaInfo> virtualNodes = new TreeMap<>();
private static final int VIRTUAL_NODE_NUM = 100;
// 记录上次的服务节点列表,用于判断是否需要重建哈希环
private List<ServiceMetaInfo> lastServiceList = null;
@Override
public ServiceMetaInfo select(Map<String, Object> requestParams,
List<ServiceMetaInfo> serviceMetaInfoList) {
if (serviceMetaInfoList.isEmpty()) {
return null;
}
// 只有节点列表变化时才重建哈希环
if (lastServiceList == null || !isSameServiceList(lastServiceList, serviceMetaInfoList)) {
buildConsistentHashRing(serviceMetaInfoList);
lastServiceList = new ArrayList<>(serviceMetaInfoList);
}
// 获取请求的 hash 值
int hash = getHash(requestParams);
// 选择最接近且大于等于请求 hash 值的虚拟节点
Map.Entry<Integer, ServiceMetaInfo> entry = virtualNodes.ceilingEntry(hash);
if (entry == null) {
// 如果没有大于等于的节点,返回环首部节点(形成环状)
entry = virtualNodes.firstEntry();
}
return entry.getValue();
}
/**
* 构建一致性哈希环
*/
private void buildConsistentHashRing(List<ServiceMetaInfo> serviceMetaInfoList) {
// 清空旧的虚拟节点
virtualNodes.clear();
// 为每个服务节点创建虚拟节点
for (ServiceMetaInfo serviceMetaInfo : serviceMetaInfoList) {
for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
int hash = getHash(serviceMetaInfo.getServiceAddress() + "#" + i);
virtualNodes.put(hash, serviceMetaInfo);
}
}
}
/**
* 判断服务列表是否相同
*/
private boolean isSameServiceList(List<ServiceMetaInfo> list1,
List<ServiceMetaInfo> list2) {
if (list1.size() != list2.size()) {
return false;
}
// 比较服务地址集合
Set<String> set1 = list1.stream()
.map(ServiceMetaInfo::getServiceAddress)
.collect(Collectors.toSet());
Set<String> set2 = list2.stream()
.map(ServiceMetaInfo::getServiceAddress)
.collect(Collectors.toSet());
return set1.equals(set2);
}
private int getHash(Object key) {
return key.hashCode();
}
}
4. TreeMap 和 ceilingEntry() 详解
TreeMap 是什么?
TreeMap 是一个有序的 Map,内部使用红黑树实现,key 会自动按照自然顺序排序。
java
TreeMap<Integer, String> map = new TreeMap<>();
map.put(100, "A");
map.put(50, "B");
map.put(200, "C");
// 内部顺序:50→100→200
ceilingEntry() 方法:
ceilingEntry(key) 返回大于或等于给定 key 的最小 Entry。
java
TreeMap<Integer, String> map = new TreeMap<>();
map.put(10, "A");
map.put(20, "B");
map.put(30, "C");
map.ceilingEntry(15); // 返回 (20, "B")
map.ceilingEntry(20); // 返回 (20, "B")
map.ceilingEntry(35); // 返回 null
5. 一致性哈希执行流程
假设有2个服务节点,每个节点100个虚拟节点:
步骤1:构建哈希环(仅在节点列表变化时执行)
java
Node-A (192.168.1.1:8080):
- 192.168.1.1:8080#0 → hash: 1234
- 192.168.1.1:8080#1 → hash: 5678
- ...
- 192.168.1.1:8080#99 → hash: 9999
Node-B (192.168.1.2:8080):
- 192.168.1.2:8080#0 → hash: 2345
- 192.168.1.2:8080#1 → hash: 6789
- ...
- 192.168.1.2:8080#99 → hash: 8888
TreeMap 自动排序后:
1234→Node-A, 2345→Node-B, 5678→Node-A, 6789→Node-B, ...
步骤2:计算请求哈希值
java
requestParams = {method: "getUser", userId: 123}
hash = requestParams.hashCode() = 45678
步骤3:查找节点
java
// 在 TreeMap 中查找 >= 45678 的最小 key
entry = virtualNodes.ceilingEntry(45678);
// 假设找到 key=50000,对应 Node-B
return Node-B;
6. virtualNodes 的生命周期
所有请求共用同一个 virtualNodes。
由于 SpiLoader 使用单例模式,ConsistentHashLoadBalancer 只会创建一个实例:
java
// 所有请求共用同一个负载均衡器实例
LoadBalancer lb = LoadBalancerFactory.getInstance("consistentHash");
// 因此 virtualNodes 也是共享的
private final TreeMap<Integer, ServiceMetaInfo> virtualNodes = new TreeMap<>();
执行流程演示:
java
请求1: serviceMetaInfoList =[A, B, C]
→ lastServiceList == null
→ 构建哈希环: virtualNodes = {A的100个, B的100个, C的100个}
→ 保存 lastServiceList = [A, B, C]
→ 选择节点
请求2: serviceMetaInfoList = [A, B, C]
→ 检测到节点列表未变化(isSameServiceList 返回 true)
→ 不重建哈希环,直接使用现有 virtualNodes
→ 选择节点(性能高)
请求3: serviceMetaInfoList = [A, B, C]
→ 节点列表仍未变化
→ 继续使用现有 virtualNodes
→ 选择节点
(此时发生节点上线: [A, B, C, D])
请求4: serviceMetaInfoList = [A, B, C, D]
→ 检测到节点列表变化(多了 D)
→ 清空 virtualNodes
→ 重建哈希环: virtualNodes = {A的100个, B的100个, C的100个, D的100个}
→ 保存 lastServiceList = [A, B, C, D]
→ 选择节点
请求5: serviceMetaInfoList = [A, B, C, D]
→ 节点列表未变化
→ 使用现有 virtualNodes
→ 选择节点
7. 为什么需要虚拟节点
没有虚拟节点的问题:
假设只有2个真实节点
java
Node-A hash: 1000
Node-B hash: 9000
哈希环上只有2个点,分布极不均匀:hash值在 1000~9000 之间的请求全部打到 Node-B;hash值在 9000~1000 之间的请求全部打到 Node-A。
使用虚拟节点后:
每个节点有100个虚拟节点,哈希环上有200个点,分布更均匀:
java
1234→A, 2345→B, 3456→A, 4567→B, 5678→A, 6789→B, ...
这样请求会更均匀地分配到各个节点。
8. 一致性哈希的优势
场景:节点动态变化
假设有3个节点 [A, B, C],使用普通哈希:
java
index = hash(request) % 3; // 节点数=3
当增加一个节点D后:
java
index = hash(request) % 4; // 节点数=4
几乎所有请求的目标节点都会改变!
一致性哈希的优势:增加或删除节点时,只影响哈希环上相邻的部分节点,大部分请求的目标节点不变。
具体示例:
java
初始状态:3个节点 [A, B, C]
哈希环(简化):
0 ----A(1000)---- B(5000) ----C(8000)---- 10000(环回到A)
请求1 (hash=3000) → ceilingEntry(3000) → B (第一个 >= 3000 的节点)
请求2 (hash=7000) → ceilingEntry(7000) → C (第一个 >= 7000 的节点)
请求3 (hash=9000) → ceilingEntry(9000) → A (没有 >= 9000 的,返回首节点)
删除 Node-B 后,哈希环变为:
0 ----A(1000)---- C(8000)---- 10000(环回到A)
请求1 (hash=3000) → ceilingEntry(3000) → C (改变:B删除,顺延到C)
请求2 (hash=7000) → ceilingEntry(7000) → C (不变:仍然是C)
请求3 (hash=9000) → ceilingEntry(9000) → A (不变:仍然是A)
结论:只有原本打到 B 的请求受影响,其他请求不变。
对比普通哈希(删除 Node-B 后):
-
请求1: hash % 3 = 0 → A 变为 hash % 2 = 0 → A (可能改变)
-
请求2: hash % 3 = 1 → B 变为 hash % 2 = 1 → C (改变)
-
请求3: hash % 3 = 2 → C 变为 hash % 2 = 0 → A (改变)
(几乎所有请求都改变了!)
9. 节点变化如何影响 virtualNodes
关键机制
-
单例共享:所有请求共用同一个 ConsistentHashLoadBalancer 实例;
-
变化检测:通过 isSameServiceList() 比较节点列表是否变化;
-
按需重建:只有节点列表变化时才重建哈希环。
节点变化的完整流程
-
时刻1:注册中心有 [A, B, C]
-
请求1 → 获取 [A, B, C] → 构建哈希环(300个虚拟节点)
-
请求2/3 → 获取 [A, B, C] → 检测未变化,复用哈希环
-
-
时刻2:Node-D 上线,注册中心变为[A, B, C, D]
-
请求4 → 获取 [A, B, C, D] → 检测到变化,重建哈希环(400个虚拟节点)
-
请求5/6 → 获取 [A, B, C, D] → 检测未变化,复用哈希环
-
-
时刻3:Node-B 下线,注册中心变为 [A, C, D]
-
请求7 → 获取 [A, C, D] → 检测到变化,重建哈希环(300个虚拟节点)
-
请求8 → 获取 [A, C, D] → 检测未变化,复用哈希环
-
注册中心如何影响 virtualNodes
java
// ServiceProxy.java
public Object invoke(...) {
// 1. 从注册中心获取最新的服务节点列表
List<ServiceMetaInfo> serviceMetaInfoList =
registry.serviceDiscovery(serviceKey);
// 2. 传给负载均衡器
LoadBalancer loadBalancer = LoadBalancerFactory.getInstance(...);
ServiceMetaInfo selected = loadBalancer.select(requestParams, serviceMetaInfoList);
// 3. 负载均衡器内部检测节点列表是否变化
// - 如果变化:重建 virtualNodes
// - 如果不变:复用 virtualNodes
}
10. 算法特点
-
优点:节点变化时影响范围小,适合缓存场景;
-
缺点:实现复杂,需要维护哈希环;
-
适用场景:需要会话保持,相同请求打到同一节点。
五、负载均衡器工厂与SPI机制
1. 工厂类实现
java
public class LoadBalancerFactory {
static {
SpiLoader.load(LoadBalancer.class);
}
private static final LoadBalancer DEFAULT_LOAD_BALANCER =
new RoundRobinLoadBalancer();
public static LoadBalancer getInstance(String key) {
return SpiLoader.getInstance(LoadBalancer.class, key);
}
}
2. 策略常量
java
public interface LoadBalancerKeys {
String ROUND_ROBIN = "roundRobin";
String RANDOM = "random";
String CONSISTENT_HASH = "consistentHash";
}
3. SPI 配置文件
文件路径:META-INF/rpc/system/(你的接口路径)
java
roundRobin=com.szj.example.szjrpceasy.loadBalancer.RoundRobinLoadBalancer(你的实现类路径)
random=com.szj.example.szjrpceasy.loadBalancer.RandomLoadBalancer
consistentHash=com.szj.example.szjrpceasy.loadBalancer.ConsistentHashLoadBalancer
六、在 ServiceProxy 中的使用
1. 集成方式
java
public class ServiceProxy implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
// 1. 从注册中心获取服务节点列表
List<ServiceMetaInfo> serviceMetaInfoList =
registryService.serviceDiscovery(serviceKey);
// 2. 使用负载均衡器选择节点
LoadBalancer loadBalancer = LoadBalancerFactory.getInstance(
rpcConfig.getLoadBalancer()
);
ServiceMetaInfo selectedService = loadBalancer.select(
requestParams,
serviceMetaInfoList
);
// 3. 向选中的节点发起 RPC 调用
RpcResponse rpcResponse = doRequest(rpcRequest, selectedService);
return rpcResponse.getData();
}
}
2. 执行流程
java
消费者发起调用
↓
ServiceProxy.invoke()
↓
从注册中心获取服务列表: [Node-A, Node-B, Node-C]
↓
负载均衡器选择节点: Node-B
↓
向 Node-B 发起 RPC 请求
↓
返回结果
七、三种算法对比
|-----------|----------|-------|------------------|------|---------|
| 算法 | 分布均匀性 | 实现复杂度 | 线程安全 | 会话保持 | 适用场景 |
| 随机 | 中等(长期均匀) | 简单 | 是 | 否 | 无状态服务 |
| 轮询 | 高 | 简单 | 是(AtomicInteger) | 否 | 通用场景 |
| 一致性哈希 | 高(需虚拟节点) | 复杂 | 是 | 是 | 缓存、会话保持 |
八、算法选择指南
-
选择随机算法:服务节点性能相近;无状态服务;对顺序无要求。
-
选择轮询算法(推荐):通用场景;需要均匀分配;服务节点性能相近。
-
选择一致性哈希:需要会话保持(同一用户请求打到同一节点);缓存场景(避免缓存失效);节点频繁变化的场景。
九、与容错机制的配合
负载均衡和容错机制是两层保障:
-
第一层:负载均衡
-
在正常情况下选择最优节点
-
目标:性能优化、流量分配
-
-
第二层:容错机制
-
在异常情况下处理失败
-
目标:提高可用性、降级保护
-
执行流程:
java
消费者调用
↓
负载均衡器选择节点: Node-A
↓
发起 RPC 请求
↓
请求失败
↓
容错策略介入:
- FailFast: 立即抛出异常
- FailSafe: 记录日志,返回空结果
- FailOver: 重试其他节点 (Node-B, Node-C)
- FailBack: 返回降级结果
十、实际应用示例
示例1:电商系统用户服务
// 场景:用户服务有3个节点
List<ServiceMetaInfo> userServiceNodes = Arrays.asList(
"192.168.1.1:8080",
"192.168.1.2:8080",
"192.168.1.3:8080"
);
// 使用轮询算法
LoadBalancer lb = new RoundRobinLoadBalancer();
// 10个请求的分配:
// 请求1 → 192.168.1.1:8080
// 请求2 → 192.168.1.2:8080
// 请求3 → 192.168.1.3:8080
// 请求4 → 192.168.1.1:8080
// ...
示例2:缓存服务(需要会话保持)
java
// 场景:缓存服务,相同用户的请求需要打到同一节点
Map<String, Object> requestParams = Map.of("userId", 12345);
// 使用一致性哈希
LoadBalancer lb = new ConsistentHashLoadBalancer();
// 同一用户的多次请求
ServiceMetaInfo node1 = lb.select(requestParams, cacheServiceNodes);
ServiceMetaInfo node2 = lb.select(requestParams, cacheServiceNodes);
// node1 == node2 (始终分配到同一个节点)
十一、总结
核心要点回顾:
负载均衡的本质:在多个服务节点中选择一个最优节点。
三种算法:随机、轮询、一致性哈希,各有适用场景。
线程安全:轮询算法使用 AtomicInteger 保证线程安全。
一致性哈希:通过虚拟节点和 TreeMap 实现,适合会话保持。
设计模式 :使用 工厂模式 + SPI机制,可以灵活切换负载均衡策略。