哈希函數,想必大家都不陌生。通過哈希函數我們可以將數據映射成一個數字(哈希值),然後可用於將數據打亂。例如,在HashMap中則是通過哈希函數使得每個桶中的數據儘量均勻。那一致性哈希又是什麼?它是用於解決什麼問題?本文將從普通的哈希函數說起,看看普通哈希函數存在的問題,然後再看一致性哈希是如何解決,一步步進行分析,並結合代碼實現來講解。
首先,設定這樣一個場景,我們每天有1千萬條業務數據,還有100個節點可用於存放數據。那我們希望能將數據儘量均勻地存放在這100個節點上,這時候哈希函數就能派上用場了,下面我們按一天的數據量來說明。
首先,準備下需要存放的數據,以及節點的地址。爲了簡單,這裏的數據爲隨機整型數字,節點的地址爲從“192.168.1.0”開始遞增。
private static int dataNum = 10000000;
private static int nodeNum = 100;
private static List<Integer> datas = initData(dataNum);
private static List<String> nodes = initNode(nodeNum);
private static List<Integer> initData(int n) {
List<Integer> datas = new ArrayList<>();
Random random = new Random();
for (int i = 0; i < n; i++) {
datas.add(random.nextInt());
}
return datas;
}
private static List<String> initNode(int n) {
List<String> nodes = new ArrayList<>();
for (int i = 0; i < n; i++) {
nodes.add(String.format("192.168.1.%d", i));
}
return nodes;
}
接下來,我們看下通過“哈希+取模”得到數據相應的節點地址。這裏的hash方法使用Guava提供的哈希方法來實現,後文也將繼續使用該hash方法。
public static String normalHash(Integer data, List<String> nodes) {
int hash = hash(data);
int nodeIndex = hash % nodes.size();
return nodes.get(nodeIndex);
}
private static int hash(Object object) {
HashFunction hashFunction = Hashing.murmur3_32();
if (object instanceof Integer) {
return Math.abs(hashFunction.hashInt((Integer) object).asInt());
} else if (object instanceof String) {
return Math.abs(hashFunction.hashUnencodedChars((String) object).asInt());
}
return -1;
}
最後,我們對數據的分佈情況進行統計,觀察分佈是否均勻,這裏通過標準差來觀察。
public static void normalHashMain() {
Map<String, Integer> nodeCount = new HashMap<>();
for (Integer data : datas) {
String node = normalHash(data, nodes);
if (nodeCount.containsKey(node)) {
nodeCount.put(node, nodeCount.get(node) + 1);
} else {
nodeCount.put(node, 1);
}
}
analyze(nodeCount, dataNum, nodeNum);
}
public static void analyze(Map<String, Integer> nodeCount, int dataNum, int nodeNum) {
double average = (double) dataNum / nodeNum;
IntSummaryStatistics s1
= nodeCount.values().stream().mapToInt(Integer::intValue).summaryStatistics();
int max = s1.getMax();
int min = s1.getMin();
int range = max - min;
double standardDeviation
= nodeCount.values().stream().mapToDouble(n -> Math.abs(n - average)).summaryStatistics().getAverage();
System.out.println(String.format("平均值:%.2f", average));
System.out.println(String.format("最大值:%d,(%.2f%%)", max, 100.0 * max / average));
System.out.println(String.format("最小值:%d,(%.2f%%)", min, 100.0 * min / average));
System.out.println(String.format("極差:%d,(%.2f%%)", range, 100.0 * range / average));
System.out.println(String.format("標準差:%.2f,(%.2f%%)", standardDeviation, 100.0 * standardDeviation / average));
}
/**
平均值:100000.00
最大值:100818,(100.82%)
最小值:99252,(99.25%)
極差:1566,(1.57%)
標準差:240.08,(0.24%)
**/
其中標準差較小,說明分佈較爲均勻,那我們的需求達到了。
接着,隨着業務的發展,你發現100個節點不夠用了,我們希望再增加10個節點,來提高系統性能。而我們還將繼續採用之前的方法來分佈數據。這時候就出現了一個新的問題,我們是通過“哈希+取模”來決定數據的相應節點,原來數據的哈希值是不會改變的,可是取模的時候節點的數量發生了變化,這將導致的結果就是原來的數據存在A節點,現在可能需要遷移到B節點,也就是數據遷移問題。下面我們來看下有多少數據將發生遷移。
private static int newNodeNum = 11;
private static List<String> newNodes = initNode(newNodeNum);
public static void normalHashMigrateMain() {
int migrateCount = 0;
for (Integer data : datas) {
String node = normalHash(data, nodes);
String newNode = normalHash(data, newNodes);
if (!node.equals(newNode)) {
migrateCount++;
}
}
System.out.println(String.format("數據遷移量:%d(%.2f%%)", migrateCount, migrateCount * 100.0 / datas.size()));
}
/**
數據遷移量:9091127(90.91%)
**/
有90%多的數據都需要進行遷移,這是幾乎全部的量了。普通哈希的問題暴露出來了,當將節點由100擴展爲110時,會存在大量的遷移工作。在1997年MIT提出了一致性哈希算法,用於解決普通哈希的這一問題。
我們再分析下,假設hash值爲10000,nodeNum爲100,那按照index = hash % nodeNum
得到的結果是0,而將100變爲110時,取模的結果將改變爲100。如果我們將取模的底數變大到大於hash值,那hash值的結果將仍是其本身,不會由於底數的微小變化而發生改變。這裏的hash值是int,4個字節,那我們把底數固定爲2^32-1,index = hash % (2^32-1)
。取模的結果也將固定在0到2^32-1中,將其構成一個環,如下所示。
現在的底數是2^32-1,hash值爲10000,取模的結果爲10000,而我們有100個節點,該映射到哪個節點上呢?我們可以先將節點通過哈希映射到環上。爲了繪圖方便,我們以3個節點爲例,如下圖所示:
10000落到環上後,如果沒有對應的節點,則按順時針方向找到下一個節點,便爲hash值對應的節點。下面我們用Java的TreeMap
來存節點的hash值,利用TreeMap的tailMap
尋找節點。
我們使用和之前同樣的方法,測試下當節點由100變爲110時,數據需要遷移的情況,如下所示:
public static void consistHashMigrateMain() {
int migrateCount = 0;
SortedMap<Integer, String> circle = new TreeMap<>();
for (String node : nodes) {
circle.put(hash(node), node);
}
SortedMap<Integer, String> newCircle = new TreeMap<>();
for (String node : newNodes) {
newCircle.put(hash(node), node);
}
for (Integer data : datas) {
String node = consistHash(data, circle);
String newNode = consistHash(data, newCircle);
if (!node.equals(newNode)) {
migrateCount++;
}
}
System.out.println(String.format("數據遷移量:%d(%.2f%%)", migrateCount, migrateCount * 100.0 / datas.size()));
}
public static String consistHash(Integer data, SortedMap<Integer, String> circle) {
int hash = hash(data);
// 從環中取大於等於hash值的部分
SortedMap<Integer, String> subCircle = circle.tailMap(hash);
int index;
// 如果在大於等於hash值的部分沒有節點,則取環開始的第一個節點
if (subCircle.isEmpty()) {
index = circle.firstKey();
} else {
index = subCircle.firstKey();
}
return circle.get(index);
}
/**
數據遷移量:817678(8.18%)
**/
可見需要遷移的數據由90%降到了8%,效果十分可觀。那我們再看下數據的分佈情況,是否仍然均勻:
/**
平均值:100000.00
最大值:589675,(589.68%)
最小值:227,(0.23%)
極差:589448,(589.45%)
標準差:77421.44,(77.42%)
**/
77%的標準差,一個字,崩!這是爲啥?我們原本設想的是節點映射到環上時,能將環均勻劃分,所以當數據映射到環上時,也將被均勻分佈到節點上。而實際情況,由於節點地址相似,映射到環上的位置也將相近,所以造成分佈的不均勻,如下圖所示:
由於A、B、C的地址相似,例如:
A: 192.168.1.0
B: 192.168.1.1
C: 192.168.1.2
所以映射的位置相近,那我們可以複製幾份A、B、C,並且通過改變key,讓節點能更均勻的劃分環。比如我們在地址後面追加 “-index” 的序號,例如:
A0: 192.168.1.0-0
B0: 192.168.1.1-0
C0: 192.168.1.2-0
A1: 192.168.1.0-1
B1: 192.168.1.1-1
C1: 192.168.1.2-1
雖然A0、B0、C0會相距較近,但是和A1、B1、C1的key具有差別,將能夠成功分開,這也正是虛擬節點的作用。達到的效果如下:
下面我們通過代碼驗證下實際效果:
private static int vNodeNum = 100;
public static void consistHashVirtualNodeMain() {
Map<String, Integer> nodeCount = new HashMap<>();
SortedMap<Integer, String> circle = new TreeMap<>();
for (String node : nodes) {
for (int i = 0; i < vNodeNum; i++) {
circle.put(hash(node + "-" + i), node);
}
}
for (Integer data : datas) {
String node = consistHashVirtualNode(data, circle);
if (nodeCount.containsKey(node)) {
nodeCount.put(node, nodeCount.get(node) + 1);
} else {
nodeCount.put(node, 1);
}
}
analyze(nodeCount, dataNum, nodeNum);
}
/**
平均值:100000.00
最大值:122931,(122.93%)
最小值:74434,(74.43%)
極差:48497,(48.50%)
標準差:7475.08,(7.48%)
**/
可看到標準差已經由77%降到7%,效果顯著。再多做幾組實驗,標準差隨着虛擬節點數的變化如下:
虛擬節點數 | 標準差 |
---|---|
10 | 21661.04,(21.66%) |
100 | 7475.08,(7.48%) |
1000 | 2498.36,(2.50%) |
10000 | 858.96,(0.86%) |
100000 | 363.98,(0.36%) |
結果中,隨着虛擬節點數的增加,標準差逐步下降。可見虛擬節點能達到均勻分佈數據的效果。
一句話總結下:
一致性哈希可用於解決哈希函數在擴容時的數據遷移的問題,而一致性哈希的實現中需要藉助虛擬節點來均勻分佈數據。
最後,大家可以再思考兩個問題:
- 虛擬節點越多越好嗎?會不會有什麼負面影響?
- Java中的HashMap在擴容時如何優化數據遷移問題?
文中的代碼已上傳至github,感興趣的同學可以自己試下:
https://github.com/chaycao/Learn/tree/master/LearnConsistHash