目錄
1 一致性哈希算法用途
一致性哈希是解決上線、下線後相同的請求儘可能的命中原來服務器的問題。
假如我們自己設計了一個高可用緩存系統,可以集羣部署,那麼我們每個節點上應該怎樣分配數據呢?
假如說我們存放一個k-v數據,這個數據需要怎麼確定存放節點?常用的方式是用key的哈希值對服務器節點取模,這樣實現比較簡單,但是帶來的問題就是緩存系統上線、下線節點後原來節點緩存的數據命中率打打大大降低。
三個節點的路由表
哈希值 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
路由 0 1 2 0 1 2 0 1 2 0 1 2 0 1 2
四個節點的路由表
哈希值 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
路由 0 1 2 3 0 1 2 3 0 1 2 3 0 1 2
可以看到只有6個哈希值在可以路要到原來的服務器,其餘的緩存都不能命中。這樣帶來的直接後果是
1 可能可能會帶來類似緩存雪崩的影響。緩存雪崩是指緩存數據大批量到過期時間,而查詢數據量巨大,引起數據庫壓力過大甚至宕機。
2 節點中大量無法命中的數據過期之前還在佔用內存。
2 一致性哈希算法介紹
爲了更好地提升緩存系統的伸縮性需要設計一個可靠的一致性哈希算法。
一致性哈希算法是構建一個哈希環來實現key到節點的映射。我們定義一個哈希環如下結構(0,1,2三個節點哈希值分別是0,5,10),每個節點是每個緩存系統的哈希值
0->5->10->0
路由規則是每個key落到剛好比它哈希值大的節點上。如果這個key大於環上所有的哈希值那麼就落在最小的哈希節點
三個節點的路由表
哈希值 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
路由 1 1 1 1 1 2 2 2 2 2 0 0 0 0 0
這個時候我們增加了一個節點,哈希值是12,那麼新的哈希環如下
0->5->10->12->0
四個節點的路由表
哈希值 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
路由 1 1 1 1 1 2 2 2 2 2 3 3 0 0 0
這個時候我們看到只有兩個哈希值沒有路由到原來的節點,服務擴容後只會影響新增服務下一個節點的緩存路由,很好的解決了取模路由暴露的兩個兩個問題。
3 一致性哈希算法實現
3.1 排序算法+二分查找
最優的排序算法時間複雜度是O(NlogN),二分查找算法時間複雜度是O(logN),所以這種方式的時間複雜度取決於耗時較長的排序算法,即O(NlogN)
3.2 直接遍歷
這種方式比較簡單,時間複雜度是O(N)
3.3 二叉查找樹
二叉查找樹的優點是查找效率高,時間複雜度是Olog(N),缺點是建樹過程比較耗性能。不過考慮到服務上下線場景比較有限,這個缺點可以忽略,下面我們就以二叉查找樹爲例來實現一致性哈希算法
4 TreeMap實現一致性哈希
4.1 紅黑樹介紹
滿足以下特徵的樹就是紅黑樹
1. 節點是紅色或者黑色
2. 根節點是黑色
3. 每個葉子的節點都是黑色的空節點(NULL)
4. 每個紅色節點的兩個子節點都是黑色的。
5. 從任意節點到其每個葉子的所有路徑都包含相同的黑色節點。
這些約束強制了紅黑樹的關鍵性質: 從根到葉子的最長的可能路徑不多於最短的可能路徑的兩倍長。
TreeMap就是紅黑樹的實現。
4.2 哈希再計算
我們看一下調用String默認的哈希算法計算節點ip的哈希值
127.0.0.1:8080->127.0.0.1:8084
@Test
public void testStringHash() {
// 5個server服務器
for (int i = 0; i < 5; i ++) {
String server = "127.0.0.1:808" + i;
System.out.println(server + "->" + server.hashCode());
}
}
輸出:
127.0.0.1:8080->-35736627
127.0.0.1:8081->-35736626
127.0.0.1:8082->-35736625
127.0.0.1:8083->-35736624
127.0.0.1:8084->-35736623
可以看到這種方式計算的結果,哈希值根本散不開
重新計算Hash值的算法有很多,可以參考https://blog.csdn.net/whut_gyx/article/details/39002191瞭解下
這裏我們採用散列效果和性能都不錯的FNV1_32_HASH算法
private int fnv32Hash(String str) {
final int p = 16777619;
int hash = -2128831035;
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;
}
測試代碼
@Test
public void testFnv32Hash() {
// 5個server服務器
for (int i = 0; i < 5; i ++) {
String server = "127.0.0.1:808" + i;
System.out.println(server + "->" + fnv32Hash(server));
}
}
輸出:
127.0.0.1:8080->1617490351
127.0.0.1:8081->674407738
127.0.0.1:8082->1511613106
127.0.0.1:8083->1255419186
127.0.0.1:8084->265259256
可以看到散列效果還是不錯的
4.3 一致性哈希算法代碼實現
// key list
private List<String> keyList;
// 緩存服務器ip集合
private List<String> serverList;
// 緩存服務器構建的二叉樹
private SortedMap<Integer, String> sortedMap = new TreeMap<>();
@Before
public void init() {
// 100個key
keyList = new ArrayList<>();
for (int i = 0; i < 100; i ++) {
keyList.add("key" + i);
}
// 5個server服務器
serverList = new ArrayList<>();
for (int i = 0; i < 5; i ++) {
serverList.add("127.0.0.1:808" + i);
}
}
// 獲取key路由到的服務器
private String getServer(String key) {
// 得到帶路由的結點的Hash值
int hash = fnv32Hash(key);
// 得到大於該Hash值的所有Map
SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
if (subMap.size() <= 0) {
subMap = sortedMap;
}
// 第一個Key就是順時針過去離node最近的那個結點
Integer i = subMap.firstKey();
// 返回對應的服務器名稱
return subMap.get(i);
}
private int fnv32Hash(String str) {
final int p = 16777619;
int hash = -2128831035;
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;
}
測試代碼:
@Test
public void testConsistentHash() {
for (String server : serverList) {
int hash = fnv32Hash(server);
System.out.println("[" + server + "]加入集合中, 其Hash值爲" + hash);
sortedMap.put(hash, server);
}
Map<String, List<String>> map = new HashMap<>();
for (String key : keyList) {
String server = getServer(key);
List<String> list = map.get(server);
if (Objects.isNull(list)) {
list = new ArrayList<>();
map.put(server, list);
}
list.add(key);
}
map.forEach((k, v) -> System.out.println(k + "->" + v.size() + ":" + v));
}
輸出:
[127.0.0.1:8080]加入集合中, 其Hash值爲1617490351
[127.0.0.1:8081]加入集合中, 其Hash值爲674407738
[127.0.0.1:8082]加入集合中, 其Hash值爲1511613106
[127.0.0.1:8083]加入集合中, 其Hash值爲1255419186
[127.0.0.1:8084]加入集合中, 其Hash值爲265259256
127.0.0.1:8081->12:[key13, key19, key30, key49, key61, key63, key64, key70, key72, key92, key95, key98]
127.0.0.1:8082->11:[key0, key10, key21, key34, key39, key45, key51, key60, key81, key86, key96]
127.0.0.1:8080->8:[key4, key14, key43, key44, key56, key84, key87, key89]
127.0.0.1:8083->27:[key6, key7, key9, key11, key20, key25, key26, key27, key28, key33, key40, key41, key42, key46, key47, key52, key55, key65, key67, key71, key74, key77, key78, key90, key91, key93, key97]
127.0.0.1:8084->42:[key1, key2, key3, key5, key8, key12, key15, key16, key17, key18, key22, key23, key24, key29, key31, key32, key35, key36, key37, key38, key48, key50, key53, key54, key57, key58, key59, key62, key66, key68, key69, key73, key75, key76, key79, key80, key82, key83, key85, key88, key94, key99]
4.4 一致性哈希算法優化(虛擬節點)
上述代碼一致性哈希算法我們可以看到每個key可以被正確路由到對應的節點,但是有另一個問題,分配不均。我們看到分配最少緩存的節點上只有8個緩存,最多緩存節點上有42個緩存
解決這個問題的辦法是引入虛擬節點,其工作原理是:將一個物理節點拆分爲多個虛擬節點,並且同一個物理節點的虛擬節點儘量均勻分佈在Hash環上。採取這樣的方式,就可以有效地解決增加或減少節點時候的負載不均衡的問題。
帶有虛擬節點的一致性哈希算法實現
@Test
public void testConsistentVirtualHash() {
List<String> virtualServerList = new ArrayList<>();
// 每個server虛擬5個地址
int virtualServerCount = 5;
for (String server: serverList) {
for (int i = 0; i < virtualServerCount; i ++) {
String virtualServer = server + "@VN" + i;
virtualServerList.add(virtualServer);
}
}
for (String virtualServer : virtualServerList) {
int hash = fnv32Hash(virtualServer);
System.out.println("[" + virtualServer + "]加入集合中, 其Hash值爲" + hash);
sortedMap.put(hash, virtualServer);
}
Map<String, List<String>> map = new HashMap<>();
for (String key : keyList) {
String virtualServer = getServer(key);
// 解析出真正的ip
String realServer = virtualServer.split("@")[0];
List<String> list = map.get(realServer);
if (Objects.isNull(list)) {
list = new ArrayList<>();
map.put(realServer, list);
}
list.add(key);
}
map.forEach((k, v) -> System.out.println(k + "->" + v.size() + ":" + v));
}
輸出
[127.0.0.1:8080@VN0]加入集合中, 其Hash值爲1653752734
[127.0.0.1:8080@VN1]加入集合中, 其Hash值爲2045770422
[127.0.0.1:8080@VN2]加入集合中, 其Hash值爲642460142
[127.0.0.1:8080@VN3]加入集合中, 其Hash值爲2064903931
[127.0.0.1:8080@VN4]加入集合中, 其Hash值爲134338595
[127.0.0.1:8081@VN0]加入集合中, 其Hash值爲1207025989
[127.0.0.1:8081@VN1]加入集合中, 其Hash值爲1416458113
[127.0.0.1:8081@VN2]加入集合中, 其Hash值爲2109124764
[127.0.0.1:8081@VN3]加入集合中, 其Hash值爲487588720
[127.0.0.1:8081@VN4]加入集合中, 其Hash值爲887324084
[127.0.0.1:8082@VN0]加入集合中, 其Hash值爲27662755
[127.0.0.1:8082@VN1]加入集合中, 其Hash值爲1353238534
[127.0.0.1:8082@VN2]加入集合中, 其Hash值爲1234344991
[127.0.0.1:8082@VN3]加入集合中, 其Hash值爲1502278984
[127.0.0.1:8082@VN4]加入集合中, 其Hash值爲1362517544
[127.0.0.1:8083@VN0]加入集合中, 其Hash值爲1128722563
[127.0.0.1:8083@VN1]加入集合中, 其Hash值爲1998095489
[127.0.0.1:8083@VN2]加入集合中, 其Hash值爲2077514034
[127.0.0.1:8083@VN3]加入集合中, 其Hash值爲1266869294
[127.0.0.1:8083@VN4]加入集合中, 其Hash值爲842010729
[127.0.0.1:8084@VN0]加入集合中, 其Hash值爲263233063
[127.0.0.1:8084@VN1]加入集合中, 其Hash值爲1695356940
[127.0.0.1:8084@VN2]加入集合中, 其Hash值爲956902
[127.0.0.1:8084@VN3]加入集合中, 其Hash值爲2050272550
[127.0.0.1:8084@VN4]加入集合中, 其Hash值爲1840275863
127.0.0.1:8081->11:[key7, key9, key42, key47, key59, key61, key64, key67, key70, key71, key99]
127.0.0.1:8082->8:[key0, key21, key39, key45, key60, key79, key81, key96]
127.0.0.1:8080->31:[key3, key4, key10, key12, key13, key14, key19, key22, key23, key24, key30, key31, key34, key43, key44, key49, key50, key51, key54, key56, key57, key63, key66, key72, key73, key82, key84, key86, key87, key89, key98]
127.0.0.1:8083->30:[key5, key6, key8, key11, key17, key18, key20, key25, key26, key27, key28, key33, key38, key40, key41, key46, key52, key55, key65, key74, key77, key78, key85, key90, key91, key92, key93, key94, key95, key97]
127.0.0.1:8084->20:[key1, key2, key15, key16, key29, key32, key35, key36, key37, key48, key53, key58, key62, key68, key69, key75, key76, key80, key83, key88]
可以看到我們虛擬5個節點後分配最少緩存的節點上只有8個緩存,最多緩存節點上有31個緩存,提高了緩存分配均衡性