原理
一致性哈希算法(Consistent Hashing)最早在論文《Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web》中被提出。簡單來說,一致性哈希將整個哈希值空間組織成一個虛擬的圓環,如假設某哈希函數H的值空間為0-2^32-1(即哈希值是一個32位無符號整形)。整個空間按順時針方向組織,0和232-1在零點中方向重合。
下一步將各個服務器使用Hash進行一個哈希,具體可以選擇服務器的ip或主機名作為關鍵字進行哈希,這樣每台機器就能確定其在哈希環上的位置,這里假設將上文中四台服務器使用ip地址哈希后在環空間的位置如下:
接下來使用如下算法定位數據訪問到相應服務器: 將數據key使用相同的函數Hash計算出哈希值,並確定此數據在環上的位置,從此位置沿環順時針“行走”,第一台遇到的服務器就是其應該定位到的服務器!
例如我們有Object A、Object B、Object C、Object D四個數據對象,經過哈希計算后,在環空間上的位置如下:
根據一致性哈希算法,數據A會被定為到Node A上,B被定為到Node B上,C被定為到Node C上,D被定為到Node D上。
下面分析一致性哈希算法的容錯性和可擴展性。現假設Node C不幸宕機,可以看到此時對象A、B、D不會受到影響,只有C對象被重定位到Node D。一般的,在一致性哈希算法中,如果一台服務器不可用,則受影響的數據僅僅是此服務器到其環空間中前一台服務器(即沿着逆時針方向行走遇到的第一台服務器)之間數據,其它不會受到影響。
下面考慮另外一種情況,如果在系統中增加一台服務器Node X,如下圖所示:
此時對象Object A、B、D不受影響,只有對象C需要重定位到新的Node X 。一般的,在一致性哈希算法中,如果增加一台服務器,則受影響的數據僅僅是新服務器到其環空間中前一台服務器(即沿着逆時針方向行走遇到的第一台服務器)之間數據,其它數據也不會受到影響。
綜上所述,一致性哈希算法對於節點的增減都只需重定位環空間中的一小部分數據,具有較好的容錯性和可擴展性。
另外,一致性哈希算法在服務節點太少時,容易因為節點分部不均勻而造成數據傾斜問題。
為了解決這種數據傾斜問題,一致性哈希算法引入了虛擬節點機制,即對每一個服務節點計算多個哈希,每個計算結果位置都放置一個此服務節點,稱為虛擬節點。具體做法可以在服務器ip或主機名的后面增加編號來實現。例如上面的情況,可以為每台服務器計算三個虛擬節點,於是可以分別計算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,於是形成六個虛擬節點:
同時數據定位算法不變,只是多了一步虛擬節點到實際節點的映射,例如定位到“Node A#1”、“Node A#2”、“Node A#3”三個虛擬節點的數據均定位到Node A上。這樣就解決了服務節點少時數據傾斜的問題。在實際應用中,通常將虛擬節點數設置為32甚至更大,因此即使很少的服務節點也能做到相對均勻的數據分布。
實現
1、不帶虛擬節點的

package hash; import java.util.SortedMap; import java.util.TreeMap; /** * 不帶虛擬節點的一致性Hash算法 * 重點:1.如何造一個hash環,2.如何在哈希環上映射服務器節點,3.如何找到對應的節點 */ public class ConsistentHashingWithoutVirtualNode { //待添加入Hash環的服務器列表 private static String[] servers = { "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" }; //key表示服務器的hash值,value表示服務器 private static SortedMap<Integer, String> sortedMap = new TreeMap<Integer, String>(); //程序初始化,將所有的服務器放入sortedMap中 static { for (int i=0; i<servers.length; i++) { int hash = getHash(servers[i]); System.out.println("[" + servers[i] + "]加入集合中, 其Hash值為" + hash); sortedMap.put(hash, servers[i]); } System.out.println(); } //得到應當路由到的結點 private static String getServer(String key) { //得到該key的hash值 int hash = getHash(key); //得到大於該Hash值的所有Map SortedMap<Integer, String> subMap = sortedMap.tailMap(hash); if(subMap.isEmpty()){ //如果沒有比該key的hash值大的,則從第一個node開始 Integer i = sortedMap.firstKey(); //返回對應的服務器 return sortedMap.get(i); }else{ //第一個Key就是順時針過去離node最近的那個結點 Integer i = subMap.firstKey(); //返回對應的服務器 return subMap.get(i); } } //使用FNV1_32_HASH算法計算服務器的Hash值,這里不使用重寫hashCode的方法,最終效果沒區別 private 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; } public static void main(String[] args) { String[] keys = {"太陽", "月亮", "星星"}; for(int i=0; i<keys.length; i++) System.out.println("[" + keys[i] + "]的hash值為" + getHash(keys[i]) + ", 被路由到結點[" + getServer(keys[i]) + "]"); } }
2、帶虛擬節點的

package hash; import java.util.LinkedList; import java.util.List; import java.util.SortedMap; import java.util.TreeMap; import org.apache.commons.lang.StringUtils; /** * 帶虛擬節點的一致性Hash算法 */ public class ConsistentHashingWithVirtualNode { //待添加入Hash環的服務器列表 private static String[] servers = {"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"}; //真實結點列表,考慮到服務器上線、下線的場景,即添加、刪除的場景會比較頻繁,這里使用LinkedList會更好 private static List<String> realNodes = new LinkedList<String>(); //虛擬節點,key表示虛擬節點的hash值,value表示虛擬節點的名稱 private static SortedMap<Integer, String> virtualNodes = new TreeMap<Integer, String>(); //虛擬節點的數目,這里寫死,為了演示需要,一個真實結點對應5個虛擬節點 private static final int VIRTUAL_NODES = 5; static{ //先把原始的服務器添加到真實結點列表中 for(int i=0; i<servers.length; i++) realNodes.add(servers[i]); //再添加虛擬節點,遍歷LinkedList使用foreach循環效率會比較高 for (String str : realNodes){ for(int i=0; i<VIRTUAL_NODES; i++){ String virtualNodeName = str + "&&VN" + String.valueOf(i); int hash = getHash(virtualNodeName); System.out.println("虛擬節點[" + virtualNodeName + "]被添加, hash值為" + hash); virtualNodes.put(hash, virtualNodeName); } } System.out.println(); } //使用FNV1_32_HASH算法計算服務器的Hash值,這里不使用重寫hashCode的方法,最終效果沒區別 private 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; } //得到應當路由到的結點 private static String getServer(String key){ //得到該key的hash值 int hash = getHash(key); // 得到大於該Hash值的所有Map SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash); String virtualNode; if(subMap.isEmpty()){ //如果沒有比該key的hash值大的,則從第一個node開始 Integer i = virtualNodes.firstKey(); //返回對應的服務器 virtualNode = virtualNodes.get(i); }else{ //第一個Key就是順時針過去離node最近的那個結點 Integer i = subMap.firstKey(); //返回對應的服務器 virtualNode = subMap.get(i); } //virtualNode虛擬節點名稱要截取一下 if(StringUtils.isNotBlank(virtualNode)){ return virtualNode.substring(0, virtualNode.indexOf("&&")); } return null; } public static void main(String[] args){ String[] keys = {"太陽", "月亮", "星星"}; for(int i=0; i<keys.length; i++) System.out.println("[" + keys[i] + "]的hash值為" + getHash(keys[i]) + ", 被路由到結點[" + getServer(keys[i]) + "]"); } }
Guava 中的一致性哈希
它使用起來非常簡單,里面有一個consistentHash()
的靜態方法:
int bucket = Hashing.consistentHash(id, buckets) // bucket 的范圍在 0 ~ buckets 之間
傳入數據主鍵id
(分片片鍵)和集群中機器數量buckets
,返回一個固定的數字,表示數據應當落在第幾個機器上。
而這個方法內部實現也非常簡單:
public static int consistentHash(long input, int buckets) { // 檢查 checkArgument(buckets > 0, "buckets must be positive: %s", buckets); // 利用內部的LCG算法實現,產生偽隨機數 LinearCongruentialGenerator generator = new LinearCongruentialGenerator(input); int candidate = 0; int next; // Jump from bucket to bucket until we go out of range while (true) { // generator.nextDouble() 產生偽隨機數 // 每次hash的循環中每一個的next的值總是會固定 : // 比如: // hash 1 round 1 -> 9 hash 2 round 1 -> 9 // hash 1 round 2 -> 7 hash 2 round 2 -> 7 // hash 1 round 3 -> 2 hash 2 round 3 -> 2 next = (int) ((candidate + 1) / generator.nextDouble()); if (next >= 0 && next < buckets) { // 如果在 0 到 bucket 范圍之外, 將這個next值賦值給candidate,重新計算 candidate = next; } else { // 如果在 0 到 bucket 范圍之內, 就返回這個 candidate 值,作為 input數據存儲的槽 return candidate; } } } // LCG偽隨機數的算法實現,關於LCG的解釋可以參考 http://en.wikipedia.org/wiki/Linear_congruential_generator private static final class LinearCongruentialGenerator { private long state; public LinearCongruentialGenerator(long seed) { this.state = seed; } public double nextDouble() { state = 2862933555777941757L * state + 1; return ((double) ((int) (state >>> 33) + 1)) / (0x1.0p31); } }
通過Guava
的這個方法,我們就可以輕松地在項目中使用一致性哈希了。
參考:
https://www.cnblogs.com/study-everyday/p/8629100.html
https://www.cnblogs.com/dongma/p/10103682.html
https://www.jianshu.com/p/c49ed57cdae1