java 一致性hash算法 均衡分发_Dubbo一致性哈希负载均衡的源码和Bug,了解一下?...
本文是對(duì)于Dubbo負(fù)載均衡策略之一的一致性哈希負(fù)載均衡的詳細(xì)分析。對(duì)源碼逐行解讀、根據(jù)實(shí)際運(yùn)行結(jié)果,配以豐富的圖片,可能是東半球講一致性哈希算法在Dubbo中的實(shí)現(xiàn)最詳細(xì)的文章了。
文中所示源碼,沒有特別標(biāo)注的地方,均為2.7.4.1版本。
在撰寫本文的過程中,發(fā)現(xiàn)了Dubbo2.7.0版本之后的一個(gè)bug。會(huì)導(dǎo)致性能問題,且目前還未解決,如果你們的負(fù)載均衡配置的是一致性哈希或者考慮使用一致性哈希的話,可以了解一下。
本文目錄
第一節(jié):哈希算法
本小節(jié)主要是為了介紹一致性哈希算法做鋪墊。簡(jiǎn)單的介紹了哈希算法解決了什么問題,帶來了什么問題。
第二節(jié):一致性哈希算法
本小節(jié)主要是通過作圖對(duì)一致性哈希進(jìn)行了簡(jiǎn)單的介紹。介紹了一致性哈希是怎么解決哈希算法帶來的問題,怎么解決數(shù)據(jù)傾斜的問題。
第三節(jié):一致性哈希算法在Dubbo中的應(yīng)用
本小節(jié)是全文重點(diǎn),通過一個(gè)"騷"操作,對(duì)Dubbo一致性哈希算法的源碼進(jìn)行了十分詳細(xì)的剖析。從整個(gè)類到類里面的每個(gè)方法進(jìn)行了詳盡的分析,打印了大量的日志,配合圖片,方便讀者理解。
第四節(jié):我又發(fā)現(xiàn)了一個(gè)Bug
本小節(jié)主要是介紹我在研究Dubbo一致性哈希負(fù)載均衡時(shí)遇到的一個(gè)問題,深入研究之后發(fā)現(xiàn)可能是一個(gè)Bug。這一小節(jié)就是比較詳盡的介紹了這個(gè)Bug現(xiàn)象、原因以及我的解決方案。
第五節(jié):加入節(jié)點(diǎn),畫圖分析
本小節(jié)對(duì)具體的案例進(jìn)行了分析,并配以圖片,相信能幫助讀者更加深刻的理解一致性哈希算法在Dubbo中的應(yīng)用。
第六節(jié):一致性哈希的應(yīng)用場(chǎng)景
本小節(jié)主要介紹幾個(gè)應(yīng)用場(chǎng)景。使用Duboo框架,在什么樣的需求可以使用一致性哈希算法做負(fù)載均衡。
PS:前一、二節(jié)主要是進(jìn)行了背景知識(shí)的簡(jiǎn)單鋪墊,如果你了解相關(guān)背景知識(shí),可以直接從第三節(jié)看起。本文的重點(diǎn)是第三到第五節(jié)。如果你只想知道Bug是什么,可以直接閱讀第四節(jié)。
另:閱讀本文需要對(duì)Dubbo有一定的了解。文章很長(zhǎng),建議收藏慢慢閱讀。一定會(huì)有收獲的。
哈希算法
在介紹一致性哈希算法之前,我們看看哈希算法,以及它解決了什么問題,帶來了什么問題。
如上圖所示,假設(shè)0,1,2號(hào)服務(wù)器都存儲(chǔ)的有用戶信息,那么當(dāng)我們需要獲取某用戶信息時(shí),因?yàn)槲覀儾恢涝撚脩粜畔⒋娣旁谀囊慌_(tái)服務(wù)器中,所以需要分別查詢0,1,2號(hào)服務(wù)器。這樣獲取數(shù)據(jù)的效率是極低的。
對(duì)于這樣的場(chǎng)景,我們可以引入哈希算法。
還是上面的場(chǎng)景,但前提是每一臺(tái)服務(wù)器存放用戶信息時(shí)是根據(jù)某一種哈希算法存放的。所以取用戶信息的時(shí)候,也按照同樣的哈希算法取即可。
假設(shè)我們要查詢用戶號(hào)為100的用戶信息,經(jīng)過某個(gè)哈希算法,比如這里的userId mod n,即100 mod 3結(jié)果為1。所以用戶號(hào)100的這個(gè)請(qǐng)求最終會(huì)被1號(hào)服務(wù)器接收并處理。
這樣就解決了無效查詢的問題。
但是這樣的方案會(huì)帶來什么問題呢?
擴(kuò)容或者縮容時(shí),會(huì)導(dǎo)致大量的數(shù)據(jù)遷移。最少也會(huì)影響百分之50的數(shù)據(jù)。
為了說明問題,我們加入一臺(tái)服務(wù)器3。服務(wù)器的數(shù)量n就從3變成了4。還是查詢用戶號(hào)為100的用戶信息時(shí),100 mod 4結(jié)果為0。這時(shí),請(qǐng)求就被0號(hào)服務(wù)器接收了。
當(dāng)服務(wù)器數(shù)量為3時(shí),用戶號(hào)為100的請(qǐng)求會(huì)被1號(hào)服務(wù)器處理。
當(dāng)服務(wù)器數(shù)量為4時(shí),用戶號(hào)為100的請(qǐng)求會(huì)被0號(hào)服務(wù)器處理。
所以,當(dāng)服務(wù)器數(shù)量增加或者減少時(shí),一定會(huì)涉及到大量數(shù)據(jù)遷移的問題。可謂是牽一發(fā)而動(dòng)全身。
對(duì)于上述哈希算法其優(yōu)點(diǎn)是簡(jiǎn)單易用,大多數(shù)分庫(kù)分表規(guī)則就采取的這種方式。一般是提前根據(jù)數(shù)據(jù)量,預(yù)先估算好分區(qū)數(shù)。
其缺點(diǎn)是由于擴(kuò)容或收縮節(jié)點(diǎn)導(dǎo)致節(jié)點(diǎn)數(shù)量變化時(shí),節(jié)點(diǎn)的映射關(guān)系需要重新計(jì)算,會(huì)導(dǎo)致數(shù)據(jù)進(jìn)行遷移。所以擴(kuò)容時(shí)通常采用翻倍擴(kuò)容,避免數(shù)據(jù)映射全部被打亂,導(dǎo)致全量遷移的情況,這樣只會(huì)發(fā)生50%的數(shù)據(jù)遷移。
假設(shè)這是一個(gè)緩存服務(wù),數(shù)據(jù)的遷移會(huì)導(dǎo)致在遷移的時(shí)間段內(nèi),有緩存是失效的。緩存失效,可怕啊。還記得我之前的文章嗎,《當(dāng)周杰倫把QQ音樂干翻的時(shí)候,作為程序猿我看到了什么?》就是講緩存擊穿、緩存穿透、緩存雪崩的場(chǎng)景和對(duì)應(yīng)的解決方案。
一致性哈希算法
為了解決哈希算法帶來的數(shù)據(jù)遷移問題,一致性哈希算法應(yīng)運(yùn)而生。
對(duì)于一致性哈希算法,官方說法如下:
一致性哈希算法在1997年由麻省理工學(xué)院提出,是一種特殊的哈希算法,在移除或者添加一個(gè)服務(wù)器時(shí),能夠盡可能小地改變已存在的服務(wù)請(qǐng)求與處理請(qǐng)求服務(wù)器之間的映射關(guān)系。一致性哈希解決了簡(jiǎn)單哈希算法在分布式哈希表( Distributed Hash Table,DHT) 中存在的動(dòng)態(tài)伸縮等問題。
什么意思呢?我用大白話加畫圖的方式給你簡(jiǎn)單的介紹一下。
一致性哈希,你可以想象成一個(gè)哈希環(huán),它由0到2^32-1個(gè)點(diǎn)組成。A,B,C分別是三臺(tái)服務(wù)器,每一臺(tái)的IP加端口經(jīng)過哈希計(jì)算后的值,在哈希環(huán)上對(duì)應(yīng)如下:
當(dāng)請(qǐng)求到來時(shí),對(duì)請(qǐng)求中的某些參數(shù)進(jìn)行哈希計(jì)算后,也會(huì)得出一個(gè)哈希值,此值在哈希環(huán)上也會(huì)有對(duì)應(yīng)的位置,這個(gè)請(qǐng)求會(huì)沿著順時(shí)針的方向,尋找最近的服務(wù)器來處理它,如下圖所示:
一致性哈希就是這么個(gè)東西。那它是怎么解決服務(wù)器的擴(kuò)容或收縮導(dǎo)致大量的數(shù)據(jù)遷移的呢?
看一下當(dāng)我們使用一致性哈希算法時(shí),加入服務(wù)器會(huì)發(fā)什么事情。
當(dāng)我們加入一個(gè)D服務(wù)器后,假設(shè)其IP加端口,經(jīng)過哈希計(jì)算后落在了哈希環(huán)上圖中所示的位置。
這時(shí)影響的范圍只有圖中標(biāo)注了五角星的區(qū)間。這個(gè)區(qū)間的請(qǐng)求從原來的由C服務(wù)器處理變成了由D服務(wù)器請(qǐng)求。而D到C,C到A,A到B這個(gè)區(qū)間的請(qǐng)求沒有影響,加入D節(jié)點(diǎn)后,A、B服務(wù)器是無感知的。
所以,在一致性哈希算法中,如果增加一臺(tái)服務(wù)器,則受影響的區(qū)間僅僅是新服務(wù)器(D)在哈希環(huán)空間中,逆時(shí)針方向遇到的第一臺(tái)服務(wù)器(B)之間的區(qū)間,其它區(qū)間(D到C,C到A,A到B)不會(huì)受到影響。
在加入了D服務(wù)器的情況下,我們?cè)偌僭O(shè)一段時(shí)間后,C服務(wù)器宕機(jī)了:
當(dāng)C服務(wù)器宕機(jī)后,影響的范圍也是圖中標(biāo)注了五角星的區(qū)間。C節(jié)點(diǎn)宕機(jī)后,B、D服務(wù)器是無感知的。
所以,在一致性哈希算法中,如果宕機(jī)一臺(tái)服務(wù)器,則受影響的區(qū)間僅僅是宕機(jī)服務(wù)器(C)在哈希環(huán)空間中,逆時(shí)針方向遇到的第一臺(tái)服務(wù)器(D)之間的區(qū)間,其它區(qū)間(C到A,A到B,B到D)不會(huì)受到影響。
綜上所述,在一致性哈希算法中,不管是增加節(jié)點(diǎn),還是宕機(jī)節(jié)點(diǎn),受影響的區(qū)間僅僅是增加或者宕機(jī)服務(wù)器在哈希環(huán)空間中,逆時(shí)針方向遇到的第一臺(tái)服務(wù)器之間的區(qū)間,其它區(qū)間不會(huì)受到影響。
是不是很完美?
不是的,理想和現(xiàn)實(shí)的差距是巨大的。
一致性哈希算法帶來了什么問題?
當(dāng)節(jié)點(diǎn)很少的時(shí)候可能會(huì)出現(xiàn)這樣的分布情況,A服務(wù)會(huì)承擔(dān)大部分請(qǐng)求。這種情況就叫做數(shù)據(jù)傾斜。
怎么解決數(shù)據(jù)傾斜呢?加入虛擬節(jié)點(diǎn)。
怎么去理解這個(gè)虛擬節(jié)點(diǎn)呢?
首先一個(gè)服務(wù)器根據(jù)需要可以有多個(gè)虛擬節(jié)點(diǎn)。假設(shè)一臺(tái)服務(wù)器有n個(gè)虛擬節(jié)點(diǎn)。那么哈希計(jì)算時(shí),可以使用IP+端口+編號(hào)的形式進(jìn)行哈希值計(jì)算。其中的編號(hào)就是0到n的數(shù)字。由于IP+端口是一樣的,所以這n個(gè)節(jié)點(diǎn)都是指向的同一臺(tái)機(jī)器。
如下圖所示:
在沒有加入虛擬節(jié)點(diǎn)之前,A服務(wù)器承擔(dān)了絕大多數(shù)的請(qǐng)求。但是假設(shè)每個(gè)服務(wù)器有一個(gè)虛擬節(jié)點(diǎn)(A-1,B-1,C-1),經(jīng)過哈希計(jì)算后落在了如上圖所示的位置。那么A服務(wù)器的承擔(dān)的請(qǐng)求就在一定程度上(圖中標(biāo)注了五角星的部分)分?jǐn)偨o了B-1、C-1虛擬節(jié)點(diǎn),實(shí)際上就是分?jǐn)偨o了B、C服務(wù)器。
一致性哈希算法中,加入虛擬節(jié)點(diǎn),可以解決數(shù)據(jù)傾斜問題。
當(dāng)你在面試的過程中,如果聽到了類似于數(shù)據(jù)傾斜的字眼。那大概率是在問你一致性哈希算法和虛擬節(jié)點(diǎn)。
在介紹了相關(guān)背景后,我們可以去看看一致性哈希算法在Dubbo中的應(yīng)用了。
一致性哈希算法在Dubbo中的應(yīng)用
經(jīng)過《一文講透Dubbo負(fù)載均衡之最小活躍數(shù)算法》這篇文章我們知道Dubbo中負(fù)載均衡的實(shí)現(xiàn)是通過org.apache.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance中的doSelect抽象方法實(shí)現(xiàn)的,一致性哈希負(fù)載均衡的實(shí)現(xiàn)類如下所示:
org.apache.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance
由于一致性哈希實(shí)現(xiàn)類看起來稍微有點(diǎn)抽象,不太好演示,所以我想到了一個(gè)"騷"操作。前面的文章說過LoadBalance是一個(gè)SPI接口:
既然是一個(gè)SPI接口,那我們可以自己擴(kuò)展一個(gè)一模一樣的算法,只是在算法里面加入一點(diǎn)輸出語(yǔ)句方便我們觀察情況。怎么擴(kuò)展SPI接口就不描述了,只要記住代碼里面的輸出語(yǔ)句都是額外加的,此外沒有任何改動(dòng)即可,如下:
整個(gè)類如下圖片所示,請(qǐng)先看完整個(gè)類,有一個(gè)整體的概念后,我會(huì)進(jìn)行方法級(jí)別的分析。
圖片很長(zhǎng),其中我加了很多注釋和輸出語(yǔ)句,可以點(diǎn)開大圖查看,一定會(huì)幫你更加好的理解一致性哈希在Dubbo中的應(yīng)用:
把代碼也貼在這里
public class WhyConsistentHashLoadBalance extends AbstractLoadBalance {
public static final String NAME = "consistenthash";
/**
* Hash nodes name
*/
public static final String HASH_NODES = "hash.nodes";
/**
* Hash arguments name
*/
public static final String HASH_ARGUMENTS = "hash.arguments";
private final ConcurrentMap> selectors =
new ConcurrentHashMap>();
@SuppressWarnings("unchecked")
@Override
protected Invoker doSelect(List> invokers, URL url, Invocation invocation) {
String methodName = RpcUtils.getMethodName(invocation);
String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;
System.out.println("從selectors中獲取value的key=" + key);
//獲取invokers的hashcode
int identityHashCode = System.identityHashCode(invokers);
WhyConsistentHashLoadBalance.ConsistentHashSelector selector =
(WhyConsistentHashLoadBalance.ConsistentHashSelector) selectors.get(key);
//如果invokers是一個(gè)新的List對(duì)象,意味著服務(wù)提供者數(shù)量發(fā)生了變化,可能新增也可能減少了。
//此時(shí)selector.identityHashCode!=identityHashCode條件成立
//如果是第一次調(diào)用此時(shí)selector == null條件成立
if (selector == null || selector.identityHashCode != identityHashCode) {
System.out.println("是新的invokers:" + identityHashCode + ",原:" + (selector == null ? "null" : selector.identityHashCode));
//創(chuàng)建新的ConsistentHashSelector
selectors.put(key, new WhyConsistentHashLoadBalance.ConsistentHashSelector(invokers, methodName, identityHashCode));
selector = (WhyConsistentHashLoadBalance.ConsistentHashSelector) selectors.get(key);
System.out.println("哈希環(huán)構(gòu)建完成,詳情如下:");
for (Map.Entry> entry : selector.virtualInvokers.entrySet()) {
System.out.println("key(哈希值)=" + entry.getKey() + ",value(虛擬節(jié)點(diǎn))=" + entry.getValue());
}
}
//調(diào)用ConsistentHashSelector的select方法選擇Invoker
System.out.println("開始調(diào)用ConsistentHashSelector的select方法選擇Invoker");
return selector.select(invocation);
}
private static final class ConsistentHashSelector {
//使用TreeMap存儲(chǔ)Invoker的虛擬節(jié)點(diǎn)
private final TreeMap> virtualInvokers;
//虛擬節(jié)點(diǎn)數(shù)
private final int replicaNumber;
//hashCode
private final int identityHashCode;
//請(qǐng)求中的參數(shù)下標(biāo)。
//需要對(duì)請(qǐng)求中對(duì)應(yīng)下標(biāo)的參數(shù)進(jìn)行哈希計(jì)算
private final int[] argumentIndex;
ConsistentHashSelector(List> invokers, String methodName, int identityHashCode) {
this.virtualInvokers = new TreeMap>();
this.identityHashCode = identityHashCode;
URL url = invokers.get(0).getUrl();
System.out.println("CHS中url為=" + url);
//即使啟動(dòng)多個(gè)invoker,每個(gè)invoker對(duì)應(yīng)的url上的虛擬節(jié)點(diǎn)數(shù)配置的都是一樣的
//這里默認(rèn)是160個(gè)。本文中的示例代碼設(shè)置為4個(gè)。
this.replicaNumber = url.getMethodParameter(methodName, HASH_NODES, 160);
//所有輸出語(yǔ)句都是我加的,CHS是ConsistentHashSelector的縮寫
System.out.println("CHS中url上的【hash.nodes】為=" + replicaNumber);
//獲取參與哈希計(jì)算的參數(shù)下標(biāo)值,默認(rèn)對(duì)第一個(gè)參數(shù)進(jìn)行哈希運(yùn)算
//本文中的示例代碼使用默認(rèn)配置,所以這里的index長(zhǎng)度為1。
String[] index = COMMA_SPLIT_PATTERN.split(url.getMethodParameter(methodName, HASH_ARGUMENTS, "0"));
System.out.println("CHS中url上的【hash.arguments】為=" + Arrays.toString(index));
//for循環(huán),對(duì)argumentIndex進(jìn)行賦值操作。
argumentIndex = new int[index.length];
for (int i = 0; i < index.length; i++) {
argumentIndex[i] = Integer.parseInt(index[i]);
}
System.out.println("CHS中argumentIndex數(shù)組為=" + Arrays.toString(argumentIndex));
//本文中啟動(dòng)了2個(gè)服務(wù)提供者,所以invokers=2
for (Invoker invoker : invokers) {
//獲取每個(gè)invoker的地址
String address = invoker.getUrl().getAddress();
System.out.println("CHS中invoker的地址為=" + address);
for (int i = 0; i < replicaNumber / 4; i++) {
//對(duì)address+i進(jìn)行md5運(yùn)算得到一個(gè)長(zhǎng)度為16的字節(jié)數(shù)組
byte[] digest = md5(address + i);
System.out.println("CHS中對(duì)" + address + i + "進(jìn)行md5計(jì)算");
//對(duì)digest部分字節(jié)進(jìn)行4次hash運(yùn)算得到四個(gè)不同的long型正整數(shù)
for (int h = 0; h < 4; h++) {
//h=0時(shí),取digest中下標(biāo)為0~3的4個(gè)字節(jié)進(jìn)行位運(yùn)算
//h=1時(shí),取digest中下標(biāo)為4~7的4個(gè)字節(jié)進(jìn)行位運(yùn)算
//h=2,h=3時(shí)過程同上
long m = hash(digest, h);
System.out.println("CHS中對(duì)digest進(jìn)行第"+h+"次hash計(jì)算后的值:"+m+",當(dāng)前invoker="+invoker);
//將hash到invoker的映射關(guān)系存儲(chǔ)到virtualInvokers中,
//virtualInvokers需要提供高效的查詢操作,因此選用TreeMap作為存儲(chǔ)結(jié)構(gòu)
virtualInvokers.put(m, invoker);
}
}
}
}
public Invoker select(Invocation invocation) {
String key = toKey(invocation.getArguments());
System.out.println("CHS的select方法根據(jù)argumentIndex取出invocation中參與hash計(jì)算的key="+key);
byte[] digest = md5(key);
//取digest數(shù)組的前四個(gè)字節(jié)進(jìn)行hash運(yùn)算,再將hash值傳給selectForKey方法,
//尋找合適的Invoker
long hash = hash(digest, 0);
System.out.println("CHS的select方法中key=" + key + "經(jīng)過哈希計(jì)算后hash=" + hash);
return selectForKey(hash);
}
//根據(jù)argumentIndex將參數(shù)轉(zhuǎn)化為key。
private String toKey(Object[] args) {
StringBuilder buf = new StringBuilder();
for (int i : argumentIndex) {
if (i >= 0 && i < args.length) {
buf.append(args[i]);
}
}
return buf.toString();
}
private Invoker selectForKey(long hash) {
//到TreeMap中查找第一個(gè)節(jié)點(diǎn)值大于或等于當(dāng)前hash的Invoker
Map.Entry> entry = virtualInvokers.ceilingEntry(hash);
//如果hash大于Invoker在圓環(huán)上最大的位置,此時(shí)entry=null,
//需要將TreeMap的頭節(jié)點(diǎn)賦值給entry
if (entry == null) {
entry = virtualInvokers.firstEntry();
}
System.out.println("CHS的selectForKey方法根據(jù)key="+hash+"選擇出來的invoker="+entry.getValue());
return entry.getValue();
}
private long hash(byte[] digest, int number) {
return (((long) (digest[3 + number * 4] & 0xFF) << 24)
| ((long) (digest[2 + number * 4] & 0xFF) << 16)
| ((long) (digest[1 + number * 4] & 0xFF) << 8)
| (digest[number * 4] & 0xFF))
& 0xFFFFFFFFL;
}
private byte[] md5(String value) {
MessageDigest md5;
try {
md5 = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e.getMessage(), e);
}
md5.reset();
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
md5.update(bytes);
return md5.digest();
}
}
}
改造之后,我們先把程序跑起來,有了輸出就好分析了。
服務(wù)端代碼如下:
其中的端口是需要手動(dòng)修改的,我分別啟動(dòng)服務(wù)在20881和20882端口。
項(xiàng)目中provider.xml配置如下:
consumer.xml配置如下:
然后,啟動(dòng)在20881和20882端口分別啟動(dòng)兩個(gè)服務(wù)端。客戶端消費(fèi)如下:
運(yùn)行結(jié)果輸出如下,可以先看個(gè)大概的輸出,下面會(huì)對(duì)每一部分輸出進(jìn)行逐一的解讀。
好了,用例也跑起來了,日志也有了。接下來開始結(jié)合代碼和日志進(jìn)行方法級(jí)別的分析。
首先是doSelect方法的入口:
從上圖我們知道了,第一次調(diào)用需要對(duì)selectors進(jìn)行put操作,selectors的key是接口中定義的方法,value是ConsistentHashSelector內(nèi)部類。
ConsistentHashSelector通過調(diào)用其構(gòu)造函數(shù)進(jìn)行初始化的。invokers(服務(wù)端)作為參數(shù)傳遞到了構(gòu)造函數(shù)中,構(gòu)造函數(shù)里面的邏輯,就是把服務(wù)端映射到哈希環(huán)上的過程,請(qǐng)看下圖,結(jié)合代碼,仔細(xì)分析輸出數(shù)據(jù):
從上圖可以看出,當(dāng)ConsistentHashSelector的構(gòu)造方法調(diào)用完成后,8個(gè)虛擬節(jié)點(diǎn)在哈希環(huán)上已經(jīng)映射完成。兩臺(tái)服務(wù)器,每一臺(tái)4個(gè)虛擬節(jié)點(diǎn)組成了這8個(gè)虛擬節(jié)點(diǎn)。
doSelect方法繼續(xù)執(zhí)行,并打印出每個(gè)虛擬節(jié)點(diǎn)的哈希值和對(duì)應(yīng)的服務(wù)端,請(qǐng)仔細(xì)品讀下圖:
說明一下:上面圖中的哈希環(huán)是沒有考慮比例的,僅僅是展現(xiàn)了兩個(gè)服務(wù)器在哈希環(huán)上的相對(duì)位置。而且為了演示說明方便,僅僅只有8個(gè)節(jié)點(diǎn)。假設(shè)我們有4臺(tái)服務(wù)器,每臺(tái)服務(wù)器的虛擬節(jié)點(diǎn)是默認(rèn)值(160),這個(gè)情況下哈希環(huán)上一共有160*4=640個(gè)節(jié)點(diǎn)。
哈希環(huán)映射完成后,接下來的邏輯是把這次請(qǐng)求經(jīng)過哈希計(jì)算后,映射到哈希環(huán)上,并順時(shí)針方向?qū)ふ矣龅降牡谝粋€(gè)節(jié)點(diǎn),讓該節(jié)點(diǎn)處理該請(qǐng)求:
還記得地址為468e8565的A服務(wù)器是什么端口嗎?前面的圖片中有哦,該服務(wù)對(duì)應(yīng)的端口是20882。
最后我們看看輸出結(jié)果:
和我們預(yù)期的一致。整個(gè)調(diào)用就算是完成了。
再對(duì)兩個(gè)方法進(jìn)行一個(gè)補(bǔ)充說明。
第一個(gè)方法是selectForKey,這個(gè)方法里面邏輯如下圖所示:
虛擬節(jié)點(diǎn)都存儲(chǔ)在TreeMap中。順時(shí)針查詢的邏輯由TreeMap保證。看一下下面的Demo你就明白了。
第二個(gè)方法是hash方法,其中的& 0xFFFFFFFFL的目的如下:
&是位運(yùn)算符,而0xFFFFFFFFL轉(zhuǎn)換為四字節(jié)表現(xiàn)后,其低32位全是1,所以保證了哈希環(huán)的范圍是[0,Integer.MAX_VALUE]:
所以這里我們可以改造這個(gè)哈希環(huán)的范圍,假設(shè)我們改為100000。十進(jìn)制的100000對(duì)于的16進(jìn)制為186A0。所以我們改造后的哈希算法為:
再次調(diào)用后可以看到,計(jì)算后的哈希值都在10萬(wàn)以內(nèi)。但是分布極不均勻,說明修改數(shù)據(jù)后這個(gè)哈希算法不是一個(gè)優(yōu)秀的哈希算法:
以上,就是對(duì)一致性哈希算法在Dubbo中的實(shí)現(xiàn)的解讀。需要特殊說明一下的是,和上周分享的最小活躍數(shù)負(fù)載均衡算法不同,一致性哈希負(fù)載均衡策略和權(quán)重沒有任何關(guān)系。
我又發(fā)現(xiàn)了一個(gè)BUG
在上篇文章中,我介紹了Dubbo 2.6.5版本之前,最小活躍數(shù)算法的兩個(gè)bug。很不幸,這次我又發(fā)現(xiàn)了Dubbo 2.7.4.1版本,一致性哈希負(fù)載均衡策略的一個(gè)bug,我提交了issue,截止目前還未解決。
我在這里詳細(xì)說一下這個(gè)Bug現(xiàn)象、原因和我的解決方案。
現(xiàn)象如下,我們調(diào)用三次服務(wù)端:
輸出日志如下(有部分刪減):
可以看到,在三次調(diào)用的過程中并沒有發(fā)生服務(wù)的上下線操作,但是每一次調(diào)用都重新進(jìn)行了哈希環(huán)的映射。而我們預(yù)期的結(jié)果是應(yīng)該只有在第一次調(diào)用的時(shí)候進(jìn)行哈希環(huán)的映射,如果沒有服務(wù)上下線的操作,后續(xù)請(qǐng)求根據(jù)已經(jīng)映射好的哈希環(huán)進(jìn)行處理。
上面輸出的原因是由于每次調(diào)用的invokers的identityHashCode發(fā)生了變化:
我們看一下三次調(diào)用invokers的情況:
經(jīng)過debug我們可以看出因?yàn)槊看握{(diào)用的invokers地址值不是同一個(gè),所以System.identityHashCode(invokers)方法返回的值都不一樣。
接下來的問題就是為什么每次調(diào)用的invokers地址值都不一樣呢?
經(jīng)過Debug之后,可以找到這個(gè)地方:
org.apache.dubbo.rpc.cluster.RouterChain#route
問題就出在這個(gè)TagRouter中:
org.apache.dubbo.rpc.cluster.router.tag.TagRouter#filterInvoker
所以,在TagRouter中的stream操作,改變了invokers,導(dǎo)致每次調(diào)用時(shí)其
System.identityHashCode(invokers)返回的值不一樣。所以每次調(diào)用都會(huì)進(jìn)行哈希環(huán)的映射操作,在服務(wù)節(jié)點(diǎn)多,虛擬節(jié)點(diǎn)多的情況下會(huì)有一定的性能問題。
到這一步,問題又發(fā)生了變化。這個(gè)TagRouter怎么來的呢?
如果了解Dubbo 2.7.x版本新特性的朋友可能知道,標(biāo)簽路由是Dubbo2.7引入的新功能。
通過加載下面的配置加載了RouterFactrory:
META-INFdubbointernalorg.apache.dubbo.rpc.cluster.RouterFactory(Dubbo 2.7.0版本之前)
META-INFdubbointernalcom.alibaba.dubbo.rpc.cluster.RouterFactory(Dubbo 2.7.0之前)
下面是Dubbo 2.6.7(2.6.x的最后一個(gè)版本)和Dubbo 2.7.0版本該文件的對(duì)比:
可以看到確實(shí)是在Dubbo2.7.0之后引入了TagRouter。
至此,Dubbo 2.7.0版本之后,一致性哈希負(fù)載均衡算法的Bug的來龍去脈也介紹清楚了。目前該Bug還未解決。
解決方案是什么呢?特別簡(jiǎn)單,把獲取identityHashCode的方法從System.identityHashCode(invokers)修改為invokers.hashCode()即可。
此方案是我提的issue里面的評(píng)論,這里System.identityHashCode和 hashCode之間的聯(lián)系和區(qū)別就不進(jìn)行展開講述了,不清楚的大家可以自行了解一下。
改完之后,我們?cè)倏纯催\(yùn)行效果:
可以看到第二次調(diào)用的時(shí)候并沒有進(jìn)行哈希環(huán)的映射操作,而是直接取到了值,進(jìn)行調(diào)用。
加入節(jié)點(diǎn),畫圖分析
最后,我再分析一種情況。在A、B、C三個(gè)服務(wù)器(20881、20882、20883端口)都在正常運(yùn)行,哈希映射已經(jīng)完成的情況下,我們?cè)賳?dòng)一個(gè)D節(jié)點(diǎn)(20884端口),這時(shí)的日志輸出和對(duì)應(yīng)的哈希環(huán)變化情況如下:
根據(jù)日志作圖如下:
根據(jù)輸出日志和上圖再加上源碼,你再細(xì)細(xì)回味一下。我個(gè)人覺得還是講的非常詳細(xì)了,可能是東半球講一致性哈希算法在Dubbo中的實(shí)現(xiàn)最詳細(xì)的文章了。
一致性哈希的應(yīng)用場(chǎng)景
當(dāng)大家談到一致性哈希算法的時(shí)候,首先的第一印象應(yīng)該是在緩存場(chǎng)景下的使用,因?yàn)樵谝粋€(gè)優(yōu)秀的哈希算法加持下,其上下線節(jié)點(diǎn)對(duì)整體數(shù)據(jù)的影響(遷移)都是比較友好的。
但是想一下為什么Dubbo在負(fù)載均衡策略里面提供了基于一致性哈希的負(fù)載均衡策略?它的實(shí)際使用場(chǎng)景是什么?
我最開始也想不明白。我想的是在Dubbo的場(chǎng)景下,假設(shè)需求是想要一個(gè)用戶的請(qǐng)求一直讓一臺(tái)服務(wù)器處理,那我們可以采用一致性哈希負(fù)載均衡策略,把用戶號(hào)進(jìn)行哈希計(jì)算,可以實(shí)現(xiàn)這樣的需求。但是這樣的需求未免有點(diǎn)太牽強(qiáng)了,適用場(chǎng)景略小。
直到有天晚上,我睡覺之前,電光火石之間突然想到了一個(gè)稍微適用的場(chǎng)景了。
如果需求是需要保證某一類請(qǐng)求必須順序處理呢?
如果你用其他負(fù)載均衡策略,請(qǐng)求分發(fā)到了不同的機(jī)器上去,就很難保證請(qǐng)求的順序處理了。比如A,B請(qǐng)求要求順序處理,現(xiàn)在A請(qǐng)求先發(fā)送,被負(fù)載到了A服務(wù)器上,B請(qǐng)求后發(fā)送,被負(fù)載到了B服務(wù)器上。而B服務(wù)器由于性能好或者當(dāng)前沒有其他請(qǐng)求或者其他原因極有可能在A服務(wù)器還在處理A請(qǐng)求之前就把B請(qǐng)求處理完成了。這樣不符合我們的要求。
這時(shí),一致性哈希負(fù)載均衡策略就上場(chǎng)了,它幫我們保證了某一類請(qǐng)求都發(fā)送到固定的機(jī)器上去執(zhí)行。比如把同一個(gè)用戶的請(qǐng)求發(fā)送到同一臺(tái)機(jī)器上去執(zhí)行,就意味著把某一類請(qǐng)求發(fā)送到同一臺(tái)機(jī)器上去執(zhí)行。所以我們只需要在該機(jī)器上運(yùn)行的程序中保證順序執(zhí)行就行了,比如你加一個(gè)隊(duì)列。
一致性哈希算法+隊(duì)列,可以實(shí)現(xiàn)順序處理的需求。
最后說一句
這是Dubbo負(fù)載均衡算法的第二篇文章,上周寫了一篇《一文講透Dubbo負(fù)載均衡之最小活躍數(shù)算法》,也是非常詳細(xì),可以看看哦。
才疏學(xué)淺,難免會(huì)有紕漏,如果你發(fā)現(xiàn)了錯(cuò)誤的地方,還請(qǐng)你留言給我指出來,我對(duì)其加以修改。
如果你覺得文章還不錯(cuò),你的轉(zhuǎn)發(fā)、分享、贊賞、點(diǎn)贊、留言就是對(duì)我最大的鼓勵(lì)。
感謝您的閱讀,我的訂閱號(hào)里全是原創(chuàng),十分歡迎并感謝您的關(guān)注。
以上。
原創(chuàng)不易,歡迎轉(zhuǎn)發(fā),求個(gè)關(guān)注,賞個(gè)"在看"吧。
總結(jié)
以上是生活随笔為你收集整理的java 一致性hash算法 均衡分发_Dubbo一致性哈希负载均衡的源码和Bug,了解一下?...的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 寄存器(CPU工作原理)04 - 零基础
- 下一篇: 解析并验证IE6及之前版本的'!impo