一致性雜湊演算法的原理與實現
分散式系統中物件與節點的對映關係,傳統方案是使用物件的雜湊值,對節點個數取模,再對映到相應編號的節點,這種方案在節點個數變動時,絕大多數物件的對映關係會失效而需要遷移;而一致性雜湊演算法中,當節點個數變動時,對映關係失效的物件非常少,遷移成本也非常小。本文總結了一致性雜湊的演算法原理和Java實現,並列舉了其應用。
作者:王克鋒
出處: https://kefeng.wang/2018/08/10/consistent-hashing/
版權: 自由轉載-非商用-非衍生-保持署名 ,轉載請標明作者和出處。
1 概述
1.1 傳統雜湊(硬雜湊)
分散式系統中,假設有 n 個節點,傳統方案使用 mod(key, n)
對映資料和節點。
當擴容或縮容時(哪怕只是增減1個節點),對映關係變為 mod(key, n+1)
/ mod(key, n-1)
,絕大多數資料的對映關係都會失效。
1.2 一致性雜湊(Consistent Hashing)
1997年,麻省理工學院(MIT)的 David Karger 等6個人釋出學術論文《 Consistent hashing and random trees: distributed caching protocols for relieving hot spots on the World Wide Web (一致性雜湊和隨機樹:用於緩解全球資訊網上熱點的分散式快取協議)》,對於 K 個關鍵字和 n 個槽位(分散式系統中的節點)的雜湊表,增減槽位後,平均只需對 K/n 個關鍵字重新對映。
1.3 雜湊指標
評估一個雜湊演算法的優劣,有如下指標,而一致性雜湊全部滿足:
- 均衡性(Balance):將關鍵字的雜湊地址均勻地分佈在地址空間中,使地址空間得到充分利用,這是設計雜湊的一個基本特性。
- 單調性(Monotonicity): 單調性是指當地址空間增大時,通過雜湊函式所得到的關鍵字的雜湊地址也能對映的新的地址空間,而不是僅限於原先的地址空間。或等地址空間減少時,也是隻能對映到有效的地址空間中。簡單的雜湊函式往往不能滿足此性質。
- 分散性(Spread): 雜湊經常用在分散式環境中,終端使用者通過雜湊函式將自己的內容存到不同的緩衝區。此時,終端有可能看不到所有的緩衝,而是隻能看到其中的一部分。當終端希望通過雜湊過程將內容對映到緩衝上時,由於不同終端所見的緩衝範圍有可能不同,從而導致雜湊的結果不一致,最終的結果是相同的內容被不同的終端對映到不同的緩衝區中。這種情況顯然是應該避免的,因為它導致相同內容被儲存到不同緩衝中去,降低了系統儲存的效率。分散性的定義就是上述情況發生的嚴重程度。好的雜湊演算法應能夠儘量避免不一致的情況發生,也就是儘量降低分散性。
- 負載(Load): 負載問題實際上是從另一個角度看待分散性問題。既然不同的終端可能將相同的內容對映到不同的緩衝區中,那麼對於一個特定的緩衝區而言,也可能被不同的使用者對映為不同的內容。與分散性一樣,這種情況也是應當避免的,因此好的雜湊演算法應能夠儘量降低緩衝的負荷。
1.4 資料連結
原始論文《Consistent Hashing and Random Trees》連結如下:
相關論文《Web Caching with Consistent Hashing》連結如下:
2 演算法原理
2.1 對映方案
2.1.1 公用雜湊函式和雜湊環
設計雜湊函式 Hash(key),要求取值範圍為 [0, 2 ^32 )
各雜湊值在上圖 Hash 環上的分佈:時鐘12點位置為0,按順時針方向遞增,臨近12點的左側位置為2 ^32 -1。
2.1.2 節點(Node)對映至雜湊環
如圖雜湊環上的綠球所示,四個節點 Node A/B/C/D,
其 IP 地址或機器名,經過同一個 Hash() 計算的結果,對映到雜湊環上。
2.1.3 物件(Object)對映於雜湊環
如圖雜湊環上的黃球所示,四個物件 Object A/B/C/D,
其鍵值,經過同一個 Hash() 計算的結果,對映到雜湊環上。
2.1.4 物件(Object)對映至節點(Node)
在物件和節點都對映至同一個雜湊環之後,要確定某個物件對映至哪個節點,
只需從該物件開始,沿著雜湊環順時針方向查詢,找到的第一個節點,即是。
可見,Object A/B/C/D 分別對映至 Node A/B/C/D。
2.2 刪除節點
現實場景:伺服器縮容時刪除節點,或者有節點宕機。如下圖,要刪除節點 Node C:
只會影響欲刪除節點(Node C)與上一個(順時針為前進方向)節點(Node B)與之間的物件,也就是 Object C,
這些物件的對映關係,按照 2.1.4 的規則,調整對映至欲刪除節點的下一個節點 Node D。
其他物件的對映關係,都無需調整。

2.3 增加節點
現實場景:伺服器擴容時增加節點。比如要在 Node B/C 之間增加節點 Node X:
只會影響欲新增節點(Node X)與上一個(順時針為前進方向)節點(Node B)與之間的物件,也就是 Object C,
這些物件的對映關係,按照 2.1.4 的規則,調整對映至新增的節點 Node X。
其他物件的對映關係,都無需調整。

2.4 虛擬節點
對於前面的方案,節點數越少,越容易出現節點在雜湊環上的分佈不均勻,導致各節點對映的物件數量嚴重不均衡(資料傾斜);相反,節點數越多越密集,資料在雜湊環上的分佈就越均勻。
但實際部署的物理節點有限,我們可以用有限的物理節點,虛擬出足夠多的虛擬節點(Virtual Node),最終達到資料在雜湊環上均勻分佈的效果:
如下圖,實際只部署了2個節點 Node A/B,
每個節點都複製成3倍,結果看上去是部署了6個節點。
可以想象,當複製倍數為 2 ^32 時,就達到絕對的均勻,通常可取複製倍數為32或更高。
虛擬節點雜湊值的計算方法調整為:對“節點的IP(或機器名)+虛擬節點的序號(1~N)”作雜湊。

3 演算法實現
一致性雜湊演算法有多種具體的實現,包括 Chord 演算法,KAD 演算法等,都比較複雜。
這裡給出一個簡易實現及其演示,可以看到一致性雜湊的均衡性和單調性的優勢。
單調性在本例中沒有統計資料,但根據前面原理可知,增刪節點後只有很少量的資料需要調整對映關係。
3.1 原始碼
/** * @author: https://kefeng.wang * @date: 2018-08-10 11:08 **/ public class ConsistentHashing { // 物理節點 private Set<String> physicalNodes = new TreeSet<String>() { { add("192.168.1.101"); add("192.168.1.102"); add("192.168.1.103"); add("192.168.1.104"); } }; //虛擬節點 private final int VIRTUAL_COPIES = 1048576; // 物理節點至虛擬節點的複製倍數 private TreeMap<Long, String> virtualNodes = new TreeMap<>(); // 雜湊值 => 物理節點 // 32位的 Fowler-Noll-Vo 雜湊演算法 // https://en.wikipedia.org/wiki/Fowler–Noll–Vo_hash_function private static Long FNVHash(String key) { final int p = 16777619; Long hash = 2166136261L; for (int idx = 0, num = key.length(); idx < num; ++idx) { hash = (hash ^ key.charAt(idx)) * 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; } // 根據物理節點,構建虛擬節點對映表 public ConsistentHashing() { for (String nodeIp : physicalNodes) { addPhysicalNode(nodeIp); } } // 新增物理節點 public void addPhysicalNode(String nodeIp) { for (int idx = 0; idx < VIRTUAL_COPIES; ++idx) { long hash = FNVHash(nodeIp + "#" + idx); virtualNodes.put(hash, nodeIp); } } // 刪除物理節點 public void removePhysicalNode(String nodeIp) { for (int idx = 0; idx < VIRTUAL_COPIES; ++idx) { long hash = FNVHash(nodeIp + "#" + idx); virtualNodes.remove(hash); } } // 查詢物件對映的節點 public String getObjectNode(String object) { long hash = FNVHash(object); SortedMap<Long, String> tailMap = virtualNodes.tailMap(hash); // 所有大於 hash 的節點 Long key = tailMap.isEmpty() ? virtualNodes.firstKey() : tailMap.firstKey(); return virtualNodes.get(key); } // 統計物件與節點的對映關係 public void dumpObjectNodeMap(String label, int objectMin, int objectMax) { // 統計 Map<String, Integer> objectNodeMap = new TreeMap<>(); // IP => COUNT for (int object = objectMin; object <= objectMax; ++object) { String nodeIp = getObjectNode(Integer.toString(object)); Integer count = objectNodeMap.get(nodeIp); objectNodeMap.put(nodeIp, (count == null ? 0 : count + 1)); } // 列印 double totalCount = objectMax - objectMin + 1; System.out.println("======== " + label + " ========"); for (Map.Entry<String, Integer> entry : objectNodeMap.entrySet()) { long percent = (int) (100 * entry.getValue() / totalCount); System.out.println("IP=" + entry.getKey() + ": RATE=" + percent + "%"); } } public static void main(String[] args) { ConsistentHashing ch = new ConsistentHashing(); // 初始情況 ch.dumpObjectNodeMap("初始情況", 0, 65536); // 刪除物理節點 ch.removePhysicalNode("192.168.1.103"); ch.dumpObjectNodeMap("刪除物理節點", 0, 65536); // 新增物理節點 ch.addPhysicalNode("192.168.1.108"); ch.dumpObjectNodeMap("新增物理節點", 0, 65536); } }
3.2 複製倍數為 1 時的均衡性
修改程式碼中 VIRTUAL_COPIES = 1
(相當於沒有虛擬節點),執行結果如下(可見各節點負荷很不均衡):
======== 初始情況 ======== IP=192.168.1.101: RATE=45% IP=192.168.1.102: RATE=3% IP=192.168.1.103: RATE=28% IP=192.168.1.104: RATE=22% ======== 刪除物理節點 ======== IP=192.168.1.101: RATE=45% IP=192.168.1.102: RATE=3% IP=192.168.1.104: RATE=51% ======== 新增物理節點 ======== IP=192.168.1.101: RATE=45% IP=192.168.1.102: RATE=3% IP=192.168.1.104: RATE=32% IP=192.168.1.108: RATE=18%
3.2 複製倍數為 32 時的均衡性
修改程式碼中 VIRTUAL_COPIES = 32
,執行結果如下(可見各節點負荷比較均衡):
======== 初始情況 ======== IP=192.168.1.101: RATE=29% IP=192.168.1.102: RATE=21% IP=192.168.1.103: RATE=25% IP=192.168.1.104: RATE=23% ======== 刪除物理節點 ======== IP=192.168.1.101: RATE=39% IP=192.168.1.102: RATE=37% IP=192.168.1.104: RATE=23% ======== 新增物理節點 ======== IP=192.168.1.101: RATE=35% IP=192.168.1.102: RATE=20% IP=192.168.1.104: RATE=23% IP=192.168.1.108: RATE=20%
3.2 複製倍數為 1M 時的均衡性
修改程式碼中 VIRTUAL_COPIES = 1048576
,執行結果如下(可見各節點負荷非常均衡):
======== 初始情況 ======== IP=192.168.1.101: RATE=24% IP=192.168.1.102: RATE=24% IP=192.168.1.103: RATE=25% IP=192.168.1.104: RATE=25% ======== 刪除物理節點 ======== IP=192.168.1.101: RATE=33% IP=192.168.1.102: RATE=33% IP=192.168.1.104: RATE=33% ======== 新增物理節點 ======== IP=192.168.1.101: RATE=25% IP=192.168.1.102: RATE=24% IP=192.168.1.104: RATE=24% IP=192.168.1.108: RATE=24%
4 應用
一致性雜湊是分散式系統元件負載均衡的首選演算法,它既可以在客戶端實現,也可以在中介軟體上實現。其應用有:
- 分散式散列表(DHT)的設計;
- 分散式關係資料庫(MySQL):分庫分表時,計算資料與節點的對映關係;
- 分散式快取:Memcached 的客戶端實現了一致性雜湊,還可以使用中介軟體 twemproxy 管理 redis/memcache 叢集;
- RPC 框架 Dubbo:用來選擇服務提供者;
- 亞馬遜的雲端儲存系統 Dynamo;
- 分散式 Web 快取;
- Bittorrent DHT;
- LVS。