Dubbo——负载均衡的实现

负载均衡的实现

在整个集群容错流程中,首先经过Directory获取所有Invoker列表,然后经过Router根据路由规则过滤Invoker,最后幸存下来的Invoker还需要经过负载均衡这一关,选出最终要调用的Invoker。

包装后的负载均衡

所有的容错策略中的负载均衡都使用了抽象父类AbstractClusterInvoker中定义的Invoker <T> select方法,而并不是直接使用LoadBalance方法。因为抽象父类在LoadBalance的基础上有封装了一些新的特性:

  1. 粘滞连接:Dubbo中有一种特性叫粘滞连接,以下内容摘自官方文档:
粘滞连接用于有状态服务,尽可能让客户端总是向同一提供者发起调用,除非该提供者"挂了",再连接另一台
粘滞连接将自动开启延迟拦截,以减少长连接数。
<dubbo:protocol name="dubbo" stickytrue" />>
  1. 可用检测:Dubbo调用的URL中,如果含有cluster.availablecheck=false,则不会检测远程服务是否可用,直接调用。如果不设置,则默认会开启检查,对所有的服务都做是否可用的检查,如果不可用,则再次做负载均衡。
  2. 避免重复调用:对于已经调用过的远程服务,避免重复选择,每次都使用同意而节点。这种特性主要是为了避免并发场景下,某个节点瞬间被大量请求,整个逻辑过程大致可以分为4步:
    1. 检查URL中是否有配置粘滞连接,如果有则使用粘滞连接的Invoker。如果没有配置粘滞连接,或者重复调用检测不通过、可用检测不通过,则进入第2步。
    2. 通过ExtensionLoader获取负载均衡的具体实现,并通过负载均衡做节点的选择。对选择出来的节点做重复调用、可用性检测,通过则直接返回,否则进入第3步。
    3. 进行节点的重新选择。如果需要做可用性检测,则会遍历Directory中得到的所有节点,过滤不可用和已经调用过的节点,在剩余节点中重新做负载均衡;如果不需要做可用性检测,那么也会遍历Directory中得到的所有节点,但只过滤已经调用过的,在剩余的节点中重新做负载均衡。这里存在一种情况,就是在过滤不可用或已经调用过的节点时,节点全部被过滤,没有剩下任何节点,此时进入第4步。
    4. 遍历所有已经调用过的节点,,选出所有可用的节点,再通过负载均衡选出一个结点并返回。如果还找不到可调用的节点,则返回null。

从上述逻辑中,我们可以得知,框架会优先处理粘滞连接。否则会根据可用性检测或重复调用检测过滤一些节点,并在剩余的节点中做负载均衡。如果可用性检测或重复调用检测把节点都过滤了,则兜底的策略是:在已经调用过的节点中通过负载均衡选择出一个可用的节点

负载均衡的总体结构

Dubbo内置了4中负载均衡算法,与用户也可以自行扩展,因为LoadBalance接口上有@SPI注解

在这里插入图片描述

从代码中可以知道默认的负载均衡就是RandomloadBalance,即随机负载均衡。由于select方法上有Adaptive("loadbalance")注解,因此在URL中可以通过loadbalance=xxx来懂爱指定select使得负载均衡算法。

负载均衡算法名称效果说明
Random LodBalance随机,按权重设置随机概率。在一个节点上碰撞的概率高,但调用量越大分布越均匀,而且按概率使用权重后也比较均匀,有利于动态调整提供者的权重
RoundRobin LoadBalance轮询,按公约后的权重设置轮询比例。存在慢的提供者累积请求的问题,比如:第二台机器很慢,但没有"挂",当请求调用到第二台就卡在案例,久而久之,所有请求都卡在第二台上
LeastActive LoadBalance最少活跃调用数,如果活跃数相同则随机调用,活跃数指调用前后计数差使慢的提供者收到更少请求,因为越慢的提供者的调用前后计数差会越大
ConsistentHash LoadBalance一致性Hash,相同参数的请求总是发送同一提供者。当某一台提供者"挂"时,原本发往该提供者的请求,基于虚拟节点,会平摊到其他提供者,不会引起剧烈变动。默认只对第一个参数"Hash",如果要修改,则配置
<dubbo:parameter key="hash.arguments" value="0, 1" />
默认使用160份虚拟节点,如果要修改,则配置<dubo:parameter key="hash.nodes" value="320"/>

四种负载均衡算法都继承自同一个抽象类,使用的也是模板模式,抽象父类中已经把通用的逻辑完成,留了一个抽象的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负载均衡是按照权重设置随机概率做负载均衡的。这种负载均衡算法并不能精确地平均请求,但是随着请求数量的增加,最终结果是大致平均的。它的负载计算步骤如下:

  1. 计算总权重并判断每个 Invoker的权重是否一样。遍历整个 Invoker列表,求和总权
    重。在遍历过程中,会对比每个 Invoker的权重,判断所有 Invoker的权重是否相同。
  2. 如果权重相同,则说明每个 Invoker的概率都一样,因此直接用 nextInt随机选一个
    返回即可
  3. 如果权重不同,则首先得到偏移值,然后根据偏移值找到对应的 Invoker

在这里插入图片描述

示例:

假设有4个Invoker,它们权重分别是1234,则总权重是1+2+3+4=10。
说明每个Invoker分别有1/102/103/104/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负载均衡的工作步骤,如下:

  1. 初始化权重缓存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;
  1. 遍历所有lnoker首先,在遍历的过程中把每个Invoker 的数据填充到第1步生成的权重缓存Map中。其次,获取每个Invoker的预热权重,新版的框架RoundRobin也支持预热,通过和Random负载均衡中相同的方式获得预热阶段的权重。如果预热权重和Invoker 设置的权重不相等,则说明还在预热阶段,此时会以预热权重为准。然后,进行平滑轮询。 每个Invoker会把权重加到自己的 current属性上,并更新当前Invoker的lastUpdate。同时累加每个Invoke的权重到totalWeight。最终,遍历完后,选出所有Invoker中current最大的作为最终要调用的节点。

  2. 清除已经没有使用的缓存节点。由于所有的Invoker 的权重都会被封装成weightedRoundRobin对象,因此如果可调用的Invoker列表数量和缓存weightedRoundRobin对象的Map大小不相等,则说明缓存Map中有无用数据(有些Invoker已经不在了,但Map中还有缓存)。

    为什么不相等就说明有老数据呢?如果Invoker列表比缓存Map大,则说明有没被缓存的Invoker,此时缓存Map会新增数据。因此缓存Map永远大于等于Invoker列表。
    清除老旧数据时,各线程会先用CAS抢占锁(抢到锁的线程才做清除操作,抢不到的线程就直接跳过,保证只有一个线程在 做清除操作),然后复制原有的 Map到一个新的Map中,根据lastUpdate清除新Map中的过期数据(默认60秒算过期),最后把Map从旧的Map引用修改到新的Map上面。这是一种CopyOnWrite的修改方式。

  3. 返回Invoker。 注意,返回之前会把当前Invoker的current减去总权重。这是平滑权重轮询中重要的一步。

算法逻辑:

  1. 每次请求做负载均衡时,会遍历所有可调用的节点(Invoker列表)。对于每个Invoker,让它的current = current + weight。 属性含义见weightedRoundRobin 对象。同时累加每个Invoker的weight到totalWeight,即totalWeight = totalweight + weight

  2. 遍历完所有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返回第一个节点。

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值