一致性Hash原理與實現
前言
網際網路公司中,絕大部分都沒有馬爸爸系列的公司那樣財大氣粗,他們即沒有強勁的伺服器、也沒有錢去購買昂貴的海量資料庫。那他們是怎麼應對大資料量高併發的業務場景的呢?
這個和當前的開源技術、海量資料架構都有著不可分割的關係。比如通過mysql、nginx等開源軟體,通過架構和低成本的伺服器搭建千萬級別的使用者訪問系統。
怎麼樣搭建一個好的系統架構,這個話題我們能聊上個七天七夜。這裡我主要結合Redis叢集來講一下一致性Hash的相關問題。
Redis叢集的使用
我們在使用Redis的過程中,為了保證Redis的高可用,我們一般會對Redis做主從複製,組成Master-Master
或者Master-Slave
圖1-1:Master-Slave模式
當快取資料量超過一定的數量時,我們就要對Redis叢集做分庫分表的操作。
來個栗子,我們有一個電商平臺,需要使用Redis儲存商品的圖片資源,儲存的格式為鍵值對,key值為圖片名稱,Value為該圖片所在的檔案伺服器的路徑,我們需要根據檔名,查詢到檔案所在的檔案伺服器上的路徑,我們的圖片數量大概在3000w左右,按照我們的規則進行分庫,規則就是隨機分配的,我們以每臺伺服器存500w的數量,部署12臺快取伺服器,並且進行主從複製,架構圖如下圖1-2所示: 圖1-2:Redis分庫分表由於我們定義的規則是隨機的,所以我們的資料有可能儲存在任何一組Redis中,比如我們需要查詢"product.png"的圖片,由於規則的隨機性,我們需要遍歷所有Redis伺服器,才能查詢得到。這樣的結果顯然不是我們所需要的。所以我們會想到按某一個欄位值進行Hash值、取模。所以我們就看看使用Hash的方式是怎麼進行的。
使用Hash的Redis叢集
如果我們使用Hash的方式,每一張圖片在進行分庫的時候都可以定位到特定的伺服器,示意圖如圖1-3所示:
圖1-3:使用Hash方式的命中快取
從上圖中,我們需要查詢的是圖product.png
,由於我們有6臺主伺服器,所以計算的公式為:hash(product.png) % 6 = 5
, 我們就可以定位到是5號主從,這們就省去了遍歷所有伺服器的時間,從而大大提升了效能。
使用Hash時遇到的問題
在上述hash取模的過程中,我們雖然不需要對所有Redis伺服器進行遍歷而提升了效能。但是,使用Hash演算法快取時會出現一些問題,Redis伺服器變動時,所有快取的位置都會發生改變
比如,現在我們的Redis快取伺服器增加到了8臺,我們計算的公式從
hash(product.png) % 6 = 5
變成了hash(product.png) % 8 = ?
結果肯定不是原來的5了。再者,6臺的伺服器叢集中,當某個主從群出現故障時,無法進行快取,那我們需要把故障機器移除,所以取模數又會從6變成了5。我們計算的公式也會變化。
由於上面hash演算法是使用取模來進行快取的,為了規避上述情況,Hash一致性演算法就誕生了~~
一致性Hash演算法原理
一致性Hash演算法也是使用取模的方法,不過,上述的取模方法是對伺服器的數量進行取模,而一致性的Hash演算法是對2的32方
取模。即,一致性Hash演算法將整個Hash空間組織成一個虛擬的圓環,Hash函式的值空間為0 ~ 2^32 - 1(一個32位無符號整型)
,整個雜湊環如下:
整個圓環以
順時針方向組織
,圓環正上方的點代表0,0點右側的第一個點代表1,以此類推。第二步,我們將各個伺服器使用Hash進行一個雜湊,具體可以選擇伺服器的IP或主機名作為關鍵字進行雜湊,這樣每臺伺服器就確定在了雜湊環的一個位置上,比如我們有三臺機器,使用IP地址雜湊後在環空間的位置如圖1-4所示:
圖1-4:伺服器在雜湊環上的位置
現在,我們使用以下演算法定位資料訪問到相應的伺服器:
將資料Key使用相同的函式Hash計算出雜湊值,並確定此資料在環上的位置,從此位置沿環順時針查詢,遇到的伺服器就是其應該定位到的伺服器。
例如,現在有ObjectA,ObjectB,ObjectC三個資料物件,經過雜湊計算後,在環空間上的位置如下:
圖1-5:資料物件在環上的位置
根據一致性演算法,Object -> NodeA,ObjectB -> NodeB, ObjectC -> NodeC
一致性Hash演算法的容錯性和可擴充套件性
現在,假設我們的Node C宕機了,我們從圖中可以看到,A、B不會受到影響,只有Object C物件被重新定位到Node A。所以我們發現,在一致性Hash演算法中,如果一臺伺服器不可用,受影響的資料僅僅是此伺服器到其環空間前一臺伺服器之間的資料(這裡為Node C到Node B之間的資料),其他不會受到影響。如圖1-6所示:
圖1-6:C節點宕機情況,資料移到節點A上
另外一種情況,現在我們系統增加了一臺伺服器Node X,如圖1-7所示:
圖1-7:增加新的伺服器節點X
此時物件ObjectA、ObjectB沒有受到影響,只有Object C重新定位到了新的節點X上。
如上所述:
一致性Hash演算法對於節點的增減都只需重定位環空間中的一小部分資料,有很好的容錯性和可擴充套件性。
資料傾斜問題
在一致性Hash演算法服務節點太少的情況下,容易因為節點分佈不均勻面造成資料傾斜(被快取的物件大部分快取在某一臺伺服器上)問題
,如圖1-8特例:
這時我們發現有大量資料集中在節點A上,而節點B只有少量資料。為了解決資料傾斜問題,一致性Hash演算法引入了
虛擬節點機制
,即對每一個伺服器節點計算多個雜湊,每個計算結果位置都放置一個此服務節點,稱為虛擬節點。具體操作可以為伺服器IP或主機名後加入編號來實現,實現如圖1-9所示:
圖1-9:增加虛擬節點情況
資料定位演算法不變,只需要增加一步:虛擬節點到實際點的對映。
所以加入虛擬節點之後,即使在服務節點很少的情況下,也能做到資料的均勻分佈。
具體實現
演算法介面類
public interface IHashService { Long hash(String key); }
演算法介面實現類
public class HashService implements IHashService { /** * MurMurHash演算法,效能高,碰撞率低 * * @param key String * @return Long */ public Long hash(String key) { ByteBuffer buf = ByteBuffer.wrap(key.getBytes()); int seed = 0x1234ABCD; ByteOrder byteOrder = buf.order(); buf.order(ByteOrder.LITTLE_ENDIAN); long m = 0xc6a4a7935bd1e995L; int r = 47; long h = seed ^ (buf.remaining() * m); long k; while (buf.remaining() >= 8) { k = buf.getLong(); k *= m; k ^= k >>> r; k *= m; h ^= k; h *= m; } if (buf.remaining() > 0) { ByteBuffer finish = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); finish.put(buf).rewind(); h ^= finish.getLong(); h *= m; } h ^= h >>> r; h *= m; h ^= h >>> r; buf.order(byteOrder); return h; } }
模擬機器節點
public class Node<T> { private String ip; private String name; public Node(String ip, String name) { this.ip = ip; this.name = name; } public String getIp() { return ip; } public void setIp(String ip) { this.ip = ip; } public String getName() { return name; } public void setName(String name) { this.name = name; } /** * 使用IP當做hash的Key * * @return String */ @Override public String toString() { return ip; } }
一致性Hash操作
public class ConsistentHash<T> { // Hash函式介面 private final IHashService iHashService; // 每個機器節點關聯的虛擬節點數量 private final int numberOfReplicas; // 環形虛擬節點 private final SortedMap<Long, T> circle = new TreeMap<Long, T>(); public ConsistentHash(IHashService iHashService, int numberOfReplicas, Collection<T> nodes) { this.iHashService = iHashService; this.numberOfReplicas = numberOfReplicas; for (T node : nodes) { add(node); } } /** * 增加真實機器節點 * * @param node T */ public void add(T node) { for (int i = 0; i < this.numberOfReplicas; i++) { circle.put(this.iHashService.hash(node.toString() + i), node); } } /** * 刪除真實機器節點 * * @param node T */ public void remove(T node) { for (int i = 0; i < this.numberOfReplicas; i++) { circle.remove(this.iHashService.hash(node.toString() + i)); } } public T get(String key) { if (circle.isEmpty()) return null; long hash = iHashService.hash(key); // 沿環的順時針找到一個虛擬節點 if (!circle.containsKey(hash)) { SortedMap<Long, T> tailMap = circle.tailMap(hash); hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey(); } return circle.get(hash); } }
測試類
public class TestHashCircle { // 機器節點IP字首 private static final String IP_PREFIX = "192.168.0."; public static void main(String[] args) { // 每臺真實機器節點上儲存的記錄條數 Map<String, Integer> map = new HashMap<String, Integer>(); // 真實機器節點, 模擬10臺 List<Node<String>> nodes = new ArrayList<Node<String>>(); for (int i = 1; i <= 10; i++) { map.put(IP_PREFIX + i, 0); // 初始化記錄 Node<String> node = new Node<String>(IP_PREFIX + i, "node" + i); nodes.add(node); } IHashService iHashService = new HashService(); // 每臺真實機器引入100個虛擬節點 ConsistentHash<Node<String>> consistentHash = new ConsistentHash<Node<String>>(iHashService, 500, nodes); // 將5000條記錄儘可能均勻的儲存到10臺機器節點上 for (int i = 0; i < 5000; i++) { // 產生隨機一個字串當做一條記錄,可以是其它更復雜的業務物件,比如隨機字串相當於物件的業務唯一標識 String data = UUID.randomUUID().toString() + i; // 通過記錄找到真實機器節點 Node<String> node = consistentHash.get(data); // 再這裡可以能過其它工具將記錄儲存真實機器節點上,比如MemoryCache等 // ... // 每臺真實機器節點上儲存的記錄條數加1 map.put(node.getIp(), map.get(node.getIp()) + 1); } // 列印每臺真實機器節點儲存的記錄條數 for (int i = 1; i <= 10; i++) { System.out.println(IP_PREFIX + i + "節點記錄條數:" + map.get(IP_PREFIX + i)); } } }
執行結果如下:
一致性hash測試結果每臺機器對映的虛擬節點越多,則分佈的越均勻~~~
感興趣的同學可以拷貝上面的程式碼執行嘗試一下。