一致性Hash算法html
關於一致性Hash算法,在我以前的博文中已經有屢次提到了,MemCache超詳細解讀一文中"一致性Hash算法"部分,對於爲何要使用一致性Hash算法、一致性Hash算法的算法原理作了詳細的解讀。node
算法的具體原理這裏再次貼上:算法
先構造一個長度爲232的整數環(這個環被稱爲一致性Hash環),根據節點名稱的Hash值(其分佈爲[0, 232-1])將服務器節點放置在這個Hash環上,而後根據數據的Key值計算獲得其Hash值(其分佈也爲[0, 232-1]),接着在Hash環上順時針查找距離這個Key值的Hash值最近的服務器節點,完成Key到服務器的映射查找。編程
這種算法解決了普通餘數Hash算法伸縮性差的問題,能夠保證在上線、下線服務器的狀況下儘可能有多的請求命中原來路由到的服務器。數組
固然,萬事不可能十全十美,一致性Hash算法比普通的餘數Hash算法更具備伸縮性,可是同時其算法實現也更爲複雜,本文就來研究一下,如何利用Java代碼實現一致性Hash算法。在開始以前,先對一致性Hash算法中的幾個核心問題進行一些探究。服務器
數據結構的選取數據結構
一致性Hash算法最早要考慮的一個問題是:構造出一個長度爲232的整數環,根據節點名稱的Hash值將服務器節點放置在這個Hash環上。負載均衡
那麼,整數環應該使用何種數據結構,才能使得運行時的時間複雜度最低?首先說明一點,關於時間複雜度,常見的時間複雜度與時間效率的關係有以下的經驗規則:分佈式
O(1) < O(log2N) < O(n) < O(N * log2N) < O(N2) < O(N3) < 2N < 3N < N!性能
通常來講,前四個效率比較高,中間兩個差強人意,後三個比較差(只要N比較大,這個算法就動不了了)。OK,繼續前面的話題,應該如何選取數據結構,我認爲有如下幾種可行的解決方案。
一、解決方案一:排序+List
我想到的第一種思路是:算出全部待加入數據結構的節點名稱的Hash值放入一個數組中,而後使用某種排序算法將其從小到大進行排序,最後將排序後的數據放入List中,採用List而不是數組是爲告終點的擴展考慮。
以後,待路由的結點,只須要在List中找到第一個Hash值比它大的服務器節點就能夠了,好比服務器節點的Hash值是[0,2,4,6,8,10],帶路由的結點是7,只須要找到第一個比7大的整數,也就是8,就是咱們最終須要路由過去的服務器節點。
若是暫時不考慮前面的排序,那麼這種解決方案的時間複雜度:
(1)最好的狀況是第一次就找到,時間複雜度爲O(1)
(2)最壞的狀況是最後一次才找到,時間複雜度爲O(N)
平均下來時間複雜度爲O(0.5N+0.5),忽略首項係數和常數,時間複雜度爲O(N)。
可是若是考慮到以前的排序,我在網上找了張圖,提供了各類排序算法的時間複雜度:
看得出來,排序算法要麼穩定可是時間複雜度高、要麼時間複雜度低但不穩定,看起來最好的歸併排序法的時間複雜度仍然有O(N * logN),稍微耗費性能了一些。
二、解決方案二:遍歷+List
既然排序操做比較耗性能,那麼能不能不排序?能夠的,因此進一步的,有了第二種解決方案。
解決方案使用List不變,不過能夠採用遍歷的方式:
(1)服務器節點不排序,其Hash值所有直接放入一個List中
(2)帶路由的節點,算出其Hash值,因爲指明瞭"順時針",所以遍歷List,比待路由的節點Hash值大的算出差值並記錄,比待路由節點Hash值小的忽略
(3)算出全部的差值以後,最小的那個,就是最終須要路由過去的節點
在這個算法中,看一下時間複雜度:
一、最好狀況是隻有一個服務器節點的Hash值大於帶路由結點的Hash值,其時間複雜度是O(N)+O(1)=O(N+1),忽略常數項,即O(N)
二、最壞狀況是全部服務器節點的Hash值都大於帶路由結點的Hash值,其時間複雜度是O(N)+O(N)=O(2N),忽略首項係數,即O(N)
因此,總的時間複雜度就是O(N)。其實算法還能更改進一些:給一個位置變量X,若是新的差值比原差值小,X替換爲新的位置,不然X不變。這樣遍歷就減小了一輪,不過通過改進後的算法時間複雜度仍爲O(N)。
總而言之,這個解決方案和解決方案一相比,整體來看,彷佛更好了一些。
三、解決方案三:二叉查找樹
拋開List這種數據結構,另外一種數據結構則是使用二叉查找樹。對於樹不是很清楚的朋友能夠簡單看一下這篇文章樹形結構。
固然咱們不能簡單地使用二叉查找樹,由於可能出現不平衡的狀況。平衡二叉查找樹有AVL樹、紅黑樹等,這裏使用紅黑樹,選用紅黑樹的緣由有兩點:
一、紅黑樹主要的做用是用於存儲有序的數據,這其實和第一種解決方案的思路又不謀而合了,可是它的效率很是高
二、JDK裏面提供了紅黑樹的代碼實現TreeMap和TreeSet
另外,以TreeMap爲例,TreeMap自己提供了一個tailMap(K fromKey)方法,支持從紅黑樹中查找比fromKey大的值的集合,但並不須要遍歷整個數據結構。
使用紅黑樹,可使得查找的時間複雜度下降爲O(logN),比上面兩種解決方案,效率大大提高。
爲了驗證這個說法,我作了一次測試,從大量數據中查找第一個大於其中間值的那個數據,好比10000數據就找第一個大於5000的數據(模擬平均的狀況)。看一下O(N)時間複雜度和O(logN)時間複雜度運行效率的對比:
50000 | 100000 | 500000 | 1000000 | 4000000 | |
ArrayList | 1ms | 1 | 4 | 4 | 5 |
LinkedList | 4ms | 7 | 11 | 13 | 17 |
TreeMap | 0ms | 0 | 0 | 0 | 0 |
由於再大就內存溢出了,因此只測試到4000000數據。能夠看到,數據查找的效率,TreeMap是完勝的,其實再增大數據測試也是同樣的,紅黑樹的數據結構決定了任何一個大於N的最小數據,它都只須要幾回至幾十次查找就能夠查到。
固然,明確一點,有利必有弊,根據我另一次測試獲得的結論是,爲了維護紅黑樹,數據插入效率TreeMap在三種數據結構裏面是最差的,且插入要慢上5~10倍
Hash值從新計算
服務器節點咱們確定用字符串來表示,好比"192.168.1.1"、"192.168.1.2",根據字符串獲得其Hash值,那麼另一個重要的問題就是Hash值要從新計算,這個問題是我在測試String的hashCode()方法的時候發現的,不妨來看一下爲何要從新計算Hash值:
/** * String的hashCode()方法運算結果查看 * @author 五月的倉頡 http://www.cnblogs.com/xrq730/ * */public class StringHashCodeTest { public static void main(String[] args) { System.out.println("192.168.0.0:111的哈希值:" + "192.168.0.0:1111".hashCode()); System.out.println("192.168.0.1:111的哈希值:" + "192.168.0.1:1111".hashCode()); System.out.println("192.168.0.2:111的哈希值:" + "192.168.0.2:1111".hashCode()); System.out.println("192.168.0.3:111的哈希值:" + "192.168.0.3:1111".hashCode()); System.out.println("192.168.0.4:111的哈希值:" + "192.168.0.4:1111".hashCode()); } }
咱們在作集羣的時候,集羣點的IP以這種連續的形式存在是很正常的。看一下運行結果爲:
192.168.0.0:111的哈希值:1845870087 192.168.0.1:111的哈希值:1874499238 192.168.0.2:111的哈希值:1903128389 192.168.0.3:111的哈希值:1931757540 192.168.0.4:111的哈希值:1960386691
這個就問題大了,[0,232-1]的區間之中,5個HashCode值卻只分布在這麼小小的一個區間,什麼概念?[0,232-1]中有4294967296個數字,而咱們的區間只有122516605,從機率學上講這將致使97%待路由的服務器都被路由到"192.168.0.1"這個集羣點上,簡直是糟糕透了!
另外還有一個很差的地方:規定的區間是非負數,String的hashCode()方法卻會產生負數(不信用"192.168.1.0:1111"試試看就知道了)。不過這個問題好解決,取絕對值就是一種解決的辦法。
綜上,String重寫的hashCode()方法在一致性Hash算法中沒有任何實用價值,得找個算法從新計算HashCode。這種從新計算Hash值的算法有不少,好比CRC32_HASH、FNV1_32_HASH、KETAMA_HASH等,其中KETAMA_HASH是默認的MemCache推薦的一致性Hash算法,用別的Hash算法也能夠,好比FNV1_32_HASH算法的計算效率就會高一些。
一致性Hash算法實現版本1:不帶虛擬節點
使用一致性Hash算法,儘管加強了系統的伸縮性,可是也有可能致使負載分佈不均勻,解決辦法就是使用虛擬節點代替真實節點,第一個代碼版本,先來個簡單的,不帶虛擬節點。
下面來看一下不帶虛擬節點的一致性Hash算法的Java代碼實現:
1 /** 2 * 不帶虛擬節點的一致性Hash算法 3 * @author 五月的倉頡http://www.cnblogs.com/xrq730/ 4 * 5 */ 6 public class ConsistentHashingWithoutVirtualNode 7 { 8 /** 9 * 待添加入Hash環的服務器列表10 */11 private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",12 "192.168.0.3:111", "192.168.0.4:111"};13 14 /**15 * key表示服務器的hash值,value表示服務器的名稱16 */17 private static SortedMap<Integer, String> sortedMap = 18 new TreeMap<Integer, String>();19 20 /**21 * 程序初始化,將全部的服務器放入sortedMap中22 */23 static24 {25 for (int i = 0; i < servers.length; i++)26 {27 int hash = getHash(servers[i]);28 System.out.println("[" + servers[i] + "]加入集合中, 其Hash值爲" + hash);29 sortedMap.put(hash, servers[i]);30 }31 System.out.println();32 }33 34 /**35 * 使用FNV1_32_HASH算法計算服務器的Hash值,這裏不使用重寫hashCode的方法,最終效果沒區別 36 */37 private static int getHash(String str)38 {39 final int p = 16777619;40 int hash = (int)2166136261L;41 for (int i = 0; i < str.length(); i++)42 hash = (hash ^ str.charAt(i)) * p;43 hash += hash << 13;44 hash ^= hash >> 7;45 hash += hash << 3;46 hash ^= hash >> 17;47 hash += hash << 5;48 49 // 若是算出來的值爲負數則取其絕對值50 if (hash < 0)51 hash = Math.abs(hash);52 return hash;53 }54 55 /**56 * 獲得應當路由到的結點57 */58 private static String getServer(String node)59 {60 // 獲得帶路由的結點的Hash值61 int hash = getHash(node);62 // 獲得大於該Hash值的全部Map63 SortedMap<Integer, String> subMap = 64 sortedMap.tailMap(hash);65 // 第一個Key就是順時針過去離node最近的那個結點66 Integer i = subMap.firstKey();67 // 返回對應的服務器名稱68 return subMap.get(i);69 }70 71 public static void main(String[] args)72 {73 String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};74 for (int i = 0; i < nodes.length; i++)75 System.out.println("[" + nodes[i] + "]的hash值爲" + 76 getHash(nodes[i]) + ", 被路由到結點[" + getServer(nodes[i]) + "]");77 }78 }
能夠運行一下看一下結果:
[192.168.0.0:111192.168.0.1:111192.168.0.2:111192.168.0.3:111192.168.0.4:111127.0.0.1:1111]的hash值爲380278925, 被路由到結點[192.168.0.0:111221.226.0.1:2222]的hash值爲1493545632, 被路由到結點[192.168.0.4:11110.211.0.1:3333]的hash值爲1393836017, 被路由到結點[192.168.0.4:111
看到通過FNV1_32_HASH算法從新計算事後的Hash值,就比原來String的hashCode()方法好多了。從運行結果來看,也沒有問題,三個點路由到的都是順時針離他們Hash值最近的那臺服務器上。
使用虛擬節點來改善一致性Hash算法
上面的一致性Hash算法實現,能夠在很大程度上解決不少分佈式環境下很差的路由算法致使系統伸縮性差的問題,可是會帶來另一個問題:負載不均。
好比說有Hash環上有A、B、C三個服務器節點,分別有100個請求會被路由到相應服務器上。如今在A與B之間增長了一個節點D,這致使了原來會路由到B上的部分節點被路由到了D上,這樣A、C上被路由到的請求明顯多於B、D上的,原來三個服務器節點上均衡的負載被打破了。某種程度上來講,這失去了負載均衡的意義,由於負載均衡的目的自己就是爲了使得目標服務器均分全部的請求。
解決這個問題的辦法是引入虛擬節點,其工做原理是:將一個物理節點拆分爲多個虛擬節點,而且同一個物理節點的虛擬節點儘可能均勻分佈在Hash環上。採起這樣的方式,就能夠有效地解決增長或減小節點時候的負載不均衡的問題。
至於一個物理節點應該拆分爲多少虛擬節點,下面能夠先看一張圖:
橫軸表示須要爲每臺福利服務器擴展的虛擬節點倍數,縱軸表示的是實際物理服務器數。能夠看出,物理服務器不多,須要更大的虛擬節點;反之物理服務器比較多,虛擬節點就能夠少一些。好比有10臺物理服務器,那麼差很少須要爲每臺服務器增長100~200個虛擬節點才能夠達到真正的負載均衡。
一致性Hash算法實現版本2:帶虛擬節點
在理解了使用虛擬節點來改善一致性Hash算法的理論基礎以後,就能夠嘗試開發代碼了。編程方面須要考慮的問題是:
一、一個真實結點如何對應成爲多個虛擬節點?
二、虛擬節點找到後如何還原爲真實結點?
這兩個問題其實有不少解決辦法,我這裏使用了一種簡單的辦法,給每一個真實結點後面根據虛擬節點加上後綴再取Hash值,好比"192.168.0.0:111"就把它變成"192.168.0.0:111&&VN0"到"192.168.0.0:111&&VN4",VN就是Virtual Node的縮寫,還原的時候只須要從頭截取字符串到"&&"的位置就能夠了。
下面來看一下帶虛擬節點的一致性Hash算法的Java代碼實現:
1 /** 2 * 帶虛擬節點的一致性Hash算法 3 * @author 五月的倉頡 http://www.cnblogs.com/xrq730/ 4 */ 5 public class ConsistentHashingWithVirtualNode 6 { 7 /** 8 * 待添加入Hash環的服務器列表 9 */10 private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",11 "192.168.0.3:111", "192.168.0.4:111"};12 13 /**14 * 真實結點列表,考慮到服務器上線、下線的場景,即添加、刪除的場景會比較頻繁,這裏使用LinkedList會更好15 */16 private static List<String> realNodes = new LinkedList<String>();17 18 /**19 * 虛擬節點,key表示虛擬節點的hash值,value表示虛擬節點的名稱20 */21 private static SortedMap<Integer, String> virtualNodes = 22 new TreeMap<Integer, String>();23 24 /**25 * 虛擬節點的數目,這裏寫死,爲了演示須要,一個真實結點對應5個虛擬節點26 */27 private static final int VIRTUAL_NODES = 5;28 29 static30 {31 // 先把原始的服務器添加到真實結點列表中32 for (int i = 0; i < servers.length; i++)33 realNodes.add(servers[i]);34 35 // 再添加虛擬節點,遍歷LinkedList使用foreach循環效率會比較高36 for (String str : realNodes)37 {38 for (int i = 0; i < VIRTUAL_NODES; i++)39 {40 String virtualNodeName = str + "&&VN" + String.valueOf(i);41 int hash = getHash(virtualNodeName);42 System.out.println("虛擬節點[" + virtualNodeName + "]被添加, hash值爲" + hash);43 virtualNodes.put(hash, virtualNodeName);44 }45 }46 System.out.println();47 }48 49 /**50 * 使用FNV1_32_HASH算法計算服務器的Hash值,這裏不使用重寫hashCode的方法,最終效果沒區別 51 */52 private static int getHash(String str)53 {54 final int p = 16777619;55 int hash = (int)2166136261L;56 for (int i = 0; i < str.length(); i++)57 hash = (hash ^ str.charAt(i)) * p;58 hash += hash << 13;59 hash ^= hash >> 7;60 hash += hash << 3;61 hash ^= hash >> 17;62 hash += hash << 5;63 64 // 若是算出來的值爲負數則取其絕對值65 if (hash < 0)66 hash = Math.abs(hash);67 return hash;68 }69 70 /**71 * 獲得應當路由到的結點72 */73 private static String getServer(String node)74 {75 // 獲得帶路由的結點的Hash值76 int hash = getHash(node);77 // 獲得大於該Hash值的全部Map78 SortedMap<Integer, String> subMap = 79 virtualNodes.tailMap(hash);80 // 第一個Key就是順時針過去離node最近的那個結點81 Integer i = subMap.firstKey();82 // 返回對應的虛擬節點名稱,這裏字符串稍微截取一下83 String virtualNode = subMap.get(i);84 return virtualNode.substring(0, virtualNode.indexOf("&&"));85 }86 87 public static void main(String[] args)88 {89 String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};90 for (int i = 0; i < nodes.length; i++)91 System.out.println("[" + nodes[i] + "]的hash值爲" + 92 getHash(nodes[i]) + ", 被路由到結點[" + getServer(nodes[i]) + "]");93 }94 }
關注一下運行結果:
虛擬節點[192.168.0.0:111&&VN0]被添加, hash值爲1686427075 虛擬節點[192.168.0.0:111&&VN1]被添加, hash值爲354859081 虛擬節點[192.168.0.0:111&&VN2]被添加, hash值爲1306497370 虛擬節點[192.168.0.0:111&&VN3]被添加, hash值爲817889914 虛擬節點[192.168.0.0:111&&VN4]被添加, hash值爲396663629 虛擬節點[192.168.0.1:111&&VN0]被添加, hash值爲1032739288 虛擬節點[192.168.0.1:111&&VN1]被添加, hash值爲707592309 虛擬節點[192.168.0.1:111&&VN2]被添加, hash值爲302114528 虛擬節點[192.168.0.1:111&&VN3]被添加, hash值爲36526861 虛擬節點[192.168.0.1:111&&VN4]被添加, hash值爲848442551 虛擬節點[192.168.0.2:111&&VN0]被添加, hash值爲1452694222 虛擬節點[192.168.0.2:111&&VN1]被添加, hash值爲2023612840 虛擬節點[192.168.0.2:111&&VN2]被添加, hash值爲697907480 虛擬節點[192.168.0.2:111&&VN3]被添加, hash值爲790847074 虛擬節點[192.168.0.2:111&&VN4]被添加, hash值爲2010506136 虛擬節點[192.168.0.3:111&&VN0]被添加, hash值爲891084251 虛擬節點[192.168.0.3:111&&VN1]被添加, hash值爲1725031739 虛擬節點[192.168.0.3:111&&VN2]被添加, hash值爲1127720370 虛擬節點[192.168.0.3:111&&VN3]被添加, hash值爲676720500 虛擬節點[192.168.0.3:111&&VN4]被添加, hash值爲2050578780 虛擬節點[192.168.0.4:111&&VN0]被添加, hash值爲586921010 虛擬節點[192.168.0.4:111&&VN1]被添加, hash值爲184078390 虛擬節點[192.168.0.4:111&&VN2]被添加, hash值爲1331645117 虛擬節點[192.168.0.4:111&&VN3]被添加, hash值爲918790803 虛擬節點[192.168.0.4:111&&VN4]被添加, hash值爲1232193678 [127.0.0.1:1111]的hash值爲380278925, 被路由到結點[192.168.0.0:111] [221.226.0.1:2222]的hash值爲1493545632, 被路由到結點[192.168.0.0:111] [10.211.0.1:3333]的hash值爲1393836017, 被路由到結點[192.168.0.2:111]
從代碼運行結果看,每一個點路由到的服務器都是Hash值順時針離它最近的那個服務器節點,沒有任何問題。
經過採起虛擬節點的方法,一個真實結點再也不固定在Hash換上的某個點,而是大量地分佈在整個Hash環上,這樣即便上線、下線服務器,也不會形成總體的負載不均衡。