您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
Dubbo负载均衡策略之 平滑加权轮询
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
Dubbo负载均衡策略之 平滑加权轮询
自猿其说Tech
2021-11-29
IP归属:未知
92440浏览
计算机编程
### 1 简介 **LoadBalance** 中文意思为负载均衡,它的职责是将网络请求,或者其他形式的负载“均摊”到不同的机器上。避免集群中部分服务器压力过大,而另一些服务器比较空闲的情况。通过负载均衡,可以让每台服务器获取到适合自己处理能力的负载。在为高负载服务器分流的同时,还可以避免资源浪费,一举两得。负载均衡可分为软件负载均衡和硬件负载均衡。在我们日常开发中,一般很难接触到硬件负载均衡。但软件负载均衡还是可以接触到的,比如 Nginx。在 Dubbo 中,也有负载均衡的概念和相应的实现。 Dubbo 需要对服务消费者的调用请求进行分配,避免少数服务提供者负载过大。服务提供者负载过大,会导致部分请求超时。因此将负载均衡到每个服务提供者上是非常必要的。Dubbo 提供了4种负载均衡实现,分别是基于权重随机算法的 RandomLoadBalance、基于最少活跃调用数算法的 LeastActiveLoadBalance、基于 hash 一致性的 ConsistentHashLoadBalance,以及基于加权轮询算法的 RoundRobinLoadBalance。 ### 2 加权轮询 首先,我们来看一下 Dubbo 中加权轮询负载均衡的实现 RoundRobinLoadBalance。在详细分析源码前,我们先来了解一下什么是加权轮询。这里从最简单的轮询开始讲起,所谓轮询是指将请求轮流分配给每台服务器。举个例子,我们有三台服务器 A、B、C。我们将第一个请求分配给服务器 A,第二个请求分配给服务器 B,第三个请求分配给服务器 C,第四个请求再次分配给服务器 A。这个过程就叫做轮询。轮询是一种无状态负载均衡算法,实现简单,适用于每台服务器性能相近的场景下。 但现实情况下,我们并不能保证每台服务器性能均相近。如果我们将等量的请求分配给性能较差的服务器,这显然是不合理的。因此,这个时候我们需要对轮询过程进行加权,以调控每台服务器的负载。经过加权后,每台服务器能够得到的请求数比例,接近或等于他们的权重比。比如服务器 A、B、C 权重比为 5:2:1。那么在8次请求中,服务器 A 将收到其中的5次请求,服务器 B 会收到其中的2次请求,服务器 C 则收到其中的1次请求。 以上就是加权轮询的算法思想,搞懂了这个思想,接下来我们就可以分析源码了。我们先来看一下 2.6.4 版本的 RoundRobinLoadBalance。 ```java public class RoundRobinLoadBalance extends AbstractLoadBalance { public static final String NAME = "roundrobin"; private final ConcurrentMap<String, AtomicPositiveInteger> sequences = new ConcurrentHashMap<String, AtomicPositiveInteger>(); @Override protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) { // key = 全限定类名 + "." + 方法名,比如 com.xxx.DemoService.sayHello String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName(); int length = invokers.size(); // 最大权重 int maxWeight = 0; // 最小权重 int minWeight = Integer.MAX_VALUE; final LinkedHashMap<Invoker<T>, IntegerWrapper> invokerToWeightMap = new LinkedHashMap<Invoker<T>, IntegerWrapper>(); // 权重总和 int weightSum = 0; // 下面这个循环主要用于查找最大和最小权重,计算权重总和等 for (int i = 0; i < length; i++) { int weight = getWeight(invokers.get(i), invocation); // 获取最大和最小权重 maxWeight = Math.max(maxWeight, weight); minWeight = Math.min(minWeight, weight); if (weight > 0) { // 将 weight 封装到 IntegerWrapper 中 invokerToWeightMap.put(invokers.get(i), new IntegerWrapper(weight)); // 累加权重 weightSum += weight; } } // 查找 key 对应的对应 AtomicPositiveInteger 实例,为空则创建。 // 这里可以把 AtomicPositiveInteger 看成一个黑盒,大家只要知道 // AtomicPositiveInteger 用于记录服务的调用编号即可。至于细节, // 大家如果感兴趣,可以自行分析 AtomicPositiveInteger sequence = sequences.get(key); if (sequence == null) { sequences.putIfAbsent(key, new AtomicPositiveInteger()); sequence = sequences.get(key); } // 获取当前的调用编号 int currentSequence = sequence.getAndIncrement(); // 如果最小权重小于最大权重,表明服务提供者之间的权重是不相等的 if (maxWeight > 0 && minWeight < maxWeight) { // 使用调用编号对权重总和进行取余操作 int mod = currentSequence % weightSum; // 进行 maxWeight 次遍历 for (int i = 0; i < maxWeight; i++) { // 遍历 invokerToWeightMap for (Map.Entry<Invoker<T>, IntegerWrapper> each : invokerToWeightMap.entrySet()) { // 获取 Invoker final Invoker<T> k = each.getKey(); // 获取权重包装类 IntegerWrapper final IntegerWrapper v = each.getValue(); // 如果 mod = 0,且权重大于0,此时返回相应的 Invoker if (mod == 0 && v.getValue() > 0) { return k; } // mod != 0,且权重大于0,此时对权重和 mod 分别进行自减操作 if (v.getValue() > 0) { v.decrement(); mod--; } } } } // 服务提供者之间的权重相等,此时通过轮询选择 Invoker return invokers.get(currentSequence % length); } // IntegerWrapper 是一个 int 包装类,主要包含了一个自减方法。 private static final class IntegerWrapper { private int value; public void decrement() { this.value--; } // 省略部分代码 } } ``` 如上,RoundRobinLoadBalance 的每行代码都不是很难理解,但是将它们组合在一起之后,就不是很好理解了。所以下面我们举例进行说明,假设我们有三台服务器 servers = [A, B, C],对应的权重为 weights = [2, 5, 1]。接下来对上面的逻辑进行简单的模拟。 - mod = 0:满足条件,此时直接返回服务器 A - mod = 1:需要进行一次递减操作才能满足条件,此时返回服务器 B - mod = 2:需要进行两次递减操作才能满足条件,此时返回服务器 C - mod = 3:需要进行三次递减操作才能满足条件,经过递减后,服务器权重为 [1, 4, 0],此时返回服务器 A - mod = 4:需要进行四次递减操作才能满足条件,经过递减后,服务器权重为 [0, 4, 0],此时返回服务器 B - mod = 5:需要进行五次递减操作才能满足条件,经过递减后,服务器权重为 [0, 3, 0],此时返回服务器 B - mod = 6:需要进行六次递减操作才能满足条件,经过递减后,服务器权重为 [0, 2, 0],此时返回服务器 B - mod = 7:需要进行七次递减操作才能满足条件,经过递减后,服务器权重为 [0, 1, 0],此时返回服务器 B 经过8次调用后,我们得到的负载均衡结果为 [A, B, C, A, B, B, B, B],次数比 A:B:C = 2:5:1,等于权重比。当 sequence = 8 时,mod = 0,此时重头再来。从上面的模拟过程可以看出,当 mod >= 3 后,服务器 C 就不会被选中了,因为它的权重被减为0了。当 mod >= 4 后,服务器 A 的权重被减为0,此后 A 就不会再被选中。 以上是 2.6.4 版本的 RoundRobinLoadBalance 分析过程,2.6.4 版本的 RoundRobinLoadBalance 在某些情况下存在着比较严重的性能问题,该问题最初是在 issue #2578 中被反馈出来。问题出在了 Invoker 的返回时机上,RoundRobinLoadBalance 需要在mod == 0 && v.getValue() > 0 条件成立的情况下才会被返回相应的 Invoker。假如 mod 很大,比如 10000,50000,甚至更大时,doSelect 方法需要进行很多次计算才能将 mod 减为0。 由此可知,doSelect 的效率与 mod 有关,时间复杂度为 O(mod)。mod 又受最大权重 maxWeight 的影响,因此当某个服务提供者配置了非常大的权重,此时 RoundRobinLoadBalance 会产生比较严重的性能问题。这个问题被反馈后,社区很快做了回应。并对 RoundRobinLoadBalance 的代码进行了重构,将时间复杂度优化至了常量级别。这个优化可以说很好了,下面我们来学习一下优化后的代码。 ### 3 常数级别加权轮询 ```java public class RoundRobinLoadBalance extends AbstractLoadBalance { public static final String NAME = "roundrobin"; private final ConcurrentMap<String, AtomicPositiveInteger> sequences = new ConcurrentHashMap<String, AtomicPositiveInteger>(); private final ConcurrentMap<String, AtomicPositiveInteger> indexSeqs = new ConcurrentHashMap<String, AtomicPositiveInteger>(); @Override protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) { String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName(); int length = invokers.size(); int maxWeight = 0; int minWeight = Integer.MAX_VALUE; final List<Invoker<T>> invokerToWeightList = new ArrayList<>(); // 查找最大和最小权重 for (int i = 0; i < length; i++) { int weight = getWeight(invokers.get(i), invocation); maxWeight = Math.max(maxWeight, weight); minWeight = Math.min(minWeight, weight); if (weight > 0) { invokerToWeightList.add(invokers.get(i)); } } // 获取当前服务对应的调用序列对象 AtomicPositiveInteger AtomicPositiveInteger sequence = sequences.get(key); if (sequence == null) { // 创建 AtomicPositiveInteger,默认值为0 sequences.putIfAbsent(key, new AtomicPositiveInteger()); sequence = sequences.get(key); } // 获取下标序列对象 AtomicPositiveInteger AtomicPositiveInteger indexSeq = indexSeqs.get(key); if (indexSeq == null) { // 创建 AtomicPositiveInteger,默认值为 -1 indexSeqs.putIfAbsent(key, new AtomicPositiveInteger(-1)); indexSeq = indexSeqs.get(key); } if (maxWeight > 0 && minWeight < maxWeight) { length = invokerToWeightList.size(); while (true) { int index = indexSeq.incrementAndGet() % length; int currentWeight = sequence.get() % maxWeight; // 每循环一轮(index = 0),重新计算 currentWeight if (index == 0) { currentWeight = sequence.incrementAndGet() % maxWeight; } // 检测 Invoker 的权重是否大于 currentWeight,大于则返回 if (getWeight(invokerToWeightList.get(index), invocation) > currentWeight) { return invokerToWeightList.get(index); } } } // 所有 Invoker 权重相等,此时进行普通的轮询即可 return invokers.get(sequence.incrementAndGet() % length); } } ``` 上面代码的逻辑是这样的,每进行一轮循环,重新计算 currentWeight。如果当前 Invoker 权重大于 currentWeight,则返回该 Invoker。下面举例说明,假设服务器 [A, B, C] 对应权重 [5, 2, 1]。 - 第一轮循环,currentWeight = 1,可返回 A 和 B - 第二轮循环,currentWeight = 2,返回 A - 第三轮循环,currentWeight = 3,返回 A - 第四轮循环,currentWeight = 4,返回 A - 第五轮循环,currentWeight = 0,返回 A, B, C 如上,这里的一轮循环是指 index 再次变为0所经历过的循环,这里可以把 index = 0 看做是一轮循环的开始。每一轮循环的次数与 Invoker 的数量有关,Invoker 数量通常不会太多,所以我们可以认为上面代码的时间复杂度为常数级。 ### 4 平滑加权轮询 重构后的 RoundRobinLoadBalance 看起来已经很不错了,但是在代码更新不久后,很快又被重构了。这次重构原因是新的 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个请求依次进入负载均衡逻辑,选择过程如下: ![](//img1.jcloudcs.com/developer.jdcloud.com/69535305-a70c-4084-80a6-86a1e414546c20211129143613.png) 如上,经过平滑性处理后,得到的服务器序列为 [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 是如何实现上面的计算过程的。 ```java public class RoundRobinLoadBalance extends AbstractLoadBalance { public static final String NAME = "roundrobin"; 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); } } // 嵌套 Map 结构,存储的数据结构示例如下: // { // "UserService.query": { // "url1": WeightedRoundRobin@123, // "url2": WeightedRoundRobin@456, // }, // "UserService.update": { // "url1": WeightedRoundRobin@123, // "url2": WeightedRoundRobin@456, // } // } // 最外层为服务类名 + 方法名,第二层为 url 到 WeightedRoundRobin 的映射关系。 // 这里我们可以将 url 看成是服务提供者的 id private ConcurrentMap<String, ConcurrentMap<String, WeightedRoundRobin>> methodWeightMap = new ConcurrentHashMap<String, ConcurrentMap<String, WeightedRoundRobin>>(); // 原子更新锁 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<String, WeightedRoundRobin>()); 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<String, WeightedRoundRobin>(); // 拷贝 newMap.putAll(map); // 遍历修改,即移除过期记录 Iterator<Entry<String, WeightedRoundRobin>> it = newMap.entrySet().iterator(); while (it.hasNext()) { 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; } // should not happen here return invokers.get(0); } } ``` 以上就是 Dubbo-2.6.5 版本的 RoundRobinLoadBalance,大家如果能够理解平滑加权轮询算法的计算过程,再配合代码中注释,理解上面的代码应该不难。 ### 5 总结 对于一个算法的实现来说,进行简单的实现可能会是比较容易的,但是如何利用更小的复杂度,实现更高效的性能,这才是最重要的,也是难点所在之处。正如加权轮询算法来说,作为国内顶级的公司来说,还是从最初时的O(mod)的复杂度,一点点过滤到了平滑的算法,技术是一点点的向前进步的。 从Dubbo实现平滑加权,借鉴了Nginx的负载均衡的算法来说,技术也是开源开放的,站在巨人的肩膀之上,会让技术一步一步发展的更好,更快。 ------------ ###### 自猿其说Tech-京东物流技术发展部 ###### 作者:乔杰 李武
原创文章,需联系作者,授权转载
上一篇:一文读懂什么是能力中心
下一篇:移动端代码质量提升探索-代码静态分析
相关文章
Taro小程序跨端开发入门实战
Flutter For Web实践
配运基础数据缓存瘦身实践
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
本文将从Optional所解决的问题开始,逐层解剖,由浅入深,文中会出现Optioanl方法之间的对比,实践,误用情况分析,优缺点等。与大家一起,对这项Java8中的新特性,进行理解和深入。
01
Taro小程序跨端开发入门实战
为了让小程序开发更简单,更高效,我们采用 Taro 作为首选框架,我们将使用 Taro 的实践经验整理了出来,主要内容围绕着什么是 Taro,为什么用 Taro,以及 Taro 如何使用(正确使用的姿势),还有 Taro 背后的一些设计思想来进行展开,让大家能够对 Taro 有个完整的认识。
01
Flutter For Web实践
Flutter For Web 已经发布一年多时间,它的发布意味着我们可以真正地使用一套代码、一套资源部署整个大前端系统(包括:iOS、Android、Web)。渠道研发组经过一段时间的探索,使用Flutter For Web技术开发了移动端可视化编程平台—Flutter乐高,在这里希望和大家分享下使用Flutter For Web实践过程和踩坑实践
01
配运基础数据缓存瘦身实践
在基础数据的常规能力当中,数据的存取是最基础也是最重要的能力,为了整体提高数据的读取能力,缓存技术在基础数据的场景中得到了广泛的使用,下面会重点展示一下配运组近期针对数据缓存做的瘦身实践。
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
01
Taro小程序跨端开发入门实战
01
Flutter For Web实践
01
配运基础数据缓存瘦身实践
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号