一致性哈希的分析与实现
哈希函數,想必大家都不陌生。通過哈希函數我們可以將數據映射成一個數字(哈希值),然后可用于將數據打亂。例如,在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值,那取模的結果將不會改變。這里的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-0A1:?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
有道無術,術可成;有術無道,止于術
歡迎大家關注Java之道公眾號
好文章,我在看??
總結
以上是生活随笔為你收集整理的一致性哈希的分析与实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 性能对比:Count(字段)、Count
- 下一篇: 惊呆了!不改一行 Java 代码竟然就能