原文地址:http://www.importnew.com/22488.html
一致性Hash算法
算法的具體原理這裏再次貼上:
先構造一個長度爲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,繼續前面的話題,應該如何選取數據結構,我認爲有以下幾種可行的解決方案。
1、解決方案一:排序+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),稍微耗費性能了一些。
2、解決方案二:遍歷+List
既然排序操作比較耗性能,那麼能不能不排序?可以的,所以進一步的,有了第二種解決方案。
解決方案使用List不變,不過可以採用遍歷的方式:
(1)服務器節點不排序,其Hash值全部直接放入一個List中
(2)帶路由的節點,算出其Hash值,由於指明瞭”順時針”,因此遍歷List,比待路由的節點Hash值大的算出差值並記錄,比待路由節點Hash值小的忽略
(3)算出所有的差值之後,最小的那個,就是最終需要路由過去的節點
在這個算法中,看一下時間複雜度:
1、最好情況是隻有一個服務器節點的Hash值大於帶路由結點的Hash值,其時間複雜度是O(N)+O(1)=O(N+1),忽略常數項,即O(N)
2、最壞情況是所有服務器節點的Hash值都大於帶路由結點的Hash值,其時間複雜度是O(N)+O(N)=O(2N),忽略首項係數,即O(N)
所以,總的時間複雜度就是O(N)。其實算法還能更改進一些:給一個位置變量X,如果新的差值比原差值小,X替換爲新的位置,否則X不變。這樣遍歷就減少了一輪,不過經過改進後的算法時間複雜度仍爲O(N)。
總而言之,這個解決方案和解決方案一相比,總體來看,似乎更好了一些。
3、解決方案三:二叉查找樹
拋開List這種數據結構,另一種數據結構則是使用二叉查找樹。對於樹不是很清楚的朋友可以簡單看一下這篇文章樹形結構。
當然我們不能簡單地使用二叉查找樹,因爲可能出現不平衡的情況。平衡二叉查找樹有AVL樹、紅黑樹等,這裏使用紅黑樹,選用紅黑樹的原因有兩點:
1、紅黑樹主要的作用是用於存儲有序的數據,這其實和第一種解決方案的思路又不謀而合了,但是它的效率非常高
2、JDK裏面提供了紅黑樹的代碼實現TreeMap和TreeSet
另外,以TreeMap爲例,TreeMap本身提供了一個tailMap(K fromKey)方法,支持從紅黑樹中查找比fromKey大的值的集合,但並不需要遍歷整個數據結構。
使用紅黑樹,可以使得查找的時間複雜度降低爲O(logN),比上面兩種解決方案,效率大大提升。
爲了驗證這個說法,我做了一次測試,從大量數據中查找第一個大於其中間值的那個數據,比如10000數據就找第一個大於5000的數據(模擬平均的情況)。看一下O(N)時間複雜度和O(logN)時間複雜度運行效率的對比:
50000 | 100000 | 500000 | 1000000 | 4000000 | |
ArrayList | 1ms | 1ms | 4ms | 4ms | 5ms |
LinkedList | 4ms | 7ms | 11ms | 13ms | 17ms |
TreeMap | 0ms | 0ms | 0ms | 0ms | 0ms |
因爲再大就內存溢出了,所以只測試到4000000數據。可以看到,數據查找的效率,TreeMap是完勝的,其實再增大數據測試也是一樣的,紅黑樹的數據結構決定了任何一個大於N的最小數據,它都只需要幾次至幾十次查找就可以查到。
當然,明確一點,有利必有弊,根據我另外一次測試得到的結論是,爲了維護紅黑樹,數據插入效率TreeMap在三種數據結構裏面是最差的,且插入要慢上5~10倍。
Hash值重新計算
服務器節點我們肯定用字符串來表示,比如”192.168.1.1″、”192.168.1.2″,根據字符串得到其Hash值,那麼另外一個重要的問題就是Hash值要重新計算,這個問題是我在測試String的hashCode()方法的時候發現的,不妨來看一下爲什麼要重新計算Hash值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
/** *
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以這種連續的形式存在是很正常的。看一下運行結果爲:
1
2
3
4
5
|
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個數字,而我們的區間只有114516604,從概率學上講這將導致97%待路由的服務器都被路由到”192.168.0.0″這個集羣點上,簡直是糟糕透了!
另外還有一個不好的地方:規定的區間是非負數,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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
/** *
不帶虛擬節點的一致性Hash算法 *
*/ 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(); } /** *
使用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 node) { //
得到帶路由的結點的Hash值 int
hash = getHash(node); //
得到大於該Hash值的所有Map SortedMap<Integer,
String> subMap = sortedMap.tailMap(hash); //
第一個Key就是順時針過去離node最近的那個結點 Integer
i = subMap.firstKey(); //
返回對應的服務器名稱 return
subMap.get(i); } public
static
void
main(String[] args) { String[]
nodes = { "127.0.0.1:1111" ,
"221.226.0.1:2222" ,
"10.211.0.1:3333" }; for
( int
i = 0 ;
i < nodes.length; i++) System.out.println( "["
+ nodes[i] + "]的hash值爲"
+ getHash(nodes[i])
+ ",
被路由到結點["
+ getServer(nodes[i]) + "]" ); } } |
可以運行一下看一下結果:
1
2
3
4
5
6
7
8
9
|
[ 192.168 . 0.0 : 111 ]加入集合中,
其Hash值爲 575774686 [ 192.168 . 0.1 : 111 ]加入集合中,
其Hash值爲 8518713 [ 192.168 . 0.2 : 111 ]加入集合中,
其Hash值爲 1361847097 [ 192.168 . 0.3 : 111 ]加入集合中,
其Hash值爲 1171828661 [ 192.168 . 0.4 : 111 ]加入集合中,
其Hash值爲 1764547046 [ 127.0 . 0.1 : 1111 ]的hash值爲 380278925 ,
被路由到結點[ 192.168 . 0.0 : 111 ] [ 221.226 . 0.1 : 2222 ]的hash值爲 1493545632 ,
被路由到結點[ 192.168 . 0.4 : 111 ] [ 10.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算法的理論基礎之後,就可以嘗試開發代碼了。編程方面需要考慮的問題是:
1、一個真實結點如何對應成爲多個虛擬節點?
2、虛擬節點找到後如何還原爲真實結點?
這兩個問題其實有很多解決辦法,我這裏使用了一種簡單的辦法,給每個真實結點後面根據虛擬節點加上後綴再取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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
|
/** *
帶虛擬節點的一致性Hash算法 *
@author 五月的倉頡 http://www.cnblogs.com/xrq730/ */ 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 node) { //
得到帶路由的結點的Hash值 int
hash = getHash(node); //
得到大於該Hash值的所有Map SortedMap<Integer,
String> subMap = virtualNodes.tailMap(hash); //
第一個Key就是順時針過去離node最近的那個結點 Integer
i = subMap.firstKey(); //
返回對應的虛擬節點名稱,這裏字符串稍微截取一下 String
virtualNode = subMap.get(i); return
virtualNode.substring( 0 ,
virtualNode.indexOf( "&&" )); } public
static
void
main(String[] args) { String[]
nodes = { "127.0.0.1:1111" ,
"221.226.0.1:2222" ,
"10.211.0.1:3333" }; for
( int
i = 0 ;
i < nodes.length; i++) System.out.println( "["
+ nodes[i] + "]的hash值爲"
+ getHash(nodes[i])
+ ",
被路由到結點["
+ getServer(nodes[i]) + "]" ); } } |
關注一下運行結果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
虛擬節點[ 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環上,這樣即使上線、下線服務器,也不會造成整體的負載不均衡