一致性哈希算法常用於負載均衡中要求資源被均勻的分布到所有節點上,並且對資源的請求能快速路由到對應的節點上。具體的舉兩個場景的例子:
1、MemCache集群,要求存儲各種數據均勻的存到集群中的各個節點上,訪問這些數據時能快速的路由到集群中對應存放該數據的節點上;並且要求增刪節點對整個集群的影響很小,不至於有大的動盪造成整體負載的不穩定;
2、RPC過程中服務提供者做N個節點的集群部署,為了能在服務上維護一些業務狀態,希望同一種請求每次都落到同一台服務上。
比如有{N0, N1, N2}三個節點,陸續有多個資源要分配到這三個節點上,如何盡可能均勻的分配到這些節點上?
一致性哈希算法的思路為:先構造出一個長度為232整數環,根據N0-3的節點名稱的hash值(分布為[0,232-1])放到這個環上。現在要存放資源,根據資源的Key的Hash值(也是分布為[0,232-1])值Haaa,在環上順時針的找到離Haaa最近(第一個大於或等於Haaa)的一個節點,就建立了資源和節點的映射關系。
以上圖片引自http://www.cnblogs.com/xrq730/p/4948707.html。
為什么要用環存儲節點,並用hashKey順時針尋找對應節點?
我們分配節點最簡單的辦法是取余算法,即有3個節點,資源key=5, 5%3=2,選取N2,key=3,3%3=0,選取N0。雖然簡單,但有個缺點,如果節點數增加或減少,就會有大量的key不命中,造成請求壓力轉移,可能對系統整體有很大的影響,甚至發生宕機危險。
而一致性哈希算法增加或減少節點,只會引起少部分key不命中,如下圖,增加一個Node4節點,只會將加粗部分的key值從Node1(10.0.0.0:91002)移到Node4(10.0.0.0:91003),對集群影響很小。
以上圖片引自http://www.cnblogs.com/xrq730/p/4948707.html
Java實現中用什么表示Hash環好呢?經對比,用TreeMap的時間復雜度是O(logN),相對效率比較高,因為TreeMap使用了紅黑樹結構存儲實體對象。
Hash算法的選擇上,首先我們考慮簡單的String.HashCode()方法,這個算法的缺點是,相似的字符串如N1(10.0.0.0:91001),N2(10.0.0.0:91002),N3(10.0.0.0:91003),哈希值也很相近,造成的結果是節點在Hash環上分布很緊密,導致大部分Key值落到了N0上,節點資源分布不均。一般我們采用FNV1_32_HASH、KETAMA_HASH等算法,KETAMA_HASH是MemCache集群默認的實現方法,這些算法效果要好得多,會使N0,N1,N2的Hash值更均勻的分布在環上。
我們用KETAMA_HASH算法實現一致性哈希(無虛擬節點方式),如下代碼所示:
1 package com.example.demo.arithmetic; 2 3 import java.io.UnsupportedEncodingException; 4 import java.security.MessageDigest; 5 import java.security.NoSuchAlgorithmException; 6 import java.util.Arrays; 7 import java.util.Map; 8 import java.util.TreeMap; 9 10 /** 11 * Created by markcd on 2018/2/28. 12 */ 13 public class ConsistentHashLoadBalanceNoVirtualNode { 14 15 private TreeMap<Long, String> realNodes = new TreeMap<>(); 16 private String[] nodes; 17 18 public ConsistentHashLoadBalanceNoVirtualNode(String[] nodes){ 19 this.nodes = Arrays.copyOf(nodes, nodes.length); 20 initalization(); 21 } 22 23 /** 24 * 初始化哈希環 25 * 循環計算每個node名稱的哈希值,將其放入treeMap 26 */ 27 private void initalization(){ 28 for (String nodeName: nodes) { 29 realNodes.put(hash(nodeName, 0), nodeName); 30 } 31 } 32 33 /** 34 * 根據資源key選擇返回相應的節點名稱 35 * @param key 36 * @return 節點名稱 37 */ 38 public String selectNode(String key){ 39 Long hashOfKey = hash(key, 0); 40 if (! realNodes.containsKey(hashOfKey)) {
//ceilingEntry()的作用是得到比hashOfKey大的第一個Entry 41 Map.Entry<Long, String> entry = realNodes.ceilingEntry(hashOfKey); 42 if (entry != null) 43 return entry.getValue(); 44 else 45 return nodes[0]; 46 }else 47 return realNodes.get(hashOfKey); 48 } 49 50 private Long hash(String nodeName, int number) { 51 byte[] digest = md5(nodeName); 52 return (((long) (digest[3 + number * 4] & 0xFF) << 24) 53 | ((long) (digest[2 + number * 4] & 0xFF) << 16) 54 | ((long) (digest[1 + number * 4] & 0xFF) << 8) 55 | (digest[number * 4] & 0xFF)) 56 & 0xFFFFFFFFL; 57 } 58 59 /** 60 * md5加密 61 * 62 * @param str 63 * @return 64 */ 65 public byte[] md5(String str) { 66 try { 67 MessageDigest md = MessageDigest.getInstance("MD5"); 68 md.reset(); 69 md.update(str.getBytes("UTF-8")); 70 return md.digest(); 71 } catch (NoSuchAlgorithmException e) { 72 e.printStackTrace(); 73 return null; 74 } catch (UnsupportedEncodingException e) { 75 e.printStackTrace(); 76 return null; 77 } 78 } 79 80 private void printTreeNode(){ 81 if (realNodes != null && ! realNodes.isEmpty()){ 82 realNodes.forEach((hashKey, node) -> 83 System.out.println( 84 new StringBuffer(node) 85 .append(" ==> ") 86 .append(hashKey) 87 ) 88 ); 89 }else 90 System.out.println("Cycle is Empty"); 91 } 92 93 public static void main(String[] args){ 94 String[] nodes = new String[]{"192.168.2.1:8080", "192.168.2.2:8080", "192.168.2.3:8080", "192.168.2.4:8080"}; 95 ConsistentHashLoadBalanceNoVirtualNode consistentHash = new ConsistentHashLoadBalanceNoVirtualNode(nodes); 96 consistentHash.printTreeNode(); 97 } 98 }
main()方法執行結果如下,可以看到,hash值分布的距離比較開闊。
192.168.2.3:8080 ==> 1182102228
192.168.2.4:8080 ==> 1563927337
192.168.2.1:8080 ==> 2686712470
192.168.2.2:8080 ==> 3540412423
KETAMA_HASH解決了hash值分布不均的問題,但還存在一個問題,如下圖,在沒有Node3節點時,資源相對均勻的分布在{Node0,Node1,Node2}上。增加了Node3節點后,Node1到Node3節點中間的所有資源從Node2遷移到了Node3上。這樣,Node0,Node1存儲的資源多,Node2,Node3存儲的資源少,資源分布不均勻。
以上圖片引自http://www.cnblogs.com/xrq730/p/4948707.html
如何解決這個問題呢?我們引入虛擬節點概念,如將一個真實節點Node0映射成100個虛擬節點分布在Hash環上,與這100個虛擬節點根據KETAMA_HASH哈希環匹配的資源都存到真實節點Node0上。{Node0,Node1,Node2}以相同的方式拆分虛擬節點映射到Hash環上。當集群增加節點Node3時,在Hash環上增加Node3拆分的100個虛擬節點,這新增的100個虛擬節點更均勻的分布在了哈希環上,可能承擔了{Node0,Node1,Node2}每個節點的部分資源,資源分布仍然保持均勻。
每個真實節點應該拆分成多少個虛擬節點?數量要合適才能保證負載分布的均勻,有一個大致的規律,如下圖所示,Y軸表示真實節點的數目,X軸表示需拆分的虛擬節點數目:
真實節點越少,所需闡發的虛擬節點越多,比如有10個真實節點,每個節點所需拆分的虛擬節點個數可能是100~200個,才能達到真正的負載均衡。
下面貼出使用了虛擬節點的算法實現:
1 package com.example.demo.arithmetic; 2 3 import java.io.UnsupportedEncodingException; 4 import java.security.MessageDigest; 5 import java.security.NoSuchAlgorithmException; 6 import java.util.LinkedList; 7 import java.util.Map; 8 import java.util.TreeMap; 9 10 /** 11 * Created by markcd on 2018/2/28. 12 */ 13 public class ConsistentHashLoadBalance { 14 15 private TreeMap<Long, String> virtualNodes = new TreeMap<>(); 16 private LinkedList<String> nodes;
//每個真實節點對應的虛擬節點數 17 private final int replicCnt; 18 19 public ConsistentHashLoadBalance(LinkedList<String> nodes, int replicCnt){ 20 this.nodes = nodes; 21 this.replicCnt = replicCnt; 22 initalization(); 23 } 24 25 /** 26 * 初始化哈希環 27 * 循環計算每個node名稱的哈希值,將其放入treeMap 28 */ 29 private void initalization(){ 30 for (String nodeName: nodes) { 31 for (int i = 0; i < replicCnt/4; i++) { 32 String virtualNodeName = getNodeNameByIndex(nodeName, i); 33 for (int j = 0; j < 4; j++) { 34 virtualNodes.put(hash(virtualNodeName, j), nodeName); 35 } 36 } 37 } 38 } 39 40 private String getNodeNameByIndex(String nodeName, int index){ 41 return new StringBuffer(nodeName) 42 .append("&&") 43 .append(index) 44 .toString(); 45 } 46 47 /** 48 * 根據資源key選擇返回相應的節點名稱 49 * @param key 50 * @return 節點名稱 51 */ 52 public String selectNode(String key){ 53 Long hashOfKey = hash(key, 0); 54 if (! virtualNodes.containsKey(hashOfKey)) { 55 Map.Entry<Long, String> entry = virtualNodes.ceilingEntry(hashOfKey); 56 if (entry != null) 57 return entry.getValue(); 58 else 59 return nodes.getFirst(); 60 }else 61 return virtualNodes.get(hashOfKey); 62 } 63 64 private Long hash(String nodeName, int number) { 65 byte[] digest = md5(nodeName); 66 return (((long) (digest[3 + number * 4] & 0xFF) << 24) 67 | ((long) (digest[2 + number * 4] & 0xFF) << 16) 68 | ((long) (digest[1 + number * 4] & 0xFF) << 8) 69 | (digest[number * 4] & 0xFF)) 70 & 0xFFFFFFFFL; 71 } 72 73 /** 74 * md5加密 75 * 76 * @param str 77 * @return 78 */ 79 public byte[] md5(String str) { 80 try { 81 MessageDigest md = MessageDigest.getInstance("MD5"); 82 md.reset(); 83 md.update(str.getBytes("UTF-8")); 84 return md.digest(); 85 } catch (NoSuchAlgorithmException e) { 86 e.printStackTrace(); 87 return null; 88 } catch (UnsupportedEncodingException e) { 89 e.printStackTrace(); 90 return null; 91 } 92 } 93 94 public void addNode(String node){ 95 nodes.add(node); 96 String virtualNodeName = getNodeNameByIndex(node, 0); 97 for (int i = 0; i < replicCnt/4; i++) { 98 for (int j = 0; j < 4; j++) { 99 virtualNodes.put(hash(virtualNodeName, j), node); 100 } 101 } 102 } 103 104 public void removeNode(String node){ 105 nodes.remove(node); 106 String virtualNodeName = getNodeNameByIndex(node, 0); 107 for (int i = 0; i < replicCnt/4; i++) { 108 for (int j = 0; j < 4; j++) { 109 virtualNodes.remove(hash(virtualNodeName, j), node); 110 } 111 } 112 } 113 114 private void printTreeNode(){ 115 if (virtualNodes != null && ! virtualNodes.isEmpty()){ 116 virtualNodes.forEach((hashKey, node) -> 117 System.out.println( 118 new StringBuffer(node) 119 .append(" ==> ") 120 .append(hashKey) 121 ) 122 ); 123 }else 124 System.out.println("Cycle is Empty"); 125 } 126 127 public static void main(String[] args){ 128 LinkedList<String> nodes = new LinkedList<>(); 129 nodes.add("192.168.2.1:8080"); 130 nodes.add("192.168.2.2:8080"); 131 nodes.add("192.168.2.3:8080"); 132 nodes.add("192.168.2.4:8080"); 133 ConsistentHashLoadBalance consistentHash = new ConsistentHashLoadBalance(nodes, 160); 134 consistentHash.printTreeNode(); 135 } 136 }
以上main方法執行的結果如下:
192.168.2.4:8080 ==> 18075595
192.168.2.1:8080 ==> 18286704
192.168.2.1:8080 ==> 35659769
192.168.2.2:8080 ==> 43448858
192.168.2.1:8080 ==> 44075453
192.168.2.3:8080 ==> 47625378
........(由於內容過多,不做全部展示)
至此哈希一致性算法的原理和實現描述完畢,歡迎大家討論,如有不當的地方歡迎大家提出異議。