一致性Hash算法背景
一致性哈希算法在1997年由麻省理工學院的Karger等人在解決分布式Cache中提出的,設計目標是為了解決因特網中的熱點(Hot spot)問題,初衷和CARP十分類似。一致性哈希修正了CARP使用的簡單哈希算法帶來的問題,使得DHT可以在P2P環(huán)境中真正得到應用。
原理
基本概念
一致性哈希算法(Consistent Hashing)最早在論文《Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web》中被提出。簡單來說,一致性哈希將整個哈希值空間組織成一個虛擬的圓環(huán),如假設某哈希函數H的值空間為0-2^32-1(即哈希值是一個32位無符號整形),整個哈希空間環(huán)如下:
在集群服務器確定以后,將各個服務器使用Hash函數進行一個哈希計算,哈希計算可以選擇服務器的ip地址或者主機名等關鍵字進行哈希,這樣就完成了節(jié)點在hash環(huán)上的位置分配,假設集群有四臺服務器,最終效果如下:
接下來,每臺服務器在hash環(huán)上的位置分配完成后,在客戶端對緩存key進行同樣函數的hash運算,得出hash值,同樣得到環(huán)上的一個位置,從這個位置順時針找到最近的一個服務器節(jié)點,比如遍歷所有節(jié)點位置和key位置差值取最小值,這樣就完成了路由。
例如我們有Object A、Object B、Object C、Object D四個數據對象,經過哈希計算后,在環(huán)空間上的位置如下:
根據一致性哈希算法,數據A會被定為到Node A上,B被定為到Node B上,C被定為到Node C上,D被定為到Node D上。
容錯性和拓展性
上篇博客提到了簡單哈希的缺點,當服務器宕機、擴容、縮容時,容錯性和擴展性差,那么一致性hash的容錯性和拓展性如何呢?
接著上圖討論,現(xiàn)假設Node C不幸宕機,可以看到此時對象A、B、D不會受到影響,只有C對象被重定位到Node D。一般的,在一致性哈希算法中,如果一臺服務器不可用,則受影響的數據僅僅是此服務器到其環(huán)空間中前一臺服務器(即沿著逆時針方向行走遇到的第一臺服務器)之間數據,其它不會受到影響。
下面考慮另外一種情況,如果在系統(tǒng)中增加一臺服務器Node X,如下圖所示:
此時對象Object A、B、D不受影響,只有對象C需要重定位到新的Node X 。一般的,在一致性哈希算法中,如果增加一臺服務器,則受影響的數據僅僅是新服務器到其環(huán)空間中前一臺服務器(即沿著逆時針方向行走遇到的第一臺服務器)之間數據,其它數據也不會受到影響。
綜上所述,一致性哈希算法對于節(jié)點的增減都只需重定位環(huán)空間中的一小部分數據,具有較好的容錯性和可擴展性。
數據傾斜問題
一致性hash算法的缺陷是它無法控制節(jié)點分布的均勻性,因為hash的結果并不一定均勻分布在環(huán)上對稱的位置,極端的情況,舉個例子,現(xiàn)在有A、B、C三個服務器節(jié)點,hash的結果緊挨在一起,那么根據一致性hash,一定是極少量的key會訪問到從0點開始順時針數到的第二、三個節(jié)點,絕大部分key都會訪問到順時針數到的第一個節(jié)點。如下圖:
hash(key)運算結果落在A和B之間的請求會訪問到B服務器,hash(key)運算結果落在B和C之間的請求會訪問到C服務器,而hash(key)落在A和C之間的請求會訪問到A服務器。這樣A的壓力不言而喻。
解決方案
虛擬節(jié)點
為了解決這種數據傾斜問題,一致性哈希算法引入了虛擬節(jié)點機制
簡單來說就是將一臺服務器加上編號尾綴進行哈希,每臺服務器就會有多個結果
同時數據定位算法不變,只是多了一步虛擬節(jié)點到實際節(jié)點的映射,例如定位到“Node A#1”、“Node A#2”、“Node A#3”三個虛擬節(jié)點的數據均定位到Node A上。這樣就解決了服務節(jié)點少時數據傾斜的問題。在實際應用中,通常將虛擬節(jié)點數設置為32甚至更大,因此即使很少的服務節(jié)點也能做到相對均勻的數據分布。
良好的分布式cahce系統(tǒng)中的一致性hash算法應該滿足以下幾個方面:
平衡性(Balance)
平衡性是指哈希的結果能夠盡可能分布到所有的緩沖中去,這樣可以使得所有的緩沖空間都得到利用。很多哈希算法都能夠滿足這一條件。
單調性(Monotonicity)
單調性是指如果已經有一些內容通過哈希分派到了相應的緩沖中,又有新的緩沖區(qū)加入到系統(tǒng)中,那么哈希的結果應能夠保證原有已分配的內容可以被映射到新的緩沖區(qū)中去,而不會被映射到舊的緩沖集合中的其他緩沖區(qū)。簡單的哈希算法往往不能滿足單調性的要求,如最簡單的線性哈希:x = (ax + b) mod (P),在上式中,P表示全部緩沖的大小。不難看出,當緩沖大小發(fā)生變化時(從P1到P2),原來所有的哈希結果均會發(fā)生變化,從而不滿足單調性的要求。哈希結果的變化意味著當緩沖空間發(fā)生變化時,所有的映射關系需要在系統(tǒng)內全部更新。而在P2P系統(tǒng)內,緩沖的變化等價于Peer加入或退出系統(tǒng),這一情況在P2P系統(tǒng)中會頻繁發(fā)生,因此會帶來極大計算和傳輸負荷。單調性就是要求哈希算法能夠應對這種情況。
分散性(Spread)
在分布式環(huán)境中,終端有可能看不到所有的緩沖,而是只能看到其中的一部分。當終端希望通過哈希過程將內容映射到緩沖上時,由于不同終端所見的緩沖范圍有可能不同,從而導致哈希的結果不一致,最終的結果是相同的內容被不同的終端映射到不同的緩沖區(qū)中。這種情況顯然是應該避免的,因為它導致相同內容被存儲到不同緩沖中去,降低了系統(tǒng)存儲的效率。分散性的定義就是上述情況發(fā)生的嚴重程度。好的哈希算法應能夠盡量避免不一致的情況發(fā)生,也就是盡量降低分散性。
負載(Load)
負載問題實際上是從另一個角度看待分散性問題。既然不同的終端可能將相同的內容映射到不同的緩沖區(qū)中,那么對于一個特定的緩沖區(qū)而言,也可能被不同的用戶映射為不同的內容。與分散性一樣,這種情況也是應當避免的,因此好的哈希算法應能夠盡量降低緩沖的負荷。
平滑性(Smoothness)
平滑性是指緩存服務器的數目平滑改變和緩存對象的平滑改變是一致的。
一致性hash代碼實現(xiàn):
package com.ctrip.dcs;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
public class ConsistencyHash {
? ? private TreeMap<Long,Object> nodes = null;
? ? //真實服務器節(jié)點信息
? ? private List<Object> shards = new ArrayList();
? ? //設置虛擬節(jié)點數目
? ? private int VIRTUAL_NUM = 4;
? ? /**
? ? * 初始化一致環(huán)
? ? */
? ? public void init() {
? ? ? ? shards.add("192.168.0.0-服務器0");
? ? ? ? shards.add("192.168.0.1-服務器1");
? ? ? ? shards.add("192.168.0.2-服務器2");
? ? ? ? shards.add("192.168.0.3-服務器3");
? ? ? ? shards.add("192.168.0.4-服務器4");
? ? ? ? nodes = new TreeMap<>();
? ? ? ? for(int i=0; i<shards.size(); i++) {
? ? ? ? ? ? Object shardInfo = shards.get(i);
? ? ? ? ? ? for(int j=0; j<VIRTUAL_NUM; j++) {
? ? ? ? ? ? ? ? nodes.put(hash(computeMd5("SHARD-" + i + "-NODE-" + j),j), shardInfo);
? ? ? ? ? ? }
? ? ? ? }
? ? }
? ? /**
? ? * 根據key的hash值取得服務器節(jié)點信息
? ? * @param hash
? ? * @return
? ? */
? ? public Object getShardInfo(long hash) {
? ? ? ? Long key = hash;
? ? ? ? SortedMap<Long, Object> tailMap=nodes.tailMap(key);
? ? ? ? if(tailMap.isEmpty()) {
? ? ? ? ? ? key = nodes.firstKey();
? ? ? ? } else {
? ? ? ? ? ? key = tailMap.firstKey();
? ? ? ? }
? ? ? ? return nodes.get(key);
? ? }
? ? /**
? ? * 打印圓環(huán)節(jié)點數據
? ? */
? ? public void printMap() {
? ? ? ? System.out.println(nodes);
? ? }
? ? /**
? ? * 根據2^32把節(jié)點分布到圓環(huán)上面。
? ? * @param digest
? ? * @param nTime
? ? * @return
? ? */
? ? public long hash(byte[] digest, int nTime) {
? ? ? ? long rv = ((long) (digest[3+nTime*4] & 0xFF) << 24)
? ? ? ? ? ? ? ? | ((long) (digest[2+nTime*4] & 0xFF) << 16)
? ? ? ? ? ? ? ? | ((long) (digest[1+nTime*4] & 0xFF) << 8)
? ? ? ? ? ? ? ? | (digest[0+nTime*4] & 0xFF);
? ? ? ? return rv & 0xffffffffL; /* Truncate to 32-bits */
? ? }
? ? /**
? ? * Get the md5 of the given key.
? ? * 計算MD5值
? ? */
? ? public byte[] computeMd5(String k) {
? ? ? ? MessageDigest md5;
? ? ? ? try {
? ? ? ? ? ? md5 = MessageDigest.getInstance("MD5");
? ? ? ? } catch (NoSuchAlgorithmException e) {
? ? ? ? ? ? throw new RuntimeException("MD5 not supported", e);
? ? ? ? }
? ? ? ? md5.reset();
? ? ? ? byte[] keyBytes = null;
? ? ? ? try {
? ? ? ? ? ? keyBytes = k.getBytes("UTF-8");
? ? ? ? } catch (UnsupportedEncodingException e) {
? ? ? ? ? ? throw new RuntimeException("Unknown string :" + k, e);
? ? ? ? }
? ? ? ? md5.update(keyBytes);
? ? ? ? return md5.digest();
? ? }
? ? public static void main(String[] args) {
? ? ? ? Random ran = new Random();
? ? ? ? ConsistencyHash hash = new ConsistencyHash();
? ? ? ? hash.init();
? ? ? ? hash.printMap();
? ? ? ? //循環(huán)50次,是為了取50個數來測試效果,當然也可以用其他任何的數據來測試
? ? ? ? for(int i=0; i<50; i++) {
? ? ? ? ? ? System.out.println(hash.getShardInfo(hash.hash(hash.computeMd5(String.valueOf(i)),ran.nextInt(hash.VIRTUAL_NUM))));
? ? ? ? }
? ? }
}
下篇博客介紹一下redis cluster的hash slot算法,介紹一下redis分片技術。