在設計一個分布式系統的架構時,為了提高系統的負載能力,需要把不同的數據分發到不同的服務節點上。因此這里就需要一種分發的機制,其實就是一種算法,來實現這種功能。這里我們就用到了Consistent Hashing算法。
在正式介紹Consistent Hashing算法之前我們先來看一個簡單的hash算法,就是用取余數的方式來選擇節點。具體的步驟如下:
一、根據集群服務的節點數創建一個哈希表
二、然后根據鍵名計算出鍵名的整數哈希值,用該哈希值對節點數取余。
三、最后根據余數在哈希表中取出節點。
假設在一個集群中有n個服務器節點,對這些節點編號為0,1,2,…,n-1 。然后,將一條數據(key,value)存儲到服務器中。這時我們該如何來選擇服務器節點呢?根據上面的步驟我們需要對key計算hash值,然后再對n(節點個數)取余數。最后得到的值就是我們所要的節點。用一個公式來表示:num = hash(key) % n。hash()是一個計算hash值的函數,這里對hash()函數還是有一定的要求的,如果我們使用的hash()函數很優化的話,那計算出的num是均勻分布在0,1,2,…,n-1之間的,從而使盡可能多的服務器節點都能被使用。而不是所有的數據都集中在一個或者幾個服務器節點上面。具體的hash()實現不是本章討論的重點。
這種單純的取余數的方式雖然簡單,但是如果將其應用到實際生產系統中會出現很大的問題。假設我們有23個服務節點。那么根據上面的方式,一個key映射到每個節點的概率都是1/23。假設增加了一個服務節點的話,之前的hash(key) % n 就會變成hash(key) % (n+1) 。也就是說對於key來說有23/24的概率會被重新分配到新的節點。相反只會有1/24的概率會被分配到原節點。同樣,當你減少一個節點的時候,有22/23的概率會被重新分配到新的節點上去。
鑒於這種情況,就需要有一種方式來避免或者減少在橫向擴展的時候命中率降低的情況的發生。這種方法就是我們將要介紹的Consistent Hashing算法,我們稱其為一致性hash算法。
為了了解Consistent Hashing算法是如何工作的,我們假設單位區間 [ 0 , 1 ) 依順時針的方向均勻的分布在圓上。
假使有n個服務節點,為每個服務節點編號為0, 1, 2, …, n-1。然后我們需要有一個hash()函數來對服務節點計算hash值。如果選用的hash()函數返回值的取值范圍為[ 0, R ),那么使用公式 v = hash(n) / R。這樣得到的v會分布在單位區間[ 0, 1 )內。所以,通過這個方式就可以使我們的服務節點分布在圓上面。
當然,以單位區間[ 0, 1 ) 畫圓只是一種方式,還有很多其他的畫圓方式,比如說:以區間[ 0, 2^32-1 ) 為圓,然后使用hash()函數對服務節點計算hash()值。選用的hash()函數產生的值當然也必須在0 – (2^32-1) 范圍之內了。
這里我們還是以[ 0, 1 )為例來介紹。
我們以3個服務節點為例來進行說明
這三個節點隨機的分布在這個圓上面。現在假設我們有一條數據(key,value)需要存儲,接下來要做的就是將這條數據通過同樣的方法映射到圓上面。
然后從key所坐落在圓上的位置開始順時針查找服務節點所在的位置,找到的第一個服務節點即是要存儲的節點。所以說這條數據將要存儲在服務節點1上。
同理,當有其它的(key,value)對需要存儲的時候,也是按照上面的方式進行服務節點的選擇。
現在我們來看該方法對於我們剛開始提到的橫向擴展的問題是否能夠很好的解決呢?
假設我們需要增加一個服務節點3
通過上圖,我們可以看出,只有key1會改變其存儲服務節點。對於大部分的數據來說依然會找到原先的節點。因此,對於n個服務節點的集群來說,當有服務節點增加的時候一條數據只有1/(n+1)的概率會改變其存儲的服務節點。這個概率遠比取余數法所得的概率要小的多。同樣,減少一個服務節點和增加服務節點的原理是相同的,其每條數據重新選擇服務節點的概率為1/(n-1)。同樣這個概率也是很小的。
下面就用一段php代碼來簡單的實現這個過程
$nodes = array('192.168.5.201','192.168.5.102','192.168.5.111'); $keys = array('onmpw', 'jiyi', 'onmpw_key', 'jiyi_key', 'www','www_key','key1'); $buckets = array(); //節點的hash字典 $maps = array(); //存儲key和節點之間的映射關系 /** * 生成節點字典 —— 使節點分布在單位區間[0,1)的圓上 */ foreach( $nodes as $key) { $crc = crc32($key)/pow(2,32); // CRC値 $buckets[] = array('index'=>$crc,'node'=>$key); } /* * 根據索引進行排序 */ sort($buckets); /* * 對每個key進行hash計算,找到其在圓上的位置 * 然后在該位置開始依順時針方向找到第一個服務節點 */ foreach($keys as $key){ $flag = false; //表示是否有找到服務節點 $crc = crc32($key)/pow(2,32);//計算key的hash值 for($i = 0; $i < count($buckets); $i++){ if($buckets[$i]['index'] > $crc){ /* * 因為已經對buckets進行了排序 * 所以第一個index大於key的hash值的節點即是要找的節點 */ $maps[$key] = $buckets[$i]['node']; $flag = true; break; } } if(!$flag){ //沒有找到,則使用buckets中的第一個服務節點 $maps[$key] = $buckets[0]['node']; } } foreach($maps as $key=>$val){ echo $key.'=>'.$val,"<br />"; }
這段代碼運行的結果如下
onmpw=>192.168.5.102 jiyi=>192.168.5.201 onmpw_key=>192.168.5.201 jiyi_key=>192.168.5.102 www=>192.168.5.201 www_key=>192.168.5.201 key1=>192.168.5.111
然后我們添加一個服務節點,修改代碼如下
$nodes = array('192.168.5.201','192.168.5.102','192.168.5.111','192.168.5.11');
其它代碼不變,繼續運行結果如下
onmpw=>192.168.5.102 jiyi=>192.168.5.201 onmpw_key=>192.168.5.11 jiyi_key=>192.168.5.102 www=>192.168.5.201 www_key=>192.168.5.201 key1=>192.168.5.111
我們看到,只有onmpw_key重新選擇了服務節點。其它的都是原先的節點。
到這里我們看到,較之於取余數法命中的概率提高了相當多了。那這里是不是就解決了我們前面遇到的問題了呢?
其實,還沒有。因為這些值的分布畢竟不是那么的均勻。在系統中有可能這些服務節點分布非常的集中,這可能導致的情況就是所有的key都映射到其中的一個或者幾個節點上面,剩下的服務節點都沒有被用到。雖然這並不是什么很嚴重的問題,那為什么我們要浪費哪怕只是一台服務器呢。
我們看,這種情況就造成了數據集中在一個服務節點上面,造成了其它服務節點的浪費。那如何解決這個問題呢?人們就又想出了一種新的方式:就是為每個節點建立虛擬的節點。什么意思呢?就是說對於節點j,為其建立m個復制品。這m個復制出來的節點都通過hash()函數得出不同的hash值,但是每個虛擬節點保存的節點信息都是節點j的。然后這些虛擬節點都會隨機的分布在圓上面。舉例子來說,我們有兩個服務節點。並且為每個節點都復制出三個虛擬節點。這些節點(包括虛擬節點都隨機的分布在圓上面)
這樣看起來服務節點在圓上分布還是比較均勻的了。其實,總結起來就是在上面的那種方式上稍微做了一下改進——給每個節點復制一些虛擬節點。
因此,我們的代碼也不需要做過多的修改。為了看代碼比較直觀,我在這里還是將整段代碼羅列在這。
$nodes = array('192.168.5.201','192.168.5.102','192.168.5.111'); $keys = array('onmpw', 'jiyi', 'onmpw_key', 'jiyi_key', 'www','www_key','key1'); //添加的變量 修改的地方 $replicas = 160; //每個節點的復制的個數 $buckets = array(); //節點的hash字典 $maps = array(); //存儲key和節點之間的映射關系 /** * 生成節點字典 —— 使節點分布在單位區間[0,1)的圓上 */ foreach( $nodes as $key) { //修改的地方 for($i=1;$i<=$replicas;$i++){ $crc = crc32($key.'.'.$i)/pow(2,32); // CRC値 $buckets[] = array('index'=>$crc,'node'=>$key); } } /* * 根據索引進行排序 */ sort($buckets); /* * 對每個key進行hash計算,找到其在圓上的位置 * 然后在該位置開始依順時針方向找到第一個服務節點 */ foreach($keys as $key){ $flag = false; //表示是否有找到服務節點 $crc = crc32($key)/pow(2,32);//計算key的hash值 for($i = 0; $i < count($buckets); $i++){ if($buckets[$i]['index'] > $crc){ /* * 因為已經對buckets進行了排序 * 所以第一個index大於key的hash值的節點即是要找的節點 */ $maps[$key] = $buckets[$i]['node']; $flag = true; break; } } if(!$flag){ //沒有找到,則使用buckets中的第一個服務節點 $maps[$key] = $buckets[0]['node']; } } foreach($maps as $key=>$val){ echo $key.'=>'.$val,"<br />"; }
有改動的地方在代碼里已經標注出來了。可以看到,修改的地方還是比較少的。
至此,相信大家對Consistent Hashing應該有了一個比較清晰的認識。hash算法的用處還是很廣泛的,比如在memcache集群,nginx負載等方面都有用到。
我們在 帶你深入了解Memcached中的分布式思想 這篇文章中用實際的案例介紹了一致性hash算法在Memcache中的應用。這里我們所有的代碼都是用PHP實現的,如果對PHP不熟悉的有興趣的可以參考以下教程,PHP教程。
所以,了解hash算法對於我們是有很大的幫助的。
上述算法過程的表述有不清楚或者不合適的地方,歡迎大家不吝賜教。