家喻戶曉的一致性 Hash 演算法是解決資料分散佈局或者說分散式環境下系統伸縮性差的優質解,本文旨在使用 Java 語言手動實現一套該演算法。
一、背景
最簡單的一個應用場景便是快取,當單機快取量過大時需要分庫,然後根據相關資訊進行 hash 取模運算到指定的機器上去,比如 index = hash(ip) % N。
但是當增加或者減少節點的時候,由於上述公式的 N 值是有變化的,所以絕大部分,甚至說所有的快取都會失效,對於這種場景最直接的解決辦法便是使用一致性 hash 演算法。
二、一致性 Hash 演算法簡介
1、簡單的一致性 Hash
關於一致性 Hash 演算法,簡單的抽象描述就是一個圓環,然後上面均勻佈局了 2^32 個節點,比如 [0,1,2,4,8…],然後將我們的機器節點雜湊在這個圓環上,至於雜湊的規則,可以使用 hash(ip) 或者 hash(域名)。
當尋找資料的時候,只需要將目標資料的key雜湊在這個環上,然後進行順時針讀取最近的一個機器節點上的資料即可。
如下圖的簡單版本,假如總共有3個資料節點(A、B、C),當需要查詢的資料的key經計算在A和B之間,則順時針找,便找到了節點B。
最大的優點是:還是以上面的案例為背景,當節點B宕了之後,順時針便找到了C,這樣,影響的範圍僅僅是A和B之間的資料,對於其他的資料是不影響的。
2、虛擬節點
但是在雜湊資料節點的時候,緊湊性會受 hash 演算法的影響,比如A、B、C三個資料伺服器,在 hash 計算後雜湊在 1、2、4三個節點上,這樣就會因為太密集而失去**平衡性。**比如此時我們要查詢的資料的key經過 hash 運算之後,大概率是出現在4和1之間的,即在C之後,那樣的話順時針查詢便會找到A,那麼A伺服器便承載了幾乎所有的負載,這就失去了該演算法的意義。
此時虛擬節點便出現了,比如上述的三臺伺服器各虛擬分裂出1各虛擬節點(A1、B1、C1),那麼這樣便可以在一定程度上解決一致性hash的平衡性問題。
三、陣列簡陋版
1、思路
簡單描述下思路:其實就是使用一個陣列去儲存所有的節點資訊,存完之後需要手動排序一下,因為是有序的,所以取的時候就從 index 為0開始挨個對比節點的 hash 值,直到找到一個節點的 hash 值是比我們的目標資料的 hash(key) 大即可,否則返回第一個節點的資料。
2、程式碼其實很簡單,直接擼:
package com.jet.mini.utils;
import java.util.Arrays;
/**
* @ClassName: SortArrayConsistentHash
* @Description: 初代陣列實現的一致性哈數演算法
* @Author: Jet.Chen
* @Date: 2019/3/19 23:11
* @Version: 1.0
**/
public class SortArrayConsistentHash {
/**
* 最為核心的資料結構
*/
private Node[] buckets;
/**
* 桶的初始大小
*/
private static final int INITIAL_SIZE = 32;
/**
* 當前桶的大小
*/
private int length = INITIAL_SIZE;
/**
* 當前桶的使用量
*/
private int size = 0;
public SortArrayConsistentHash(){
buckets = new Node[INITIAL_SIZE];
}
/**
* 指定陣列長度的構造
*/
public SortArrayConsistentHash(int length){
if (length < 32) {
buckets = new Node[INITIAL_SIZE];
} else {
this.length = length;
buckets = new Node[length];
}
}
/**
* @Description: 寫入資料
* @Param: [hash, value]
* @return: void
* @Author: Jet.Chen
* @Date: 2019/3/19 23:38
*/
public void add(long hash, String value){
// 大小判斷是否需要擴容
if (size == length) reSize();
Node node = new Node(value, hash);
buckets[++size] = node;
}
/**
* @Description: 刪除節點
* @Param: [hash]
* @return: boolean
* @Author: Jet.Chen
* @Date: 2019/3/20 0:24
*/
public boolean del(long hash) {
if (size == 0) return false;
Integer index = null;
for (int i = 0; i < length; i++) {
Node node = buckets[i];
if (node == null) continue;
if (node.hash == hash) index = i;
}
if (index != null) {
buckets[index] = null;
return true;
}
return false;
}
/**
* @Description: 排序
* @Param: []
* @return: void
* @Author: Jet.Chen
* @Date: 2019/3/19 23:48
*/
public void sort() {
// 此處的排序不需要關注 eqals 的情況
Arrays.sort(buckets, 0, size, (o1, o2) -> o1.hash > o2.hash ? 1 : -1);
}
/**
* @Description: 擴容
* @Param: []
* @return: void
* @Author: Jet.Chen
* @Date: 2019/3/19 23:42
*/
public void reSize() {
// 擴容1.5倍
int newLength = length >> 1 + length;
buckets = Arrays.copyOf(buckets, newLength);
}
/**
* @Description: 根據一致性hash演算法獲取node值
* @Param: [hash]
* @return: java.lang.String
* @Author: Jet.Chen
* @Date: 2019/3/20 0:16
*/
public String getNodeValue(long hash) {
if (size == 0) return null;
for (Node bucket : buckets) {
// 防止空節點
if (bucket == null) continue;
if (bucket.hash >= hash) return bucket.value;
}
// 防止迴圈無法尾部對接首部
// 場景:僅列出node的hash值,[null, 2, 3...],但是尋求的hash值是4,上面的第一遍迴圈很顯然沒能找到2這個節點,所有需要再迴圈一遍
for (Node bucket : buckets) {
if (bucket != null) return bucket.value;
}
return null;
}
/**
* node 記錄了hash值和原始的IP地址
*/
private class Node {
public String value;
public long hash;
public Node(String value, long hash) {
this.value = value;
this.hash = hash;
}
@Override
public String toString() {
return "Node{hash="+hash+", value="+value+"}";
}
}
}
複製程式碼
3、弊端
① 排序演算法:上面直接使用 Arrays.sort() ,即 TimSort 排序演算法,這個值得改進;
② hash 演算法:上文沒有提及 hash 演算法,需要改進;
③ 資料結構:上文使用的是陣列,但是需要手動進行排序,優點是插入速度尚可,但是擴容不便,而且需要手動排序,排序的時機也不定,需要改進;
④ 虛擬節點:沒有考慮虛擬節點,需要改進。
四、TreeMap 進階版
上文的實現既然有弊端,那就操刀改進之:
① 資料結構:我們可以使用 TreeMap 資料結構,優點是該資料結構是有序的,無需再排序,而且該資料結構中有個函式叫 tailMap,作用是獲取比指定的 key 大的資料集合;
② hash 演算法:此處我們使用 FNV1_32_HASH 演算法,該演算法證實下來雜湊分佈比較均勻,hash 碰撞尚且 ok;
③ 虛擬節點:我們暫且設定每個節點鎖裂變的虛擬節點數量為10。
程式碼也不難,也是直接擼:
package com.jet.mini.utils;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* @ClassName: TreeMapConsistentHash
* @Description: treeMap 實現的進化版一致性hash
* @Author: Jet.Chen
* @Date: 2019/3/20 20:44
* @Version: 1.0
**/
public class TreeMapConsistentHash {
/**
* 主要資料結構
*/
private TreeMap<Long, String> treeMap = new TreeMap<>();
/**
* 自定義虛擬節點數量
*/
private static final int VIRTUAL_NODE_NUM = 10;
/**
* 普通的增加節點
*/
@Deprecated
public void add (String key, String value) {
long hash = hash(key);
treeMap.put(hash, value);
}
/**
* 存在虛擬節點
*/
public void add4VirtualNode(String key, String value) {
for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
long hash = hash(key + "&&VIR" + i);
treeMap.put(hash, value);
}
treeMap.put(hash(key), value);
}
/**
* 讀取節點值
* @param key
* @return
*/
public String getNode(String key) {
long hash = hash(key);
SortedMap<Long, String> sortedMap = treeMap.tailMap(hash);
String value;
if (!sortedMap.isEmpty()) {
value = sortedMap.get(sortedMap.firstKey());
} else {
value = treeMap.firstEntry().getValue();
}
return value;
}
/**
* 使用的是 FNV1_32_HASH
*/
public long hash(String key) {
final int p = 16777619;
int hash = (int)2166136261L;
for(int i = 0; i < key.length(); i++) {
hash = (hash ^ key.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;
}
}
複製程式碼
五、其他
1、虛擬節點的數量建議:
看上圖,X 軸是虛擬節點數量,Y 軸是伺服器數量,很顯然,伺服器越多,建議的虛擬節點數量也就越少。