上篇中我们依次分析了Dubbo四大负载均衡算法实现细节,在分析第四种负载均衡算法实现的时候我们说到在2.6.4 版本及之前版本的 RoundRobinLoadBalance 在某些情况下存在着比较严重的性能问题——doSelect 的效率与 mod有关,时间复杂度为 O(mod)。mod 又受最大权重 maxWeight 的影响,因此当某个服务提供者配置了非常大的权重,此时 RoundRobinLoadBalance 会产生比较严重的性能问题。为此Dubbo社区做出了回应并在后续版本中进行了重构。但是重构后的新版本不久后又发现了一些弊端,继而又进行了二次重构。
这次重构原因是新的 RoundRobinLoadBalance 在某些情况下选出的服务器序列不够均匀。
比如,服务器 [A, B, C] 对应权重 [5, 1, 1]。进行7次负载均衡后,使用第一次重构后的算法选择出来的序列为 [A, A, A, A, A, B, C]。前5个请求全部都落在了服务器 A上,这将会使服务器 A 短时间内接收大量的请求,压力陡增。而 B 和 C 此时无请求,处于空闲状态。而我们期望的结果是这样的 [A, A, B, A, C, A, A],不同服务器可以穿插获取请求。为了增加负载均衡结果的平滑性,社区再次对 RoundRobinLoadBalance 的实现进行了重构,这次重构参考自 Nginx 的平滑加权轮询负载均衡。大致处理流程是每个服务器对应两个权重,分别为 weight 和 currentWeight。其中 weight 是固定的,currentWeight 会动态调整,初始值为0。当有新的请求进来时,遍历服务器列表,让它的 currentWeight 加上自身权重。遍历完成后,找到最大的 currentWeight,并将其减去权重总和,然后返回相应的服务器即可。
上面描述似乎不是很好理解,下面还是举例进行说明。这里仍然使用服务器 [A, B, C] 对应权重 [5, 1, 1] 的例子说明,现在有7个请求依次进入负载均衡逻辑,选择过程如下:
请求编号 | currentWeight | 选择结果 | 减去权重总和后的 currentWeight |
---|---|---|---|
1 | [5, 1, 1] | A | [-2, 1, 1] |
2 | [3, 2, 2] | A | [-4, 2, 2] |
3 | [1, 3, 3] | B | [1, -4, 3] |
4 | [6, -3, 4] | A | [-1, -3, 4] |
5 | [4, -2, 5] | C | [4, -2, -2] |
6 | [9, -1, -1] | A | [2, -1, -1] |
7 | [7, 0, 0] | A | [0, 0, 0] |
如上,经过平滑性处理后,得到的服务器序列为 [A, A, B, A, C, A, A],相比之前的序列 [A, A, A, A, A, B, C],分布性要好一些。初始情况下 currentWeight = [0, 0, 0],第7个请求处理完后,currentWeight 再次变为 [0, 0, 0],重新都归零的过程就是经过了一次轮询。
以上就是平滑加权轮询的大致过程,接下来,我们来看看 Dubbo-2.6.5 是如何实现上面的计算过程的。
public class RoundRobinLoadBalance3 extends AbstractLoadBalance {
public static final String NAME = "roundrobin";
//回收周期、也就是一个Invoker超过多长时间没有再次更新就剔除掉
private static int RECYCLE_PERIOD = 60000;
protected static class WeightedRoundRobin {
// 服务提供者权重
private int weight;
// 当前权重
private AtomicLong current = new AtomicLong(0);
// 最后一次更新时间
private long lastUpdate;
public void setWeight(int weight) {
this.weight = weight;
// 初始情况下,current = 0
current.set(0);
}
public long increaseCurrent() {
// current = current + weight;
return current.addAndGet(weight);
}
public void sel(int total) {
// current = current - total;
current.addAndGet(-1 * total);
}
public int getWeight() {
return weight;
}
public AtomicLong getCurrent() {
return current;
}
public void setCurrent(AtomicLong current) {
this.current = current;
}
public long getLastUpdate() {
return lastUpdate;
}
public void setLastUpdate(long lastUpdate) {
this.lastUpdate = lastUpdate;
}
}
// 最外层为服务类名 + 方法名,第二层为 url 到 WeightedRoundRobin 的映射关系。
// 这里我们可以将 url 看成是服务提供者的 id
private ConcurrentMap<String, ConcurrentMap<String, WeightedRoundRobin>> methodWeightMap = new ConcurrentHashMap<>();
// 原子更新锁,保证同一时刻只有一个线程在执行剔除服务操作
private AtomicBoolean updateLock = new AtomicBoolean();
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
// 获取 url 到 WeightedRoundRobin 映射表,如果为空,则创建一个新的
ConcurrentMap<String, WeightedRoundRobin> map = methodWeightMap.get(key);
if (map == null) {
methodWeightMap.putIfAbsent(key, new ConcurrentHashMap<>());
map = methodWeightMap.get(key);
}
int totalWeight = 0;
long maxCurrent = Long.MIN_VALUE;
// 获取当前时间
long now = System.currentTimeMillis();
Invoker<T> selectedInvoker = null;
WeightedRoundRobin selectedWRR = null;
// 下面这个循环主要做了这样几件事情:
// 1. 遍历 Invoker 列表,检测当前 Invoker 是否有
// 相应的 WeightedRoundRobin,没有则创建
// 2. 检测 Invoker 权重是否发生了变化,若变化了,
// 则更新 WeightedRoundRobin 的 weight 字段
// 3. 让 current 字段加上自身权重,等价于 current += weight
// 4. 设置 lastUpdate 字段,即 lastUpdate = now
// 5. 寻找具有最大 current 的 Invoker,以及 Invoker 对应的 WeightedRoundRobin,
// 暂存起来,留作后用
// 6. 计算权重总和
for (Invoker<T> invoker : invokers) {
String identifyString = invoker.getUrl().toIdentityString();
WeightedRoundRobin weightedRoundRobin = map.get(identifyString);
int weight = getWeight(invoker, invocation);
if (weight < 0) {
weight = 0;
}
// 检测当前 Invoker 是否有对应的 WeightedRoundRobin,没有则创建
if (weightedRoundRobin == null) {
weightedRoundRobin = new WeightedRoundRobin();
// 设置 Invoker 权重
weightedRoundRobin.setWeight(weight);
// 存储 url 唯一标识 identifyString 到 weightedRoundRobin 的映射关系
map.putIfAbsent(identifyString, weightedRoundRobin);
weightedRoundRobin = map.get(identifyString);
}
// Invoker 权重不等于 WeightedRoundRobin 中保存的权重,说明权重变化了,此时进行更新
if (weight != weightedRoundRobin.getWeight()) {
weightedRoundRobin.setWeight(weight);
}
// 让 current 加上自身权重,等价于 current += weight
long cur = weightedRoundRobin.increaseCurrent();
// 设置 lastUpdate,表示近期更新过
weightedRoundRobin.setLastUpdate(now);
// 找出最大的 current
if (cur > maxCurrent) {
maxCurrent = cur;
// 将具有最大 current 权重的 Invoker 赋值给 selectedInvoker
selectedInvoker = invoker;
// 将 Invoker 对应的 weightedRoundRobin 赋值给 selectedWRR,留作后用
selectedWRR = weightedRoundRobin;
}
// 计算权重总和
totalWeight += weight;
}
// 对 <identifyString, WeightedRoundRobin> 进行检查,过滤掉长时间未被更新的节点。
// 该节点可能挂了,invokers 中不包含该节点,所以该节点的 lastUpdate 长时间无法被更新。
// 若未更新时长超过阈值后,就会被移除掉,默认阈值为60秒。
if (!updateLock.get() && invokers.size() != map.size()) {
if (updateLock.compareAndSet(false, true)) {
try {
ConcurrentMap<String, WeightedRoundRobin> newMap = new ConcurrentHashMap<>();
// 拷贝
newMap.putAll(map);
// 遍历修改,即移除过期记录
Iterator<Map.Entry<String, WeightedRoundRobin>> it = newMap.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, WeightedRoundRobin> item = it.next();
if (now - item.getValue().getLastUpdate() > RECYCLE_PERIOD) {
it.remove();
}
}
// 更新引用
methodWeightMap.put(key, newMap);
} finally {
updateLock.set(false);
}
}
}
if (selectedInvoker != null) {
// 让 current 减去权重总和,等价于 current -= totalWeight
selectedWRR.sel(totalWeight);
// 返回具有最大 current 的 Invoker
return selectedInvoker;
}
// 这里一般不会发生
return invokers.get(0);
}
}
在这个平滑加权轮询负载均衡算法当中可以看到定义了一个RECYCLE_PERIOD,默认为60000毫秒,这个变量意思是Invoker的回收周期、也就是一个Invoker超过多长时间如果没有再次更新就剔除掉,说明这个Invoker不可用了。然后把weight和currentWeight封装到了WeightedRoundRobin这个类当中,可以猜想知道一个Invoker对应一个WeightedRoundRobin对象。然后methodWeightMap这个map是一个嵌套 Map 结构,最外层为服务类名 + 方法名,第二层为 url 到 WeightedRoundRobin 的映射关系。外层map用来缓存某个访问方法到所有Invoker的映射,内层map用来缓存Invoker到WeightedRoundRobin的映射。定义updateLock原子更新锁,保证同一时刻只有一个线程进行服务的剔除操作。
doSelect是真正的负载均衡实现方法,主要分为三部分:
①、获取 key 到 WeightedRoundRobin 映射表,如果为空,则创建一个新的、初始化总权重、最大权重等变量。利用for循环遍历 Invoker 列表,检测 Invoker 权重是否发生了变化,若变化了,则更新该Invoker的WeightedRoundRobin 的 weight 字段、让 current 字段加上自身权重,等价于 current += weight、设置 lastUpdate 字段,即 lastUpdate = now、寻找具有最大 current 的 Invoker,以及 Invoker 对应的 WeightedRoundRobin、暂存起来,留作后用、计算权重总和。
②、对 <identifyString, WeightedRoundRobin> 进行检查,过滤掉长时间未被更新的节点。该节点可能挂了,invokers 中不包含该节点,所以该节点的 lastUpdate 长时间无法被更新。若未更新时长超过阈值后,就会被移除掉,默认阈值为60秒。
③、让 current 减去权重总和,等价于 current -= totalWeight,返回selectedInvoker即可。
为了更加形象化的理解其过程,更好的帮助我们随后观察其原理,这里根据选择步骤依次提供了图示:
第一次,currentWeight数组[5, 1, 1],选择A
减去权重总和后的 currentWeight 数组[-2, 1, 1]
第二次,currentWeight数组[3, 2, 2],选择A
减去权重总和后的 currentWeight 数组[-4, 2, 2]
第三次,currentWeight数组[1, 3, 3],选择B
减去权重总和后的 currentWeight 数组[1, -4, 3]
第四次,currentWeight数组[6, -3, 4],选择A
减去权重总和后的 currentWeight 数组[-1, -3, 4]
第五次,currentWeight数组[4, -2, 5],选择C
减去权重总和后的 currentWeight 数组[4, -2, -2]
第六次,currentWeight数组[9, -1, -1],选择A
减去权重总和后的 currentWeight 数组[2, -1, -1]
第七次,currentWeight数组[7, 0, 0],选择A
减去权重总和后的 currentWeight 数组[0, 0, 0]
从第一次[5, 1, 1]、第二次[3, 2, 2]......第八次[0, 0, 0]完成一次轮询来看,其执行过程是不断地缩短每个Invoker之间的高度差,每次取最高的Invoker然后将其减去total,得到的值一定是剩下的所有Invoker的权重之和的相反数,这样做的目的是为了保证到最后能得到[0, 0, 0],也就是不断地往[0, 0, 0]趋近。执行到[0, 0, 0]时,也就意味着所有的权重都命中了一次,完成一次完整的轮询过程。
下面是以[10,1,1]为例的一次轮询过程,也具有类似特点。
这只是直观的在不断地增加自身权重增长变化和命中这样的过程中观察的结果,我觉得应该会有数学推理证明其严谨性,推理过程这里暂不做考究,如果感兴趣的话可以自行查阅相关资料。
dubbo框架更多模块解读相关源码持续更新中,感兴趣的朋友请移步至个人公众号,谢谢支持😜😜......
公众号:wenyixicodedog