2020 年 5 月 15 日,Dubbo 发布 2.7.7 release 版本。其中有这么一个 Features
新增一个负载均衡策略。
熟悉我的老读者肯定是知道的,Dubbo 的负载均衡我都写过专门的文章,对每个负载均衡算法进行了源码的解读,还分享了自己调试过程中的一些骚操作。
新的负载均衡出来了,那必须的得解读一波。
先看一下提交记录:
https://github.com/chickenlj/incubator-dubbo/commit/6d2ba7ec7b5a1cb7971143d4262d0a1bfc826d45
负载均衡是基于 SPI 实现的,我们看到对应的文件中多了一个名为 shortestresponse 的 key。
这个,就是新增的负载均衡策略了。看名字,你也知道了这个策略的名称就叫:最短响应。
所以截止 2.7.7 版本,官方提供了五种负载均衡算法了,他们分别是:
前面四种我已经在之前的文章中进行了详细的分析。有的读者反馈说想看合辑,所以我会在这篇文章中把之前文章也整合进来。
所以,需要特别强调一下的是,这篇文章集合了之前写的三篇负载均衡的文章。看完最短响应时间负载均衡这一部分后,如果你看过我之前的那三篇文章,你可以温故而知新,也可以直接拉到文末看看我推荐的一个活动,然后点个赞再走。如果你没有看过那三篇,这篇文章如果你细看,肯定有很多收获,以后谈起负载均衡的时候若数家珍,但是肯定需要看非常非常长的时间,做好心理准备。
我已经预感到了,这篇文章妥妥的会超过 2 万字。属于硬核劝退文章,想想就害怕。
首先,我们看一下这个类上的注解,先有个整体的认知。
org.Apache.dubbo.rpc.cluster.loadbalance.ShortestResponseLoadBalance
我来翻译一下是什么意思:
再配个图,就好理解了,可以先不管图片中的标号:
有了上面的整体概念的铺垫了,接下来分析源码的时候就简单了。
源码一共就 66 行,我把它分为 5 个片段去一一分析。
这里一到五的标号,对应上面流程图中的标号。我们一个个的说。
标号为①的部分
这一部分是定义并初始化一些参数,为接下来的代码服务的,翻译一下每个参数对应的注释:
length 参数:服务提供者的数量。
shortestResponse 参数:所有服务提供者的估计最短响应时间。(这个地方我觉得注释描述的不太准确,看后面的代码可以知道这只是一个零时变量,在循环中存储当前最短响应时间是多少。)
shortCount 参数:具有相同最短响应时间的服务提供者个数,初始化为 0。
shortestIndexes 参数:数组里面放的是具有相同最短响应时间的服务提供者的下标。
weights 参数:每一个服务提供者的权重。
totalWeight 参数:多个具有相同最短响应时间的服务提供者对应的预热(预热这个点还是挺重要的,在下面讲最小活跃数负载均衡的时候有详细说明)权重之和。
firstWeight 参数:第一个具有最短响应时间的服务提供者的权重。
sameWeight 参数:多个满足条件的提供者的权重是否一致。
标号为②的部分
这一部分代码的关键,就在上面框起来的部分。而框起来的部分,最关键的地方,就在于第一行。
获取调用成功的平均时间。
成功调用的平均时间怎么算的?
调用成功的请求数总数对应的总耗时 / 调用成功的请求数总数 = 成功调用的平均时间。
所以,在下面这个方法中,首先获取到了调用成功的请求数总数:
这个 succeeded 参数是怎么来的呢?
答案就是:总的请求数减去请求失败的数量,就是请求成功的总数!
那么为什么不能直接获取请求成功的总数呢?
别问,问就是没有这个选项啊。你看,在 RpcStatus 里面没有这个参数呀。
请求成功的总数我们有了,接下来成功总耗时怎么拿到的呢?
答案就是:总的请求时间减去请求失败的总时间,就是请求成功的总耗时!
那么为什么不能直接获取请求成功的总耗时呢?
别问,问就是......
我们看一下 RpcStatus 中的这几个参数是在哪里维护的:
org.apache.dubbo.rpc.RpcStatus#endCount(org.apache.dubbo.rpc.RpcStatus, long, boolean)
其中的第二个入参是本次请求调用时长,第三个入参是本次调用是否成功。
具体的方法不必细说了吧,已经显而易见了。
再回去看框起来的那三行代码:
这里,我们就获取到了如果选择当前循环中的服务提供者的预计等待时间是多长。
后面的代码怎么写?
当然是出来一个更短的就把这个踢出去呀,或者出来一个一样长时间的就记录一下,接着去 pk 权重了。
所以,接下来 shortestIndexes 参数和 weights 参数就排上用场了:
另外,多说一句的,它里面有这样的一行注释:
和 LeastActiveLoadBalance 负载均衡策略一致,我给你截图对比一下:
可以看到,确实是非常的相似,只是一个是判断谁的响应时间短,一个是判断谁的活跃数低。
标号为③的地方
标号为③的地方是这样的:
里面参数的含义我们都知道了,所以,标号为③的地方的含义就很好解释了:经过选择后只有一个服务提供者满足条件。所以,直接使用这个服务提供者。
标号为④的地方
这个地方我就不展开讲了(后面的加权随机负载均衡那一小节有详细说明),熟悉的朋友一眼就看出来这是加权随机负载均衡的写法了。
不信?我给你对比一下:
你看,是不是一模一样的。
标号为⑤的地方
一行代码,没啥说的。就是从多个满足条件的且权重一样的服务提供者中随机选择一个。
如果一定要多说一句的话,我截个图吧:
可以看到,这行代码在最短响应时间、加权随机、最小活跃数负载均衡策略中都出现了,且都在最后一行。
好了,到这里最短响应时间负载均衡策略就讲完了,你再回过头去看那张流程图,会发现其实流程非常的清晰,完全可以根据代码结构画出流程图。一个是说明这个算法是真的不复杂,另一个是说明好的代码会说话。
你知道 Dubbo 加入这个新的负载均衡算法提交了几个文件吗?
四个文件,其中还包含两个测试文件:
这里就是策略模式和 SPI 的好处。对原有的负载均衡策略没有任何侵略性。只需要按照规则扩展配置文件,实现对应接口即可。
这是什么?
这就是值得学习优雅!
那我们优雅的进入下一议题。
这一小节所示源码,没有特别标注的地方均为 2.6.0 版本。
为什么没有用截止目前(我当时写这段文章的时候是2019年12月01日)的最新的版本号 2.7.4.1 呢?因为 2.6.0 这个版本里面有两个 bug 。从 bug 讲起来,印象更加深刻。
最后会对 2.6.0/2.6.5/2.7.4.1 版本进行对比,通过对比学习,加深印象。
我这里补充一句啊,仅仅半年的时间,版本号就从 2.7.4.1 到了 2.7.7。其中还包含一个 2.7.5 这样的大版本。
所以还有人说 Dubbo 不够活跃?(几年前的文章现在还有人在发。)
对吧,我们不吵架,我们摆事实,聊数据嘛。
我看源码的习惯是先搞个 Demo 把调试环境搭起来。然后带着疑问去抽丝剥茧的 Debug,不放过在这个过程中在脑海里面一闪而过的任何疑问。
这一小节分享的是Dubbo负载均衡策略之一最小活跃数(LeastActiveLoadBalance)。所以我先搭建一个 Dubbo 的项目,并启动三个 provider 供 consumer 调用。
三个 provider 的 loadbalance 均配置的是 leastactive。权重分别是默认权重、200、300。
**默认权重是多少?**后面看源码的时候,源码会告诉你。
三个不同的服务提供者会给调用方返回自己是什么权重的服务。
启动三个实例。(注:上面的 provider.xml 和 DemoServiceImpl 其实只有一个,每次启动的时候手动修改端口、权重即可。)
到 zookeeper 上检查一下,服务提供者是否正常:
可以看到三个服务提供者分别在 20880、20881、20882 端口。(每个红框的最后5个数字就是端口号)。
最后,我们再看服务消费者。消费者很简单,配置consumer.xml
直接调用接口并打印返回值即可。
相信很多朋友也很想看源码,但是不知道从何处下手。处于一种在源码里面"乱逛"的状态,一圈逛下来,收获并不大。
这一部分我想分享一下我是怎么去看源码。首先我会带着问题去源码里面寻找答案,即有针对性的看源码。
如果是这种框架类的,正如上面写的,我会先翻一翻官网(Dubbo 的官方文档其实写的挺好了),然后搭建一个简单的 Demo 项目,然后 Debug 跟进去看。Debug 的时候当然需要是设置断点的,那么这个断点如何设置呢?
第一个断点,当然毋庸置疑,是打在调用方法的地方,比如本文中,第一个断点是在这个地方:
接下里怎么办?
你当然可以从第一个断点处,一步一步的跟进去。但是在这个过程中,你发现了吗?大多数情况你都是被源码牵着鼻子走的。本来你就只带着一个问题去看源码的,有可能你Debug了十分钟,还没找到关键的代码。也有可能你Debug了十分钟,问题从一个变成了无数个。
所以不要慌,我们点支烟,慢慢分析。
首先怎么避免被源码牵着四处乱逛呢?
我们得找到一个突破口,还记得我在《很开心,在使用mybatis的过程中我踩到一个坑》这篇文章中提到的逆向排查的方法吗?这次的文章,我再次展示一下该方法。
看源码之前,我们的目标要十分明确,就是想要找到 Dubbo 最小活跃数算法的具体实现类以及实现类的具体逻辑是什么。
根据我们的 provider.xml 里面的:
很明显,我们知道 loadbalance 是关键字。所以我们拿着 loadbalance 全局搜索,可以看到 Dubbo 包下面的 LoadBalance。
这是一个 SPI 接口
com.alibaba.dubbo.rpc.cluster.LoadBalance:
其实现类为:
com.alibaba.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance
AbstractLoadBalance 是一个抽象类,该类里面有一个抽象方法doSelect。这个抽象方法其中的一个实现类就是我们要分析的最少活跃次数负载均衡的源码。
同时,到这里我们知道了 LoadBalance 是一个 SPI 接口,说明我们可以扩展自己的负载均衡策略。抽象方法 doSelect 有四个实现类。这个四个实现类,就是 Dubbo 官方提供的负载均衡策略(截止 2.7.7 版本之前),他们分别是:
我们已经找到了 LeastActiveLoadBalance 这个类了,那么我们的第二个断点打在哪里已经很明确了。
目前看来,两个断点就可以支撑我们的分析了。
有的朋友可能想问,那我想知道 Dubbo 是怎么识别出我们想要的是最少活跃次数算法,而不是其他的算法呢?其他的算法是怎么实现的呢?从第一个断点到第二个断点直接有着怎样的调用链呢?
在没有彻底搞清楚最少活跃数算法之前,这些统统先记录在案但不予理睬。一定要明确目标,带着一个问题进来,就先把带来的问题解决了。之后再去解决在这个过程中碰到的其他问题。在这样环环相扣解决问题的过程中,你就慢慢的把握了源码的精髓。这是我个人的一点看源码的心得。供诸君参考。
既然叫做最小活跃数策略。那我们得让现有的三个消费者都有一些调用次数。所以我们得改造一下服务提供者和消费者。
服务提供者端的改造如下:
PS:这里以权重为 300 的服务端为例。另外的两个服务端改造点相同。
客户端的改造点如下:
一共发送 21 个请求:其中前 20 个先发到服务端让其 hold 住(因为服务端有 sleep),最后一个请求就是我们需要 Debug 跟踪的请求。
运行一下,让程序停在断点的地方,然后看看控制台的输出:
权重为300的服务端共计收到9个请求
权重为200的服务端共计收到6个请求
默认权重的服务端共计收到5个请求
我们还有一个请求在 Debug。直接进入到我们的第二个断点的位置,并 Debug 到下图所示的一行代码(可以点看查看大图):
正如上面这图所说的:weight=100 回答了一个问题,active=0 提出的一个问题。
weight=100 回答了什么问题呢?
默认权重是多少?是 100。
我们服务端的活跃数分别应该是下面这样的
但是这里为什么截图中的active会等于 0 呢?这是一个问题。
继续往下 Debug 你会发现,每一个服务端的 active 都是 0。所以相比之下没有一个 invoker 有最小 active 。于是程序走到了根据权重选择 invoker 的逻辑中。
active 为 0 说明在 Dubbo 调用的过程中 active 并没有发生变化。那 active 为什么是 0,其实就是在问 active 什么时候发生变化?
要回答这个问题我们得知道 active 是在哪里定义的,因为在其定义的地方,必有其修改的方法。
下面这图说明了active是定义在RpcStatus类里面的一个类型为AtomicInteger 的成员变量。
在 RpcStatus 类中,有三处()调用 active 值的方法,一个增加、一个减少、一个获取:
很明显,我们需要看的是第一个,在哪里增加。
所以我们找到了 beginCount(URL,String) 方法,该方法只有两个 Filter 调用。ActiveLimitFilter,见名知意,这就是我们要找的东西。
com.alibaba.dubbo.rpc.filter.ActiveLimitFilter具体如下:
看到这里,我们就知道怎么去回答这个问题了:为什么active是0呢?因为在客户端没有配置ActiveLimitFilter。所以,ActiveLimitFilter没有生效,导致active没有发生变化。
怎么让其生效呢?已经呼之欲出了。
好了,再来试验一次:
加上Filter之后,我们通过Debug可以看到,对应权重的活跃数就和我们预期的是一致的了。
1.权重为300的活跃数为6
2.权重为200的活跃数为11
3.默认权重(100)的活跃数为3
根据活跃数我们可以分析出来,最后我们Debug住的这个请求,一定会选择默认权重的invoker去执行,因为他是当前活跃数最小的invoker。如下所示:
虽然到这里我们还没开始进行源码的分析,只是把流程梳理清楚了。但是把Demo完整的搭建了起来,而且知道了最少活跃数负载均衡算法必须配合ActiveLimitFilter使用,位于RpcStatus类的active字段才会起作用,否则,它就是一个基于权重的算法。
比起其他地方直接告诉你,要配置ActiveLimitFilter才行哦,我们自己实验得出的结论,能让我们的印象更加深刻。
我们再仔细看一下加上ActiveLimitFilter之后的各个服务的活跃数情况:
你不觉得奇怪吗,为什么权重为200的活跃数是最高的?
其在业务上的含义是:我们有三台性能各异的服务器,A服务器性能最好,所以权重为300,B服务器性能中等,所以权重为200,C服务器性能最差,所以权重为100。
当我们选择最小活跃次数的负载均衡算法时,我们期望的是性能最好的A服务器承担更多的请求,而真实的情况是性能中等的B服务器承担的请求更多。这与我们的设定相悖。
如果你说20个请求数据量太少,可能是巧合,不足以说明问题。说明你还没被我带偏,我们不能基于巧合编程。
所以为了验证这个地方确实有问题,我把请求扩大到一万个。
同时,记得扩大 provider 端的 Dubbo 线程池:
由于每个服务端运行的代码都是一样的,所以我们期望的结果应该是权重最高的承担更多的请求。但是最终的结果如图所示:
各个服务器均摊了请求。这就是我文章最开始的时候说的Dubbo 2.6.0 版本中最小活跃数负载均衡算法的Bug之一。
接下来,我们带着这个问题,去分析源码。
com.alibaba.dubbo.rpc.cluster.loadbalance.LeastActiveLoadBalance的源码如下,我逐行进行了解读。可以点开查看大图,细细品读,非常爽:
下图中红框框起来的部分就是一个基于权重选择invoker的逻辑:
我给大家画图分析一下:
请仔细分析图中给出的举例说明。同时,上面这图也是按照比例画的,可以直观的看到,对于某一个请求,区间(权重)越大的服务器,就越可能会承担这个请求。所以,当请求足够多的时候,各个服务器承担的请求数,应该就是区间,即权重的比值。
其中第 81 行有调用 getWeight 方法,位于抽象类 AbstractLoadBalance 中,也需要进行重点解读的代码。
com.alibaba.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance 的源码如下,我也进行了大量的备注:
在 AbstractLoadBalance 类中提到了一个预热的概念。官网中是这样的介绍该功能的:
权重的计算过程主要用于保证当服务运行时长小于服务预热时间时,对服务进行降权,避免让服务在启动之初就处于高负载状态。服务预热是一个优化手段,与此类似的还有 JVM 预热。主要目的是让服务启动后“低功率”运行一段时间,使其效率慢慢提升至最佳状态。
从上图代码里面的公式(演变后):*计算后的权重=(uptime/warmup)weight 可以看出:随着服务启动时间的增加(uptime),计算后的权重会越来越接近weight。从实际场景的角度来看,随着服务启动时间的增加,服务承担的流量会慢慢上升,没有一个陡升的过程。所以这是一个优化手段。同时 Dubbo 接口还支持延迟暴露。
在仔细的看完上面的源码解析图后,配合官网的总结加上我的灵魂画作,相信你可以对最小活跃数负载均衡算法有一个比较深入的理解:
所以我觉得最小活跃数负载均衡的全称应该叫做:有最小活跃数用最小活跃数,没有最小活跃数根据权重选择,权重一样则随机返回的负载均衡算法。
Dubbo2.6.0最小活跃数算法Bug一
问题出在标号为 ① 和 ② 这两行代码中:
标号为 ① 的代码在url中取出的是没有经过 getWeight 方法降权处理的权重值,这个值会被累加到权重总和(totalWeight)中。
标号为 ② 的代码取的是经过 getWeight 方法处理后的权重值。
取值的差异会导致一个问题,标号为 ② 的代码的左边,offsetWeight 是一个在 [0,totalWeight) 范围内的随机数,右边是经过 getWeight 方法降权后的权重。所以在经过 leastCount 次的循环减法后,offsetWeight 在服务启动时间还没到热启动设置(默认10分钟)的这段时间内,极大可能仍然大于 0。导致不会进入到标号为 ③ 的代码中。直接到标号为 ④ 的代码处,变成了随机调用策略。这与设计不符,所以是个 bug。
前面章节说的情况就是这个Bug导致的。
这个Bug对应的issues地址和pull request分为:
https://github.com/apache/dubbo/issues/904
https://github.com/apache/dubbo/pull/2172
那怎么修复的呢?我们直接对比 Dubbo 2.7.4.1 的代码:
可以看到获取weight的方法变了:从url中直接获取变成了通过getWeight方法获取。获取到的变量名称也变了:从weight变成了afterWarmup,更加的见名知意。
还有一处变化是获取随机值的方法的变化,从Randmo变成了ThreadLoaclRandom,性能得到了提升。这处变化就不展开讲了,有兴趣的朋友可以去了解一下。
Dubbo2.6.0最小活跃数算法Bug二
这个Bug我没有遇到,但是我在官方文档上看了其描述(官方文档中的版本是2.6.4),引用如下:
官网上说这个问题在2.6.5版本进行修复。我对比了2.6.0/2.6.5/2.7.4.1三个版本,发现每个版本都略有不同。如下所示:
图中标记为①的三处代码:
2.6.0版本的是有Bug的代码,原因在上面说过了。
2.6.5版本的修复方式是获取随机数的时候加一,所以取值范围就从**[0,totalWeight)变成了[0,totalWeight]**,这样就可以避免这个问题。
2.7.4.1版本的取值范围还是[0,totalWeight),但是它的修复方法体现在了标记为②的代码处。2.6.0/2.6.5版本标记为②的地方都是if(offsetWeight<=0),而2.7.4.1版本变成了if(offsetWeight<0)。
你品一品,是不是效果是一样的,但是更加优雅了。
朋友们,魔鬼,都在细节里啊!
好了,进入下一议题。
这一部分是对于Dubbo负载均衡策略之一的一致性哈希负载均衡的详细分析。对源码逐行解读、根据实际运行结果,配以丰富的图片,可能是东半球讲一致性哈希算法在Dubbo中的实现最详细的文章了。
本小节所示源码,没有特别标注的地方,均为2.7.4.1版本。
在撰写本文的过程中,发现了Dubbo2.7.0版本之后的一个bug。会导致性能问题,如果你们的负载均衡配置的是一致性哈希或者考虑使用一致性哈希的话,可以了解一下。
在介绍一致性哈希算法之前,我们看看哈希算法,以及它解决了什么问题,带来了什么问题。
如上图所示,假设0,1,2号服务器都存储的有用户信息,那么当我们需要获取某用户信息时,因为我们不知道该用户信息存放在哪一台服务器中,所以需要分别查询0,1,2号服务器。这样获取数据的效率是极低的。
对于这样的场景,我们可以引入哈希算法。
还是上面的场景,但前提是每一台服务器存放用户信息时是根据某一种哈希算法存放的。所以取用户信息的时候,也按照同样的哈希算法取即可。
假设我们要查询用户号为100的用户信息,经过某个哈希算法,比如这里的userId mod n,即100 mod 3结果为1。所以用户号100的这个请求最终会被1号服务器接收并处理。
这样就解决了无效查询的问题。
但是这样的方案会带来什么问题呢?
扩容或者缩容时,会导致大量的数据迁移。最少也会影响百分之50的数据。
为了说明问题,我们加入一台服务器3。服务器的数量n就从3变成了4。还是查询用户号为100的用户信息时,100 mod 4结果为0。这时,请求就被0号服务器接收了。
当服务器数量为3时,用户号为100的请求会被1号服务器处理。
当服务器数量为4时,用户号为100的请求会被0号服务器处理。
所以,当服务器数量增加或者减少时,一定会涉及到大量数据迁移的问题。可谓是牵一发而动全身。
对于上诉哈希算法其优点是简单易用,大多数分库分表规则就采取的这种方式。一般是提前根据数据量,预先估算好分区数。
其缺点是由于扩容或收缩节点导致节点数量变化时,节点的映射关系需要重新计算,会导致数据进行迁移。所以扩容时通常采用翻倍扩容,避免数据映射全部被打乱,导致全量迁移的情况,这样只会发生50%的数据迁移。
假设这是一个缓存服务,数据的迁移会导致在迁移的时间段内,有缓存是失效的。
缓存失效,可怕啊。还记得我之前的文章吗,《当周杰伦把QQ音乐干翻的时候,作为程序猿我看到了什么?》就是讲缓存击穿、缓存穿透、缓存雪崩的场景和对应的解决方案。
为了解决哈希算法带来的数据迁移问题,一致性哈希算法应运而生。
对于一致性哈希算法,官方说法如下:
一致性哈希算法在1997年由麻省理工学院提出,是一种特殊的哈希算法,在移除或者添加一个服务器时,能够尽可能小地改变已存在的服务请求与处理请求服务器之间的映射关系。一致性哈希解决了简单哈希算法在分布式哈希表( Distributed Hash Table,DHT) 中存在的动态伸缩等问题。
什么意思呢?我用大白话加画图的方式给你简单的介绍一下。
一致性哈希,你可以想象成一个哈希环,它由0到2^32-1个点组成。A,B,C分别是三台服务器,每一台的IP加端口经过哈希计算后的值,在哈希环上对应如下:
当请求到来时,对请求中的某些参数进行哈希计算后,也会得出一个哈希值,此值在哈希环上也会有对应的位置,这个请求会沿着顺时针的方向,寻找最近的服务器来处理它,如下图所示:
一致性哈希就是这么个东西。那它是怎么解决服务器的扩容或收缩导致大量的数据迁移的呢?
看一下当我们使用一致性哈希算法时,加入服务器会发什么事情。
当我们加入一个D服务器后,假设其IP加端口,经过哈希计算后落在了哈希环上图中所示的位置。
这时影响的范围只有图中标注了五角星的区间。这个区间的请求从原来的由C服务器处理变成了由D服务器请求。而D到C,C到A,A到B这个区间的请求没有影响,加入D节点后,A、B服务器是无感知的。
所以,在一致性哈希算法中,如果增加一台服务器,则受影响的区间仅仅是新服务器(D)在哈希环空间中,逆时针方向遇到的第一台服务器(B)之间的区间,其它区间(D到C,C到A,A到B)不会受到影响。
在加入了D服务器的情况下,我们再假设一段时间后,C服务器宕机了:
当C服务器宕机后,影响的范围也是图中标注了五角星的区间。C节点宕机后,B、D服务器是无感知的。
所以,在一致性哈希算法中,如果宕机一台服务器,则受影响的区间仅仅是宕机服务器(C)在哈希环空间中,逆时针方向遇到的第一台服务器(D)之间的区间,其它区间(C到A,A到B,B到D)不会受到影响。
综上所述,在一致性哈希算法中,不管是增加节点,还是宕机节点,受影响的区间仅仅是增加或者宕机服务器在哈希环空间中,逆时针方向遇到的第一台服务器之间的区间,其它区间不会受到影响。
是不是很完美?
不是的,理想和现实的差距是巨大的。
一致性哈希算法带来了什么问题?
当节点很少的时候可能会出现这样的分布情况,A服务会承担大部分请求。这种情况就叫做数据倾斜。
怎么解决数据倾斜呢?加入虚拟节点。
怎么去理解这个虚拟节点呢?
首先一个服务器根据需要可以有多个虚拟节点。假设一台服务器有n个虚拟节点。那么哈希计算时,可以使用IP+端口+编号的形式进行哈希值计算。其中的编号就是0到n的数字。由于IP+端口是一样的,所以这n个节点都是指向的同一台机器。
如下图所示:
在没有加入虚拟节点之前,A服务器承担了绝大多数的请求。但是假设每个服务器有一个虚拟节点(A-1,B-1,C-1),经过哈希计算后落在了如上图所示的位置。那么A服务器的承担的请求就在一定程度上(图中标注了五角星的部分)分摊给了B-1、C-1虚拟节点,实际上就是分摊给了B、C服务器。
一致性哈希算法中,加入虚拟节点,可以解决数据倾斜问题。
当你在面试的过程中,如果听到了类似于数据倾斜的字眼。那大概率是在问你一致性哈希算法和虚拟节点。
在介绍了相关背景后,我们可以去看看一致性哈希算法在Dubbo中的应用了。
前面我们说了Dubbo中负载均衡的实现是通过
org.apache.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance中的 doSelect 抽象方法实现的,一致性哈希负载均衡的实现类如下所示:
org.apache.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance
由于一致性哈希实现类看起来稍微有点抽象,不太好演示,所以我想到了一个"骚"操作。前面的文章说过 LoadBalance 是一个 SPI 接口:
既然是一个 SPI 接口,那我们可以自己扩展一个一模一样的算法,只是在算法里面加入一点输出语句方便我们观察情况。怎么扩展 SPI 接口就不描述了,只要记住代码里面的输出语句都是额外加的,此外没有任何改动即可,如下:
整个类如下图片所示,请先看完整个类,有一个整体的概念后,我会进行方法级别的分析。
图片很长,其中我加了很多注释和输出语句,可以点开大图查看,一定会帮你更加好的理解一致性哈希在Dubbo中的应用:
改造之后,我们先把程序跑起来,有了输出就好分析了。
服务端代码如下:
其中的端口是需要手动修改的,我分别启动服务在20881和20882端口。
项目中provider.xml配置如下:
consumer.xml配置如下:
然后,启动在20881和20882端口分别启动两个服务端。客户端消费如下:
运行结果输出如下,可以先看个大概的输出,下面会对每一部分输出进行逐一的解读。
好了,用例也跑起来了,日志也有了。接下来开始结合代码和日志进行方法级别的分析。
首先是doSelect方法的入口:
从上图我们知道了,第一次调用需要对selectors进行put操作,selectors的 key 是接口中定义的方法,value 是 ConsistentHashSelector 内部类。
ConsistentHashSelector通过调用其构造函数进行初始化的。invokers(服务端)作为参数传递到了构造函数中,构造函数里面的逻辑,就是把服务端映射到哈希环上的过程,请看下图,结合代码,仔细分析输出数据:
从上图可以看出,当 ConsistentHashSelector 的构造方法调用完成后,8个虚拟节点在哈希环上已经映射完成。两台服务器,每一台4个虚拟节点组成了这8个虚拟节点。
doSelect方法继续执行,并打印出每个虚拟节点的哈希值和对应的服务端,请仔细品读下图:
说明一下:上面图中的哈希环是没有考虑比例的,仅仅是展现了两个服务器在哈希环上的相对位置。而且为了演示说明方便,仅仅只有8个节点。假设我们有4台服务器,每台服务器的虚拟节点是默认值(160),这个情况下哈希环上一共有160*4=640个节点。
哈希环映射完成后,接下来的逻辑是把这次请求经过哈希计算后,映射到哈希环上,并顺时针方向寻找遇到的第一个节点,让该节点处理该请求:
还记得地址为 468e8565 的 A 服务器是什么端口吗?前面的图片中有哦,该服务对应的端口是 20882 。
最后我们看看输出结果:
和我们预期的一致。整个调用就算是完成了。
再对两个方法进行一个补充说明。
第一个方法是 selectForKey,这个方法里面逻辑如下图所示:
虚拟节点都存储在 TreeMap 中。顺时针查询的逻辑由 TreeMap 保证。看一下下面的 Demo 你就明白了。
第二个方法是 hash 方法,其中的 & 0xFFFFFFFFL 的目的如下:
&是位运算符,而 0xFFFFFFFFL 转换为四字节表现后,其低32位全是1,所以保证了哈希环的范围是 [0,Integer.MAX_VALUE]:
所以这里我们可以改造这个哈希环的范围,假设我们改为 100000。十进制的 100000 对于的 16 进制为 186A0 。所以我们改造后的哈希算法为:
再次调用后可以看到,计算后的哈希值都在10万以内。但是分布极不均匀,说明修改数据后这个哈希算法不是一个优秀的哈希算法:
以上,就是对一致性哈希算法在Dubbo中的实现的解读。需要特殊说明一下的是,一致性哈希负载均衡策略和权重没有任何关系。
前面我介绍了Dubbo 2.6.5版本之前,最小活跃数算法的两个 bug。
很不幸,这次我又发现了Dubbo 2.7.4.1版本,一致性哈希负载均衡策略的一个bug,我提交了issue 地址如下:
https://github.com/apache/dubbo/issues/5429
我在这里详细说一下这个Bug现象、原因和我的解决方案。
现象如下,我们调用三次服务端:
输出日志如下(有部分删减):
可以看到,在三次调用的过程中并没有发生服务的上下线操作,但是每一次调用都重新进行了哈希环的映射。而我们预期的结果是应该只有在第一次调用的时候进行哈希环的映射,如果没有服务上下线的操作,后续请求根据已经映射好的哈希环进行处理。
上面输出的原因是由于每次调用的invokers的identityHashCode发生了变化:
我们看一下三次调用invokers的情况:
经过debug我们可以看出因为每次调用的invokers地址值不是同一个,所以System.identityHashCode(invokers)方法返回的值都不一样。
接下来的问题就是为什么每次调用的invokers地址值都不一样呢?
经过Debug之后,可以找到这个地方:
org.apache.dubbo.rpc.cluster.RouterChain#route
问题就出在这个TagRouter中:
org.apache.dubbo.rpc.cluster.router.tag.TagRouter#filterInvoker
所以,在TagRouter中的stream操作,改变了invokers,导致每次调用时其
System.identityHashCode(invokers)返回的值不一样。所以每次调用都会进行哈希环的映射操作,在服务节点多,虚拟节点多的情况下会有一定的性能问题。
到这一步,问题又发生了变化。这个TagRouter怎么来的呢?
如果了解Dubbo 2.7.x版本新特性的朋友可能知道,标签路由是Dubbo2.7引入的新功能。
通过加载下面的配置加载了RouterFactrory:
META-INFdubbointernal
org.apache.dubbo.rpc.cluster.RouterFactory(Dubbo 2.7.0版本之前)
META-INFdubbointernal
com.alibaba.dubbo.rpc.cluster.RouterFactory(Dubbo 2.7.0之前)
下面是Dubbo 2.6.7(2.6.x的最后一个版本)和Dubbo 2.7.0版本该文件的对比:
可以看到确实是在 Dubbo 2.7.0 之后引入了 TagRouter。
至此,Dubbo 2.7.0 版本之后,一致性哈希负载均衡算法的 Bug 的来龙去脉也介绍清楚了。
解决方案是什么呢?特别简单,把获取 identityHashCode 的方法从 System.identityHashCode(invokers) 修改为 invokers.hashCode() 即可。
此方案是我提的 issue 里面的评论,这里 System.identityHashCode 和 hashCode 之间的联系和区别就不进行展开讲述了,不清楚的大家可以自行了解一下。
(我的另外一篇文章:够强!一行代码就修复了我提的Dubbo的Bug。)
改完之后,我们再看看运行效果:
可以看到第二次调用的时候并没有进行哈希环的映射操作,而是直接取到了值,进行调用。
最后,我再分析一种情况。在A、B、C三个服务器(20881、20882、20883端口)都在正常运行,哈希映射已经完成的情况下,我们再启动一个D节点(20884端口),这时的日志输出和对应的哈希环变化情况如下:
根据日志作图如下:
根据输出日志和上图再加上源码,你再细细回味一下。我个人觉得还是讲的非常详细了。
当大家谈到一致性哈希算法的时候,首先的第一印象应该是在缓存场景下的使用,因为在一个优秀的哈希算法加持下,其上下线节点对整体数据的影响(迁移)都是比较友好的。
但是想一下为什么 Dubbo 在负载均衡策略里面提供了基于一致性哈希的负载均衡策略?它的实际使用场景是什么?
我最开始也想不明白。我想的是在 Dubbo 的场景下,假设需求是想要一个用户的请求一直让一台服务器处理,那我们可以采用一致性哈希负载均衡策略,把用户号进行哈希计算,可以实现这样的需求。但是这样的需求未免有点太牵强了,适用场景略小。
直到有天晚上,我睡觉之前,电光火石之间突然想到了一个稍微适用的场景了。
如果需求是需要保证某一类请求必须顺序处理呢?
如果你用其他负载均衡策略,请求分发到了不同的机器上去,就很难保证请求的顺序处理了。比如A,B请求要求顺序处理,现在A请求先发送,被负载到了A服务器上,B请求后发送,被负载到了B服务器上。而B服务器由于性能好或者当前没有其他请求或者其他原因极有可能在A服务器还在处理A请求之前就把B请求处理完成了。这样不符合我们的要求。
这时,一致性哈希负载均衡策略就上场了,它帮我们保证了某一类请求都发送到固定的机器上去执行。比如把同一个用户的请求发送到同一台机器上去执行,就意味着把某一类请求发送到同一台机器上去执行。所以我们只需要在该机器上运行的程序中保证顺序执行就行了,比如你加一个队列。
一致性哈希算法+队列,可以实现顺序处理的需求。
好了,一致性哈希负载均衡算法就写到这里。
原文链接:
https://juejin.im/post/5ed39adef265da76dc1bb817