负载均衡的实现
在整个集群容错流程中,首先经过Directory获取所有Invoker列表,然后经过Router根据路由规则过滤Invoker,最后幸存下来的Invoker还需要经过负载均衡这一关,选出最终要调用的Invoker。
包装后的负载均衡
所有的容错策略中的负载均衡都使用了抽象父类AbstractClusterInvoker中定义的Invoker <T> select
方法,而并不是直接使用LoadBalance方法。因为抽象父类在LoadBalance的基础上有封装了一些新的特性:
-
粘滞连接
:Dubbo中有一种特性叫粘滞连接,以下内容摘自官方文档:
粘滞连接用于有状态服务,尽可能让客户端总是向同一提供者发起调用,除非该提供者"挂了",再连接另一台
粘滞连接将自动开启延迟拦截,以减少长连接数。
<dubbo:protocol name="dubbo" stickytrue" />>
-
可用检测
:Dubbo调用的URL中,如果含有cluster.availablecheck=false
,则不会检测远程服务是否可用,直接调用。如果不设置,则默认会开启检查,对所有的服务都做是否可用的检查,如果不可用,则再次做负载均衡。 避免重复调用
:对于已经调用过的远程服务,避免重复选择,每次都使用同意而节点。这种特性主要是为了避免并发场景下,某个节点瞬间被大量请求,整个逻辑过程大致可以分为4步:
- 检查URL中是否有配置粘滞连接,如果有则使用粘滞连接的Invoker。如果没有配置粘滞连接,或者重复调用检测不通过、可用检测不通过,则进入第2步。
- 通过ExtensionLoader获取负载均衡的具体实现,并通过负载均衡做节点的选择。对选择出来的节点做重复调用、可用性检测,通过则直接返回,否则进入第3步。
- 进行节点的重新选择。如果需要做可用性检测,则会遍历Directory中得到的所有节点,过滤不可用和已经调用过的节点,在剩余节点中重新做负载均衡;如果不需要做可用性检测,那么也会遍历Directory中得到的所有节点,但只过滤已经调用过的,在剩余的节点中重新做负载均衡。这里存在一种情况,就是在过滤不可用或已经调用过的节点时,节点全部被过滤,没有剩下任何节点,此时进入第4步。
- 遍历所有已经调用过的节点,,选出所有可用的节点,再通过负载均衡选出一个结点并返回。如果还找不到可调用的节点,则返回null。
从上述逻辑中,我们可以得知,框架会优先处理粘滞连接。否则会根据可用性检测或重复调用检测过滤一些节点,并在剩余的节点中做负载均衡。如果可用性检测或重复调用检测把节点都过滤了,则兜底的策略是:在已经调用过的节点中通过负载均衡选择出一个可用的节点
。
负载均衡的总体结构
Dubbo内置了4中负载均衡算法,与用户也可以自行扩展,因为LoadBalance接口上有@SPI注解
从代码中可以知道默认的负载均衡就是RandomloadBalance,即随机负载均衡。由于select方法上有Adaptive("loadbalance")
注解,因此在URL中可以通过loadbalance=xxx来懂爱指定select使得负载均衡算法。
负载均衡算法名称 | 效果说明 |
Random LodBalance | 随机,按权重设置随机概率。在一个节点上碰撞的概率高,但调用量越大分布越均匀,而且按概率使用权重后也比较均匀,有利于动态调整提供者的权重 |
RoundRobin LoadBalance | 轮询,按公约后的权重设置轮询比例。存在慢的提供者累积请求的问题,比如:第二台机器很慢,但没有"挂",当请求调用到第二台就卡在案例,久而久之,所有请求都卡在第二台上 |
LeastActive LoadBalance | 最少活跃调用数,如果活跃数相同则随机调用,活跃数指调用前后计数差使慢的提供者收到更少请求,因为越慢的提供者的调用前后计数差会越大 |
ConsistentHash LoadBalance | 一致性Hash,相同参数的请求总是发送同一提供者。当某一台提供者"挂"时,原本发往该提供者的请求,基于虚拟节点,会平摊到其他提供者,不会引起剧烈变动。默认只对第一个参数"Hash",如果要修改,则配置
|
四种负载均衡算法都继承自同一个抽象类,使用的也是模板模式,抽象父类中已经把通用的逻辑完成,留了一个抽象的doSelect方法给子类实现。
抽象父类AbstractLoadBalance有两个权重相关的方法:calculateWarmupWeight和getWeight
。getWeight方法就是获取当前Invoker的权重,calculteWarmupWeight是计算具体权重。getWeight方法中会调用calculateWarmupWeight:
calculateWarmupWeight的计算逻辑比较简单,由于框架考虑了服务刚启动的时候需要有一个预热的过程,如果一启动就给予100%的流量,则可能会让服务器崩溃,因此实现了calculateWarmupWeight方法用于计算预热时候的权重,计算逻辑:(启动至今时间/给予的预热总时间 * 权重
)
例如:假设我们设置A服务的权重是5,让它预热10分钟,则第一分钟的时候,它的权重变为(1/10) * 5 = 0..5, 0.5 / 5 = 0.1, 也就是只承担10%的流量;
10分钟后,权重就变为(10 / 10) * 5= 5,也就是权重变为设置的100%,承担了所有流量
。
抽象父类的select方法是进行具体负载均衡逻辑的地方,这里只是锁了一些判断并调用需要子类实现的doSelect方法。
Random负载均衡
Randon负载均衡是按照权重设置随机概率做负载均衡的。这种负载均衡算法并不能精确地平均请求,但是随着请求数量的增加,最终结果是大致平均的。它的负载计算步骤如下:
- 计算总权重并判断每个 Invoker的权重是否一样。遍历整个 Invoker列表,求和总权
重。在遍历过程中,会对比每个 Invoker的权重,判断所有 Invoker的权重是否相同。 - 如果权重相同,则说明每个 Invoker的概率都一样,因此直接用 nextInt随机选一个
返回即可 - 如果权重不同,则首先得到偏移值,然后根据偏移值找到对应的 Invoker
示例:
假设有4个Invoker,它们权重分别是1,2,3,4,则总权重是1+2+3+4=10。
说明每个Invoker分别有1/10、2/10、3/10、4/10的概率被选中。
然后nextInt(10)会返回0~10之间的一个整数,假设为5.
如果进行类减,则减到3会小于0,此时会落入3的区间,即选择3号Invoker:
1 2 3 4
|__|____|______|________|
↑
5
RoundRobin负载均衡
权重轮询负载均衡会根据设置的权重来判断轮询的比例
。普通轮询负载均衡的好处是每个节点获得的请求会很均匀,如果某些节点的负载能力明显较弱,则这个节点会堆积比较多的请求。
因此普通的轮询还不能满足需求,还需要能根据节点权重进行干预。权重轮询又分为普通权重轮询和平滑权重轮询。普通权重轮询会造成某个节点会突然被频繁选中,这样很容易突然让一个节点流量暴增。Nginx 中有一种叫 平滑轮询的算法( smooth weighted rund-robion balancing)
,这种算法在轮询时会穿插选择其他节点,让整个服务器选择的过程比较均匀,不会“逮住”个节点一直调用。Dubbo框架中最新的RoundRobin代码已经改为平滑权重轮询算法。
先来看一下 Dubbo中RoundRobin负载均衡的工作步骤,如下:
- 初始化权重缓存Map。以每个Invoker的URL为key,对象WeightedRoundRobin为vale生成一 个CocurentMap, 并把这个Map保存到全局的methodWeightMap 中:
concurrentap<String, ConcurrentMap<string, WeightedRoundRobin>> methodweightMap
。methodWeighMap的key是每个接口+方法名
。这步只会生成这个缓存Map,但里面是空的,第2步才会生成每个Invoker对应的键值。
WeightedRoundRobin封装了每个Invoker 的权重,对象中保存了三个属性,如代码所示:
private int weght;//Invoker设定的权重
//考虑到并发场景下某个Invoker会被同时选中,表示该节点被所有线程钻中的权重总和
//例如:某节点权重是100,被4个线层同时选中,则变为400
private AtomicLon current = new AtomicLong(0);
//最后一次更新的时间,用于后续缓存超时的判断
private long lastUpdate;
- 遍历所有lnoker首先,在遍历的过程中把每个Invoker 的数据填充到第1步生成的权重缓存Map中。其次,获取每个Invoker的预热权重,新版的框架RoundRobin也支持预热,通过和Random负载均衡中相同的方式获得预热阶段的权重。如果预热权重和Invoker 设置的权重不相等,则说明还在预热阶段,
此时会以预热权重为准
。然后,进行平滑轮询。 每个Invoker会把权重加到自己的 current属性上,并更新当前Invoker的lastUpdate。同时累加每个Invoke的权重到totalWeight。最终,遍历完后,选出所有Invoker中current最大的作为最终要调用的节点。 - 清除已经没有使用的缓存节点。由于所有的Invoker 的权重都会被封装成weightedRoundRobin对象,因此如果可调用的Invoker列表数量和缓存weightedRoundRobin对象的Map大小不相等,则说明缓存Map中有无用数据(有些Invoker已经不在了,但Map中还有缓存)。
为什么不相等就说明有老数据呢?如果Invoker列表比缓存Map大,则说明有没被缓存的Invoker,此时缓存Map会新增数据。因此缓存Map永远大于等于Invoker列表。
清除老旧数据时,各线程会先用CAS抢占锁(抢到锁的线程才做清除操作,抢不到的线程就直接跳过,保证只有一个线程在 做清除操作),然后复制原有的 Map到一个新的Map中,根据lastUpdate清除新Map中的过期数据(默认60秒算过期),最后把Map从旧的Map引用修改到新的Map上面。这是一种CopyOnWrite的修改方式。 - 返回Invoker。 注意,返回之前会把当前Invoker的current减去总权重。这是平滑权重轮询中重要的一步。
算法逻辑:
- 每次请求做负载均衡时,会遍历所有可调用的节点(Invoker列表)。对于每个Invoker,让它的
current = current + weight
。 属性含义见weightedRoundRobin 对象。同时累加每个Invoker的weight到totalWeight,即totalWeight = totalweight + weight
。 - 遍历完所有Invoker后,current值最大的节点就是本次要选择的节点。最后,把该节点的current值减去totalWeight,即
current = current - totalweight
。
假设有3个Invoker: A、B、C,它们的权重分别为1、6、9,初始crretrt都是0,则平滑权重轮询过程如表所示:
请求次数 | 被选中前Invoker的current值 | 被选中后Invoker的current值 | 被选中的节点 |
1 | {1,6,9} | {1,6,-7} | C |
2 | {2,12,2} | {2,-4,2} | B |
3 | {3,2,11} | {3,2,-5} | C |
4 | {4,8,4} | {4,-8,4} | B |
5 | {5,-2,13} | {5,-2,-3} | C |
6 | {6,4,6} | {-10,4,6} | A |
7 | {-9,10,15} | {-9,10,-1} | C |
8 | {-8,16,8} | {-8,0,8} | B |
9 | {-7,6,17} | {-7,6,1} | C |
10 | {-6,12,10} | {-6,-4,10} | B |
11 | {-5,2,19} | {-5,2,3} | C |
12 | {-4,8,12} | {-4,8,-4} | C |
13 | {-3,14,5} | {-3,-2,5} | B |
14 | {-2,4,14} | {-2,4,-2} | C |
15 | {-1,10,7} | {-1,-6,7} | B |
16 | {0,0,16} | {0,0,0} | C |
从这16次的负载均衡来看,A被调用了1次,B被调用了6次,C被调用了9次。符合权重轮询的策略,因为他们的权重比是1:6:9
。此外,C并没有被频繁地一直调用,其中会穿插B和A的调用。
LeastActive负载均衡
LeastActive负载均衡称为最少活跃调用数负载均衡
,即框架会记下每个Invoker的活跃数,每次只从活跃数最少的Invoker里选一个节点。这个负载均衡算法需要配合ActiveLimitFilter过滤器来计算每个接口方法的活跃数
。最少活跃负载均衡可以看作Random负载均衡的“加强版”,因为最后根据权重做负载均衡的时候,使用的算法和Random的一样。
遍历所有Invoker,不断寻找最小的活跃数(leastActive),如果有多个Invoker的活跃数都等于leastActive,则把它们保存到同一个集合中,最后在这个Invoker集合中再通过随机的方式选出一个Invoker。
那最少活跃的计数又是如何知道的呢?
在ActiveLimitFilter中,只要进来一个请求,该方法的调用的计数就会原子性+1.整个Invoker调用过程会包在try-catch-finally中,无论调用或结束或出现异常,finally中都会把计数原子-1.该原子计数就是最少活跃数。
一致性Hash负载均衡
一致性 Hash负载均衡可以让参数相同的请求每次都路由到相同的机器上。这种负载均衡的方式可以让请求相对平均,相比直接使用Hash而言,当某些节点下线时,请求会平摊到其他服务提供者,不会引起剧烈变动。
区域1
服务A- - - - - - - - - - - - - - - 服务B
| |
区 | | 区
域 | | 域
4 | | 2
| |
| |
服务C- - - - - - - - - - - - - - - 服务D
区域3
普通一致性Hash 会把每个服务节点散列到环形上,然后把请求的客户端散列到环上,顺时前找到的第 一个节点就是要调用的节点。假设客户端落在区域2,则顺时针找到的服务C这程调用的节点。当服务C宕机下线,则落在区域2部分的客户端会自动迁移到服务D上。这样就诏 避免了全部重新散列的问题。
普通的一致性Hash也有一定的局限性,它的散列不一定均匀, 容易造成某些节点压力大。因此Dubbo框架使用了优化过的 Ketama一致性Hash。这种算法会为每个真实节点再创建多个节点, 让节点在环形上的分布更加均匀,后续的调用也会随之更加均匀。
整个逻辑的核心在ConsistentHashSelector中,因此我们继续来看ConsistentHashSelector是如何初始化的。ConsistentHashSelector初始化的时候会对节点进行散列,散列的环形是使用一个TreeMap实现的,所有的真实、虚拟节点都会放入TreeMap。把节点的IP+递增数字做“MD5"
,以此作为节点标识,再对标识做“Hash" 得到TreeMap 的key, 最后把可以调用的节点作为TreeMap的value,如代码所示。
TreeMap实现一致性Hash:在客户端调用时候,只要对请求的参数也做"MD5"即可。虽然此时得到的MD5值不一定能对应到TreeMap中的一个 key,因为每次的请求参数不同。但是由于TreeMap 是有序的树形结构,所以我们可以调用TeeMap的ceilingEntry方法,用于返回一个至少大于或等于当前给定key的Entry,从而达到顺时针往前找的效果。如果找不到,则使用firstEntry返回第一个节点。