Hash一致性算法(分片機制)

一 哈希簡介
1.1 簡介
我們首先來簡單介紹一下什麼是哈希(以下簡稱hash),hash本質來說就是映射,或者說是鍵值對key-value,不同的hash之間不過就是實現key-value映射的算法不同,例如java中計算對象的hashcode值會有不同的算法,常用於各種分佈式存儲分片的id取模算法等,都屬於hash算法。
分佈式系統中,假設有 n 個節點,傳統方案使用 mod(key, n) 映射數據和節點。
當擴容或縮容時(哪怕只是增減1個節點),映射關係變爲 mod(key, n+1) / mod(key, n-1),絕大多數數據的映射關係都會失效。

1.2算法原理:
映射方案
在這裏插入圖片描述
1.2.1公用哈希函數和哈希環
設計哈希函數 Hash(key),要求取值範圍爲 [0, 2^32)
各哈希值在上圖 Hash 環上的分佈:時鐘12點位置爲0,按順時針方向遞增,臨近12點的左側位置爲2^32-1。

1.2.2 節點(Node)映射至哈希環
如圖哈希環上的綠球所示,四個節點 Node A/B/C/D,
其 IP 地址或機器名,經過同一個 Hash() 計算的結果,映射到哈希環上。

1.2.3 對象(Object)映射於哈希環
如圖哈希環上的黃球所示,四個對象 Object A/B/C/D,
其鍵值,經過同一個 Hash() 計算的結果,映射到哈希環上。

1.2.4 對象(Object)映射至節點(Node)
在對象和節點都映射至同一個哈希環之後,要確定某個對象映射至哪個節點,
只需從該對象開始,沿着哈希環順時針方向查找,找到的第一個節點,即是。
可見,Object A/B/C/D 分別映射至 Node A/B/C/D。
刪除節點
現實場景:服務器縮容時刪除節點,或者有節點宕機。如下圖,要刪除節點 Node C:
只會影響欲刪除節點(Node C)與上一個(順時針爲前進方向)節點(Node B)與之間的對象,也就是 Object C,
這些對象的映射關係,按照 2.1.4 的規則,調整映射至欲刪除節點的下一個節點 Node D。
其他對象的映射關係,都無需調整。

在這裏插入圖片描述
增加節點
現實場景:服務器擴容時增加節點。比如要在 Node B/C 之間增加節點 Node X:
只會影響欲新增節點(Node X)與上一個(順時針爲前進方向)節點(Node B)與之間的對象,也就是 Object C,
這些對象的映射關係,按照 2.1.4 的規則,調整映射至新增的節點 Node X。
其他對象的映射關係,都無需調整。
在這裏插入圖片描述
虛擬節點
對於前面的方案,節點數越少,越容易出現節點在哈希環上的分佈不均勻,導致各節點映射的對象數量嚴重不均衡(數據傾斜);相反,節點數越多越密集,數據在哈希環上的分佈就越均勻。
但實際部署的物理節點有限,我們可以用有限的物理節點,虛擬出足夠多的虛擬節點(Virtual Node),最終達到數據在哈希環上均勻分佈的效果:
如下圖,實際只部署了2個節點 Node A/B,
每個節點都複製成3倍,結果看上去是部署了6個節點。
可以想象,當複製倍數爲 2^32 時,就達到絕對的均勻,通常可取複製倍數爲32或更高。
虛擬節點哈希值的計算方法調整爲:對“節點的IP(或機器名)+虛擬節點的序號(1~N)”作哈希。
在這裏插入圖片描述 算法實現
一致性哈希算法有多種具體的實現,包括 Chord 算法,KAD 算法等,都比較複雜。
這裏給出一個簡易實現及其演示,可以看到一致性哈希的均衡性和單調性的優勢。
單調性在本例中沒有統計數據,但根據前面原理可知,增刪節點後只有很少量的數據需要調整映射關係。

3.1 源碼
public class ConsistentHashing {
// 物理節點
private Set physicalNodes = new TreeSet() {
{
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%

二 面臨的問題
一個算法的出現一定是爲了解決某個問題或者是某類問題,理解算法解決了什麼樣的問題非常有助於我們理解算法本身,那麼一致性哈希是爲了解決什麼樣的問題呢?我們首先來看一下普通的hash算法會遇到什麼樣的問題,我們以id取模算法爲例,這種算法經常被用到分佈式存儲的分片算法中:
在這裏插入圖片描述
如圖所示,假如我們以id % 3作爲分片條件,有1-20這些元素,這樣這20個元素會按照與3取模的結果分佈在0、1、2這三個片中,一切看起來都簡單又和諧,但隨着業務的發展,我們可能需要擴容,需要再加一個片,我們需要把算法換成id%4,這個時候會發生什麼樣的變化呢?
在這裏插入圖片描述
對比兩個圖,我們發現,擴容了一個分片之後,百分之七八十的的數據都發生了遷移,大規模的數據遷移就是這個算法的缺點所在。如果是我們示例中的這種小規模數據,可能影響還不是很大,但是在企業級應用中,可能需要操作的是十億百億規模的數據,這時候要遷移它們當中百分之七八十的數據,複雜度和危險性都是非常高的。

有一種方法能夠減小數據遷移的規模,就是成倍擴容,例如示例中的3個片我們直接擴容成6個片,這樣可以將數據遷移的規模減小到50%,如果讀者閱讀過HashMap的源碼,會發現,HashMap在擴容時調用的resize方法就是將容量擴容爲原來的2倍,筆者當時在閱讀HashMap源碼時就沒搞懂爲什麼一定要擴容兩倍,原因就是在這了,就是爲了減少數據遷移的規模。

但是這種方式又會引入另外兩個問題,一個是資源浪費,可能我們的業務發展和體量暫時不需要擴容一倍,所以直接擴容一倍之後會造成一定的資源浪費。另一個是成本問題,擴容意味着增加服務器,成倍擴容無疑意味着需要更多的服務器,成本還是很高的。這兩個問題在大規模集羣中尤爲明顯。
一致性哈希
下面我們來看一致性哈希是如何解決這些問題的,首先我們來看網上經常能看到的有關一致性哈希的一張圖:
在這裏插入圖片描述
idmod = id % 100;
if (idmod >= 0 && idmod < 25) {
return db1;
} else if (idmod >= 25 && idmod < 50) {
return db2;
} else if (idmod >= 50 && idmod < 75) {
return db3;
} else {
return db4;
}
我們用id % 100的結果作爲分片的依據,並將集羣分爲四個片,每個片對應一段區間,這時,假如我們發現db3對應的區間也就是idmod在50-75之間的數據發生了熱點情況,我們需要對這個片進行擴容,那我們可以將算法改造成這樣:

idmod = id % 100;
if (idmod >= 0 && idmod < 25) {
return db1;
} else if (idmod >= 25 && idmod < 50) {
return db2;
} else if (idmod >= 50 && idmod < 65) {
return db3;
} else if (idmod >= 65 && idmod < 75) {
return db5;
} else {
return db4;
}
我們擴容了一個分片db5,並將原來的熱點分片db3中的一部分區間中的數據遷移到db5中,這樣就可以在不影響其他分片的情況下完成數據遷移,擴容的節點數量也可以進行控制,這就是一致性哈希。

我們再回頭看一下上面這樣圖,大概的意思就是這樣,原本有四個節點node1-node4,圖中粉色的點就是我們id取模之後的值落到這個環上的位置,這就是所謂的哈希環,然後擴容了一個藍色的節點node5,擴容只會影響原來node2-node4之間的數據。

一致性哈希也同樣有它的問題所在,我們上面提到,一致性哈希可以解決熱點問題,那如果我們的數據分佈的很均勻,沒有熱點問題,還是需要擴容,怎麼辦,按照上文的理解就需要爲每個節點都擴容一個節點,這不又是成倍的擴容了麼,又遇到了這個n -> 2n的問題,該怎麼解決呢?

虛擬節點
我們來做這樣一個映射,首先將id % 65536,這樣可以得到0-65535這樣一個區間,然後做一個這樣的映射:

hash id node id
0 0
1 1
2 2
3 3
4 0
5 1
… …
65535 3
這個時候如果我們需要擴容節點,增加一個節點node id爲4,我們只需要調整這張虛擬節點的映射表,隨意的按照我們的需求來調整,比如我們可以將hash id爲5、6、7的數據映射到node id爲4的節點上,所以虛擬節點的關鍵就是我們要維護好這張映射表。這裏id與多少取模選擇了65536,實際應用中取多少合適呢?很顯然,這個值越大,分佈就會越均勻,我們可以調整的空間也越大,但是實現和維護的難度也會上升,所以實際應用中到底應該取什麼值還是需要結合實際業務來做出權衡。

總結
無論是哪種算法,它們要解決的問題都是儘量的減少數據遷移的規模,還有就是減少擴容的成本,那是不是說我們就一定要選擇虛擬節點的這種算法呢?恰恰相反,我們推薦儘量使用的簡單的方法來解決問題,不要一開始就使用複雜的方式,這樣很容易產生過度設計,虛擬節點的算法雖然可以解決n -> 2n和數據遷移規模的問題,但它的缺點就是比較複雜,實現複雜,維護也複雜,所以我們推薦應用一開始儘量優先選用id取模的算法也就是n -> 2n的方式進行擴容,當集羣到達一定規模之後,我們可以做一張如上的虛擬節點映射表,將原來的取模算法平滑的切換爲虛擬節點算法,對應用沒有任何影響,然後再按照虛擬節點的方式進行擴容,這是我們最推薦的方式。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章