做者:不洗碗工做室 - Markluxhtml
出處:Marklux's Pubjava
版權歸做者全部,轉載請註明出處nginx
一致性Hash是一種特殊的Hash算法,因爲其均衡性、持久性的映射特色,被普遍的應用於負載均衡領域,如nginx和memcached都採用了一致性Hash來做爲集羣負載均衡的方案。git
本文將介紹一致性Hash的基本思路,並討論其在分佈式緩存集羣負載均衡中的應用。同時也會進行相應的代碼測試來驗證其算法特性,並給出和其餘負載均衡方案的一些對比。github
在瞭解一致性Hash算法以前,先來討論一下Hash自己的特色。普通的Hash函數最大的做用是散列,或者說是將一系列在形式上具備類似性質的數據,打散成隨機的、均勻分佈的數據。算法
好比,對字符串abc
和abcd
分別進行md5計算,獲得的結果以下:數組
能夠看到,兩個在形式上很是相近的數據通過md5散列後,變成了徹底隨機的字符串。負載均衡正是利用這一特性,對於大量隨機的請求或調用,經過必定形式的Hash將他們均勻的散列,從而實現壓力的平均化。(固然,並非只要使用了Hash就必定可以得到均勻的散列,後面會分析這一點。)緩存
舉個例子,若是咱們給每一個請求生成一個Key,只要使用一個很是簡單的Hash算法Group = Key % N
來實現請求的負載均衡,以下:bash
(若是將Key做爲緩存的Key,對應的Group儲存該Key的Value,就能夠實現一個分佈式的緩存系統,後文的具體例子都將基於這個場景)服務器
不難發現,這樣的Hash只要集羣的數量N發生變化,以前的全部Hash映射就會所有失效。若是集羣中的每一個機器提供的服務沒有差異,倒不會產生什麼影響,但對於分佈式緩存這樣的系統而言,映射所有失效就意味着以前的緩存所有失效,後果將會是災難性的。
一致性Hash經過構建環狀的Hash空間代替線性Hash空間的方法解決了這個問題,以下圖:
整個Hash空間被構建成一個首尾相接的環,使用一致性Hash時須要進行兩次映射。
第一次,給每一個節點(集羣)計算Hash,而後記錄它們的Hash值,這就是它們在環上的位置。
第二次,給每一個Key計算Hash,而後沿着順時針的方向找到環上的第一個節點,就是該Key儲存對應的集羣。
分析一下節點增長和刪除時對負載均衡的影響,以下圖:
能夠看到,當節點被刪除時,其他節點在環上的映射不會發生改變,只是原來打在對應節點上的Key如今會轉移到順時針方向的下一個節點上去。增長一個節點也是一樣的,最終都只有少部分的Key發生了失效。不過發生節點變更後,總體系統的壓力已經不是均衡的了,下文中提到的方法將會解決這個問題。
最基本的一致性Hash算法直接應用於負載均衡系統,效果仍然是不理想的,存在諸多問題,下面就對這些問題進行逐個分析並尋求更好的解決方案。
若是節點的數量不多,而hash環空間很大(通常是 0 ~ 2^32),直接進行一致性hash上去,大部分狀況下節點在環上的位置會很不均勻,擠在某個很小的區域。最終對分佈式緩存形成的影響就是,集羣的每一個實例上儲存的緩存數據量不一致,會發生嚴重的數據傾斜。
若是每一個節點在環上只有一個節點,那麼能夠想象,當某一集羣從環中消失時,它本來所負責的任務將所有交由順時針方向的下一個集羣處理。例如,當group0退出時,它本來所負責的緩存將所有交給group1處理。這就意味着group1的訪問壓力會瞬間增大。設想一下,若是group1由於壓力過大而崩潰,那麼更大的壓力又會向group2壓過去,最終服務壓力就像滾雪球同樣越滾越大,最終致使雪崩。
解決上述兩個問題最好的辦法就是擴展整個環上的節點數量,所以咱們引入了虛擬節點的概念。一個實際節點將會映射多個虛擬節點,這樣Hash環上的空間分割就會變得均勻。
同時,引入虛擬節點還會使得節點在Hash環上的順序隨機化,這意味着當一個真實節點失效退出後,它原來所承載的壓力將會均勻地分散到其餘節點上去。
以下圖:
如今咱們嘗試編寫一些測試代碼,來看看一致性hash的實際效果是否符合咱們預期。
首先咱們須要一個可以對輸入進行均勻散列的Hash算法,可供選擇的有不少,memcached官方使用了基於md5的KETAMA
算法,但這裏處於計算效率的考慮,使用了FNV1_32_HASH
算法,以下:
public class HashUtil {
/** * 計算Hash值, 使用FNV1_32_HASH算法 * @param str * @return */
public static int getHash(String str) {
final int p = 16777619;
int hash = (int)2166136261L;
for (int i = 0; i < str.length(); i++) {
hash =( hash ^ str.charAt(i) ) * p;
}
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
if (hash < 0) {
hash = Math.abs(hash);
}
return hash;
}
}
複製代碼
實際使用時能夠根據需求調整。
接着須要使用一種數據結構來保存hash環,能夠採用的方案有不少種,最簡單的是採用數組或鏈表。但這樣查找的時候須要進行排序,若是節點數量多,速度就可能變得很慢。
針對集羣負載均衡狀態讀多寫少的狀態,很容易聯想到使用二叉平衡樹的結構去儲存,實際上可使用TreeMap
(內部實現是紅黑樹)來做爲Hash環的儲存結構。
先編寫一個最簡單的,無虛擬節點的Hash環測試:
public class ConsistentHashingWithoutVirtualNode {
/** * 集羣地址列表 */
private static String[] groups = {
"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
"192.168.0.3:111", "192.168.0.4:111"
};
/** * 用於保存Hash環上的節點 */
private static SortedMap<Integer, String> sortedMap = new TreeMap<>();
/** * 初始化,將全部的服務器加入Hash環中 */
static {
// 使用紅黑樹實現,插入效率比較差,可是查找效率極高
for (String group : groups) {
int hash = HashUtil.getHash(group);
System.out.println("[" + group + "] launched @ " + hash);
sortedMap.put(hash, group);
}
}
/** * 計算對應的widget加載在哪一個group上 * * @param widgetKey * @return */
private static String getServer(String widgetKey) {
int hash = HashUtil.getHash(widgetKey);
// 只取出全部大於該hash值的部分而沒必要遍歷整個Tree
SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
if (subMap == null || subMap.isEmpty()) {
// hash值在最尾部,應該映射到第一個group上
return sortedMap.get(sortedMap.firstKey());
}
return subMap.get(subMap.firstKey());
}
public static void main(String[] args) {
// 生成隨機數進行測試
Map<String, Integer> resMap = new HashMap<>();
for (int i = 0; i < 100000; i++) {
Integer widgetId = (int)(Math.random() * 10000);
String server = getServer(widgetId.toString());
if (resMap.containsKey(server)) {
resMap.put(server, resMap.get(server) + 1);
} else {
resMap.put(server, 1);
}
}
resMap.forEach(
(k, v) -> {
System.out.println("group " + k + ": " + v + "(" + v/1000.0D +"%)");
}
);
}
}
複製代碼
生成10000個隨機數字進行測試,最終獲得的壓力分佈狀況以下:
[192.168.0.1:111] launched @ 8518713
[192.168.0.2:111] launched @ 1361847097
[192.168.0.3:111] launched @ 1171828661
[192.168.0.4:111] launched @ 1764547046
group 192.168.0.2:111: 8572(8.572%)
group 192.168.0.1:111: 18693(18.693%)
group 192.168.0.4:111: 17764(17.764%)
group 192.168.0.3:111: 27870(27.87%)
group 192.168.0.0:111: 27101(27.101%)
複製代碼
能夠看到壓力仍是比較不平均的,因此咱們繼續,引入虛擬節點:
public class ConsistentHashingWithVirtualNode {
/** * 集羣地址列表 */
private static String[] groups = {
"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
"192.168.0.3:111", "192.168.0.4:111"
};
/** * 真實集羣列表 */
private static List<String> realGroups = new LinkedList<>();
/** * 虛擬節點映射關係 */
private static SortedMap<Integer, String> virtualNodes = new TreeMap<>();
private static final int VIRTUAL_NODE_NUM = 1000;
static {
// 先添加真實節點列表
realGroups.addAll(Arrays.asList(groups));
// 將虛擬節點映射到Hash環上
for (String realGroup: realGroups) {
for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
String virtualNodeName = getVirtualNodeName(realGroup, i);
int hash = HashUtil.getHash(virtualNodeName);
System.out.println("[" + virtualNodeName + "] launched @ " + hash);
virtualNodes.put(hash, virtualNodeName);
}
}
}
private static String getVirtualNodeName(String realName, int num) {
return realName + "&&VN" + String.valueOf(num);
}
private static String getRealNodeName(String virtualName) {
return virtualName.split("&&")[0];
}
private static String getServer(String widgetKey) {
int hash = HashUtil.getHash(widgetKey);
// 只取出全部大於該hash值的部分而沒必要遍歷整個Tree
SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash);
String virtualNodeName;
if (subMap == null || subMap.isEmpty()) {
// hash值在最尾部,應該映射到第一個group上
virtualNodeName = virtualNodes.get(virtualNodes.firstKey());
}else {
virtualNodeName = subMap.get(subMap.firstKey());
}
return getRealNodeName(virtualNodeName);
}
public static void main(String[] args) {
// 生成隨機數進行測試
Map<String, Integer> resMap = new HashMap<>();
for (int i = 0; i < 100000; i++) {
Integer widgetId = i;
String group = getServer(widgetId.toString());
if (resMap.containsKey(group)) {
resMap.put(group, resMap.get(group) + 1);
} else {
resMap.put(group, 1);
}
}
resMap.forEach(
(k, v) -> {
System.out.println("group " + k + ": " + v + "(" + v/100000.0D +"%)");
}
);
}
}
複製代碼
這裏真實節點和虛擬節點的映射採用了字符串拼接的方式,這種方式雖然簡單但頗有效,memcached官方也是這麼實現的。將虛擬節點的數量設置爲1000,從新測試壓力分佈狀況,結果以下:
group 192.168.0.2:111: 18354(18.354%)
group 192.168.0.1:111: 20062(20.062%)
group 192.168.0.4:111: 20749(20.749%)
group 192.168.0.3:111: 20116(20.116%)
group 192.168.0.0:111: 20719(20.719%)
複製代碼
能夠看到基本已經達到平均分佈了,接着繼續測試刪除和增長節點給整個服務帶來的影響,相關測試代碼以下:
private static void refreshHashCircle() {
// 當集羣變更時,刷新hash環,其他的集羣在hash環上的位置不會發生變更
virtualNodes.clear();
for (String realGroup: realGroups) {
for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
String virtualNodeName = getVirtualNodeName(realGroup, i);
int hash = HashUtil.getHash(virtualNodeName);
System.out.println("[" + virtualNodeName + "] launched @ " + hash);
virtualNodes.put(hash, virtualNodeName);
}
}
}
private static void addGroup(String identifier) {
realGroups.add(identifier);
refreshHashCircle();
}
private static void removeGroup(String identifier) {
int i = 0;
for (String group:realGroups) {
if (group.equals(identifier)) {
realGroups.remove(i);
}
i++;
}
refreshHashCircle();
}
複製代碼
測試刪除一個集羣先後的壓力分佈以下:
running the normal test.
group 192.168.0.2:111: 19144(19.144%)
group 192.168.0.1:111: 20244(20.244%)
group 192.168.0.4:111: 20923(20.923%)
group 192.168.0.3:111: 19811(19.811%)
group 192.168.0.0:111: 19878(19.878%)
removed a group, run test again.
group 192.168.0.2:111: 23409(23.409%)
group 192.168.0.1:111: 25628(25.628%)
group 192.168.0.4:111: 25583(25.583%)
group 192.168.0.0:111: 25380(25.38%)
複製代碼
同時計算一下消失的集羣上的Key最終如何轉移到其餘集羣上:
[192.168.0.1:111-192.168.0.4:111] :5255
[192.168.0.1:111-192.168.0.3:111] :5090
[192.168.0.1:111-192.168.0.2:111] :5069
[192.168.0.1:111-192.168.0.0:111] :4938
複製代碼
可見,刪除集羣后,該集羣上的壓力均勻地分散給了其餘集羣,最終整個集羣仍處於負載均衡狀態,符合咱們的預期,最後看一下添加集羣的狀況。
壓力分佈:
running the normal test.
group 192.168.0.2:111: 18890(18.89%)
group 192.168.0.1:111: 20293(20.293%)
group 192.168.0.4:111: 21000(21.0%)
group 192.168.0.3:111: 19816(19.816%)
group 192.168.0.0:111: 20001(20.001%)
add a group, run test again.
group 192.168.0.2:111: 15524(15.524%)
group 192.168.0.7:111: 16928(16.928%)
group 192.168.0.1:111: 16888(16.888%)
group 192.168.0.4:111: 16965(16.965%)
group 192.168.0.3:111: 16768(16.768%)
group 192.168.0.0:111: 16927(16.927%)
複製代碼
壓力轉移:
[192.168.0.0:111-192.168.0.7:111] :3102
[192.168.0.4:111-192.168.0.7:111] :4060
[192.168.0.2:111-192.168.0.7:111] :3313
[192.168.0.1:111-192.168.0.7:111] :3292
[192.168.0.3:111-192.168.0.7:111] :3261
複製代碼
綜上能夠得出結論,在引入足夠多的虛擬節點後,一致性hash仍是可以比較完美地知足負載均衡須要的。
緩存服務器對於性能有着較高的要求,所以咱們但願在擴容時新的集羣可以較快的填充好數據並工做。可是從一個集羣啓動,到真正加入並能夠提供服務之間還存在着不小的時間延遲,要實現更優雅的擴容,咱們能夠從兩個方面出發:
高頻Key預熱
負載均衡器做爲路由層,是能夠收集並統計每一個緩存Key的訪問頻率的,若是可以維護一份高頻訪問Key的列表,新的集羣在啓動時根據這個列表提早拉取對應Key的緩存值進行預熱,即可以大大減小由於新增集羣而致使的Key失效。
具體的設計能夠經過緩存來實現,以下:
不過這個方案在實際使用時有一個很大的限制,那就是高頻Key自己的緩存失效時間可能很短,預熱時儲存的Value在實際被訪問到時可能已經被更新或者失效,處理不當會致使出現髒數據,所以實現難度仍是有一些大的。
歷史Hash環保留
回顧一致性Hash的擴容,不難發現新增節點後,它所對應的Key在原來的節點還會保留一段時間。所以在擴容的延遲時間段,若是對應的Key緩存在新節點上尚未被加載,能夠去原有的節點上嘗試讀取。
舉例,假設咱們原有3個集羣,如今要擴展到6個集羣,這就意味着原有50%的Key都會失效(被轉移到新節點上),若是咱們維護擴容前和擴容後的兩個Hash環,在擴容後的Hash環上找不到Key的儲存時,先轉向擴容前的Hash環尋找一波,若是可以找到就返回對應的值並將該緩存寫入新的節點上,找不到時再透過緩存,以下圖:
這樣作的缺點是增長了緩存讀取的時間,但相比於直接擊穿緩存而言仍是要好不少的。優勢則是能夠隨意擴容多臺機器,而不會產生大面積的緩存失效。
談完了擴容,再談談縮容。
熔斷機制
縮容後,剩餘各個節點上的訪問壓力都會有所增長,此時若是某個節點由於壓力過大而宕機,就可能會引起連鎖反應。所以做爲兜底方案,應當給每一個集羣設立對應熔斷機制來保護服務的穩定性。
多集羣LB的更新延遲
這個問題在縮容時比較嚴重,若是你使用一個集羣來做爲負載均衡,並使用一個配置服務器好比ConfigServer來推送集羣狀態以構建Hash環,那麼在某個集羣退出時這個狀態並不必定會被馬上同步到全部的LB上,這就可能會致使一個暫時的調度不一致,以下圖:
若是某臺LB錯誤地將請求打到了已經退出的集羣上,就會致使緩存擊穿。解決這個問題主要有如下幾種思路:
瞭解了一致性Hash算法的特色後,咱們也不難發現一些不盡人意的地方:
針對這些問題,Redis在實現本身的分佈式集羣方案時,設計了全新的思路:基於P2P結構的HashSlot算法,下面簡單介紹一下:
使用HashSlot
相似於Hash環,Redis Cluster採用HashSlot來實現Key值的均勻分佈和實例的增刪管理。
首先默認分配了16384個Slot(這個大小正好可使用2kb的空間保存),每一個Slot至關於一致性Hash環上的一個節點。接入集羣的全部實例將均勻地佔有這些Slot,而最終當咱們Set一個Key時,使用CRC16(Key) % 16384
來計算出這個Key屬於哪一個Slot,並最終映射到對應的實例上去。
那麼當增刪實例時,Slot和實例間的對應要如何進行對應的改動呢?
舉個例子,本來有3個節點A,B,C,那麼一開始建立集羣時Slot的覆蓋狀況是:
節點A 0-5460
節點B 5461-10922
節點C 10923-16383
複製代碼
如今假設要增長一個節點D,RedisCluster的作法是將以前每臺機器上的一部分Slot移動到D上(注意這個過程也意味着要對節點D寫入的KV儲存),成功接入後Slot的覆蓋狀況將變爲以下狀況:
節點A 1365-5460
節點B 6827-10922
節點C 12288-16383
節點D 0-1364,5461-6826,10923-12287
複製代碼
同理刪除一個節點,就是將其原來佔有的Slot以及對應的KV儲存均勻地歸還給其餘節點。
P2P節點尋找
如今咱們考慮如何實現去中心化的訪問,也就是說不管訪問集羣中的哪一個節點,你都可以拿到想要的數據。其實這有點相似於路由器的路由表,具體說來就是:
每一個節點都保存有完整的HashSlot - 節點
映射表,也就是說,每一個節點都知道本身擁有哪些Slot,以及某個肯定的Slot究竟對應着哪一個節點。
不管向哪一個節點發出尋找Key的請求,該節點都會經過CRC(Key) % 16384
計算該Key究竟存在於哪一個Slot,並將請求轉發至該Slot所在的節點。
總結一下就是兩個要點:映射表和內部轉發,這是經過著名的**Gossip協議**來實現的。
最後咱們能夠給出Redis Cluster的系統結構圖,和一致性Hash環仍是有着很明顯的區別的:
對比一下,HashSlot + P2P的方案解決了去中心化的問題,同時也提供了更好的動態擴展性。但相比於一致性Hash而言,其結構更加複雜,實現上也更加困難。
而在以前的分析中咱們也能看出,一致性Hash方案總體上仍是有着不錯的表現的,所以在實際的系統應用中,能夠根據開發成本和性能要求合理地選擇最適合的方案。總之,二者都很是優秀,至於用哪一個、怎麼用,就是仁者見仁智者見智的問題了。