哈希函數,想必你們都不陌生。經過哈希函數咱們能夠將數據映射成一個數字(哈希值),而後可用於將數據打亂。例如,在HashMap中則是經過哈希函數使得每一個桶中的數據儘可能均勻。那一致性哈希又是什麼?它是用於解決什麼問題?本文將從普通的哈希函數提及,看看普通哈希函數存在的問題,而後再看一致性哈希是如何解決,一步步進行分析,並結合代碼實現來說解。node
首先,設定這樣一個場景,咱們天天有1千萬條業務數據,還有100個節點可用於存放數據。那咱們但願能將數據儘可能均勻地存放在這100個節點上,這時候哈希函數就能派上用場了,下面咱們按一天的數據量來講明。git
首先,準備下須要存放的數據,以及節點的地址。爲了簡單,這裏的數據爲隨機整型數字,節點的地址爲從「192.168.1.0」開始遞增。github
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; }
最後,咱們對數據的分佈狀況進行統計,觀察分佈是否均勻,這裏經過標準差來觀察。dom
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%) **/
其中標準差較小,說明分佈較爲均勻,那咱們的需求達到了。ide
接着,隨着業務的發展,你發現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值,那取模的結果將不會改變。這裏的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會相距較近,可是和A一、B一、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%)
結果中,隨着虛擬節點數的增長,標準差逐步降低。可見虛擬節點能達到均勻分佈數據的效果。
一句話總結下:
一致性哈希可用於解決哈希函數在擴容時的數據遷移的問題,而一致性哈希的實現中須要藉助虛擬節點來均勻分佈數據。
最後,你們能夠再思考兩個問題: