本文是對於Dubbo負載均衡策略之一的一致性哈希負載均衡的詳細分析。對源碼逐行解讀、根據實際運行結果,配以豐富的圖片,可能是東半球講一致性哈希算法在Dubbo中的實現最詳細的文章了。
文中所示源碼,沒有特別標注的地方,均為2.7.4.1版本。
在撰寫本文的過程中,發現了Dubbo2.7.0版本之后的一個bug。會導致性能問題,且目前還未解決,如果你們的負載均衡配置的是一致性哈希或者考慮使用一致性哈希的話,可以了解一下。
本文目錄
第一節:哈希算法
本小節主要是為了介紹一致性哈希算法做鋪墊。簡單的介紹了哈希算法解決了什么問題,帶來了什么問題。
第二節:一致性哈希算法
本小節主要是通過作圖對一致性哈希進行了簡單的介紹。介紹了一致性哈希是怎么解決哈希算法帶來的問題,怎么解決數據傾斜的問題。
第三節:一致性哈希算法在Dubbo中的應用
本小節是全文重點,通過一個"騷"操作,對Dubbo一致性哈希算法的源碼進行了十分詳細的剖析。從整個類到類里面的每個方法進行了詳盡的分析,打印了大量的日志,配合圖片,方便讀者理解。
第四節:我又發現了一個Bug
本小節主要是介紹我在研究Dubbo一致性哈希負載均衡時遇到的一個問題,深入研究之后發現可能是一個Bug。這一小節就是比較詳盡的介紹了這個Bug現象、原因以及我的解決方案。
第五節:加入節點,畫圖分析
本小節對具體的案例進行了分析,並配以圖片,相信能幫助讀者更加深刻的理解一致性哈希算法在Dubbo中的應用。
第六節:一致性哈希的應用場景
本小節主要介紹幾個應用場景。使用Duboo框架,在什么樣的需求可以使用一致性哈希算法做負載均衡。
PS:前一、二節主要是進行了背景知識的簡單鋪墊,如果你了解相關背景知識,可以直接從第三節看起。本文的重點是第三到第五節。如果你只想知道Bug是什么,可以直接閱讀第四節。
另:閱讀本文需要對Dubbo有一定的了解。文章很長,建議收藏慢慢閱讀。一定會有收獲的。
哈希算法
在介紹一致性哈希算法之前,我們看看哈希算法,以及它解決了什么問題,帶來了什么問題。

如上圖所示,假設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中的應用
經過《一文講透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中的實現的解讀。需要特殊說明一下的是,和上周分享的最小活躍數負載均衡算法不同,一致性哈希負載均衡策略和權重沒有任何關系。
我又發現了一個BUG
在上篇文章中,我介紹了Dubbo 2.6.5版本之前,最小活躍數算法的兩個bug。很不幸,這次我又發現了Dubbo 2.7.4.1版本,一致性哈希負載均衡策略的一個bug,我提交了issue,截止目前還未解決。
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-INF\dubbo\internal\org.apache.dubbo.rpc.cluster.RouterFactory(Dubbo 2.7.0版本之前)
META-INF\dubbo\internal\com.alibaba.dubbo.rpc.cluster.RouterFactory(Dubbo 2.7.0之前)
下面是Dubbo 2.6.7(2.6.x的最后一個版本)和Dubbo 2.7.0版本該文件的對比:

可以看到確實是在Dubbo2.7.0之后引入了TagRouter。
至此,Dubbo 2.7.0版本之后,一致性哈希負載均衡算法的Bug的來龍去脈也介紹清楚了。目前該Bug還未解決。
解決方案是什么呢?特別簡單,把獲取identityHashCode的方法從System.identityHashCode(invokers)修改為invokers.hashCode()即可。
此方案是我提的issue里面的評論,這里System.identityHashCode和 hashCode之間的聯系和區別就不進行展開講述了,不清楚的大家可以自行了解一下。

改完之后,我們再看看運行效果:

可以看到第二次調用的時候並沒有進行哈希環的映射操作,而是直接取到了值,進行調用。
加入節點,畫圖分析
最后,我再分析一種情況。在A、B、C三個服務器(20881、20882、20883端口)都在正常運行,哈希映射已經完成的情況下,我們再啟動一個D節點(20884端口),這時的日志輸出和對應的哈希環變化情況如下:

根據日志作圖如下:

根據輸出日志和上圖再加上源碼,你再細細回味一下。我個人覺得還是講的非常詳細了,可能是東半球講一致性哈希算法在Dubbo中的實現最詳細的文章了。
一致性哈希的應用場景
當大家談到一致性哈希算法的時候,首先的第一印象應該是在緩存場景下的使用,因為在一個優秀的哈希算法加持下,其上下線節點對整體數據的影響(遷移)都是比較友好的。
但是想一下為什么Dubbo在負載均衡策略里面提供了基於一致性哈希的負載均衡策略?它的實際使用場景是什么?
我最開始也想不明白。我想的是在Dubbo的場景下,假設需求是想要一個用戶的請求一直讓一台服務器處理,那我們可以采用一致性哈希負載均衡策略,把用戶號進行哈希計算,可以實現這樣的需求。但是這樣的需求未免有點太牽強了,適用場景略小。
直到有天晚上,我睡覺之前,電光火石之間突然想到了一個稍微適用的場景了。當時的情況大概是這樣的。

如果需求是需要保證某一類請求必須順序處理呢?
如果你用其他負載均衡策略,請求分發到了不同的機器上去,就很難保證請求的順序處理了。比如A,B請求要求順序處理,現在A請求先發送,被負載到了A服務器上,B請求后發送,被負載到了B服務器上。而B服務器由於性能好或者當前沒有其他請求或者其他原因極有可能在A服務器還在處理A請求之前就把B請求處理完成了。這樣不符合我們的要求。
這時,一致性哈希負載均衡策略就上場了,它幫我們保證了某一類請求都發送到固定的機器上去執行。比如把同一個用戶的請求發送到同一台機器上去執行,就意味着把某一類請求發送到同一台機器上去執行。所以我們只需要在該機器上運行的程序中保證順序執行就行了,比如你加一個隊列。
一致性哈希算法+隊列,可以實現順序處理的需求。
最后說一句
這是Dubbo負載均衡算法的第二篇文章,上周寫了一篇《一文講透Dubbo負載均衡之最小活躍數算法》,也是非常詳細,可以看看哦。
才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,還請你留言給我指出來,我對其加以修改。
如果你覺得文章還不錯,你的轉發、分享、贊賞、點贊、留言就是對我最大的鼓勵。
以上。
再推銷一下我公眾號:對於寫文章,其實想到寫什么內容並不難,難的是你對內容的把控。關於技術性的語言,我是反復推敲,查閱大量文章來進行證偽,總之慎言慎言再慎言,畢竟做技術,我認為是一件非常嚴謹的事情,我常常想象自己就是在故宮修文物的工匠,在工匠精神的認知上,目前我可能和他們還差的有點遠,但是我時常以工匠精神要求自己。就像我之前表達的:對於技術文章(因為我偶爾也會荒腔走板的聊一聊生活,寫一寫書評,影評),我盡量保證周推,全力保證質量。堅持輸出原創。
