數據分佈之一致性哈希

1、數據分佈

在分佈式環境下,數據分佈也便是將數據拆分,存放到不一樣節點上,是分佈式系統中的基本問題之一。不一樣的數據分佈方式須要權衡諸如伸縮性、數據傾斜(負載的均衡)、元數據維護等問題。沒有一種萬能的方案可以解決全部的問題,不能脫離應用場景談優劣,應該要針對不一樣的應用場景選擇合適的方案。html

通常而言,能夠有如下幾種數據分佈的方式:java

1)哈希分區(或者叫餘數法)node

基本思想是根據數據的某項特徵(如ID或者鍵)計算hash值,而後對節點數量N求摸,其邏輯爲:hash(key) % N。這種方式的優勢是設計簡單;缺點是擴展性不佳,增刪節點後,原有的映射關係大部分將失效,而且容易出現「數據傾斜」的現象。redis

2)按數據範圍分佈算法

這種分區方式將數據按特徵值的值域範圍劃分爲不一樣的區間,而後每一個節點存儲不一樣區間的數據。緩存

例如, 已知某業務系統中用戶 ID 的值域範圍是[1,100),集羣有 3 個節點。則能夠將用戶 ID的值域分爲三個區間[1, 33)、 [33, 90)、 [90, 100),分別由 3 個節點Node一、Node二、Node3負責存儲。ruby

3)按數據量分佈服務器

這種方式將數據視爲一個順序增加的文件,並將這個文件按照某一較爲固定的大小劃分爲若干數據塊(chunk),不一樣的數據塊分佈到不一樣的服務器上,數據量分佈數據與具體的數據特徵無關。架構

4)一致性哈希app

一致性哈希主要用在分佈式緩存系統中,經過一種特殊的環形結構和分佈規則來實現,改進的一致性哈希可以比較好的解決擴展性問題和負載均衡問題。

本文主要討論一致性哈希的一些有趣的原理和特性,並實現一個簡潔地可演示和模擬的Demo算法,最後也簡單的說起Redis Cluster中的數據分佈方式,其與一致性哈希的思想類似之處但也有些差異。

2、一致性哈希

2.1 概述

一致性哈希的概念最初在論文Consistent Hashing and Random Trees:Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web的第四節Consistent Hashing中被提出來,具備以下四個特性,其陳述我的以爲比較理論化:

一、平衡性(Balance):平衡性是指哈希的結果可以儘量分佈到全部的緩衝中去,這樣可使得全部的緩衝空間都獲得利用。不少哈希算法都可以知足這一條件。

二、單調性(Monotonicity):單調性是指若是已經有一些內容經過哈希分派到了相應的緩衝中,又有新的緩衝加入到系統中。哈希的結果應可以保證原有已分配的內容能夠被映射到原有的或者新的緩衝中去,而不會被映射到舊的緩衝集合中的其餘緩衝區。 

三、分散性(Spread):在分佈式環境中,終端有可能看不到全部的緩衝,而是隻能看到其中的一部分。當終端但願經過哈希過程將內容映射到緩衝上時,因爲不一樣終端所見的緩衝範圍有可能不一樣,從而致使哈希的結果不一致,最終的結果是相同的內容被不一樣的終端映射到不一樣的緩衝區中。這種狀況顯然是應該避免的,由於它致使相同內容被存儲到不一樣緩衝中去,下降了系統存儲的效率。分散性的定義就是上述狀況發生的嚴重程度。好的哈希算法應可以儘可能避免不一致的狀況發生,也就是儘可能下降分散性。 

四、負載(Load):負載問題其實是從另外一個角度看待分散性問題。既然不一樣的終端可能將相同的內容映射到不一樣的緩衝區中,那麼對於一個特定的緩衝區而言,也可能被不一樣的用戶映射爲不一樣 的內容。與分散性同樣,這種狀況也是應當避免的,所以好的哈希算法應可以儘可能下降緩衝的負荷。

本文所講的一致性哈希算法知足平衡性和單調性,分散性和負載並彷佛不具有也沒有含義。

2.2 基本的算法原理

一致性哈希算法基本原理大體能夠經過幾個步驟來解釋:構造一致性哈希環、節點映射、路由規則。如下以鍵值對緩存服務器爲場景。

1)構造一致性哈希環

一致性哈希算法中首先有一個哈希函數,哈希函數產生hash值,全部可能的哈希值構成一個哈希空間,哈希空間爲[0,2^32-1],這原本是一個「線性」的空間,可是在算法中經過恰當邏輯控制,使其首尾相銜接,也便是0=2^32,這樣就構造一個邏輯上的環形空間。

2)節點映射

將集羣中的各節點映射到環上的某個一位置。好比集羣中有三個節點,那麼能夠大體均勻的將其分佈在環上。

3)路由規則

路由規則包括存儲(setX)和取值(getX)規則。

當須要存儲一個<key-value>對時,首先計算鍵key的hash值:hash(key),這個hash值必然對應於一致性hash環上的某個位置,而後沿着這個值按順時針找到第一個節點,並將該鍵值對存儲在該節點上。例如存儲<key1-value1>時,按此規則應該存儲在Node1服務器上(見下圖)。

當須要按某個鍵獲取值時,與上述規則基本相同,也是首先計算key的hash值,找到對應的節點,從該節點中獲取對應鍵的值。

整個算法的模型以下圖所示,

集羣中有三個節點(Node一、Node二、Node3),五個鍵(key一、key二、key三、key四、key5),其路由規則爲:

key1 -> Node1
key二、key3 -> Node2
key四、key5 -> Node3 

 不難發現,基本的一致性哈希算法有一些地方不太讓人滿意。

當集羣中增長節點時,好比當在Node2和Node3之間增長了一個節點Node4,此時再訪問節點key4時,不能在Node4中命中,更通常的,介於Node2和Node4之間的key均失效,這樣的失效方式太過於「集中」和「暴力」,更好的方式應該是「平滑」和「分散」地失效。以下圖所示:

特別是當集羣中節點自己比較少時,因增刪節點致使的不命中現象比較明顯。

除了上面的問題,還有一個比較明顯的問題是負載問題:增長節點只能對下一個相鄰節點有比較好的負載分擔效果,例如上圖中增長了節點Node4只可以對Node3分擔部分負載,對集羣中其餘的節點基本沒有起到負載分擔的效果;相似地,刪除節點會致使下一個相鄰節點負載增長,而其餘節點卻不能有效分擔負載壓力。

針對以上兩個主要的問題,特別是如何解決各節點負載動態均衡的問題,出現了一種經過增長虛擬節點的改進算法。

2.3 增長虛擬節點改進算法

爲了在增刪節點的時候,各節點可以保持動態的均衡,將每一個真實節點虛擬出若干個虛擬節點,再將這些虛擬節點隨機映射到環上。此時每一個真實節點再也不映射到環上,真實節點只是用來存儲鍵值對,它負責接應各自的一組環上虛擬節點。當對鍵值對進行存取路由時,首先路由到虛擬節點上,再由虛擬節點找到真實的節點。

以下圖所示,三個節點真實節點:Node一、Node2和Node3,每一個真實節點虛擬出三個虛擬節點:X#V一、X#V2和X#V3,這樣每一個真實節點所負責的hash空間再也不是連續的一段,而是分散在環上的各處,這樣就能夠將局部的壓力均衡到不一樣的節點,虛擬節點越多,分散性越好,理論上負載就越傾向均勻,以下圖所示:

通俗的理解,增長虛擬節點實際上是減少了路由規則過程當中的粒度,使每一個真實節點能夠分攤局部壓力。

3、Demo實現

如下是針對帶虛擬節點的一致性哈希算法的一個簡單的Demo實現,重點在於演示其算法的工做原理。

元數據包括真實節點、虛擬節點以及各虛擬節點對應的真實節點映射關係。虛擬節點採用平衡二叉搜索樹存儲,虛擬節點名經過真實節點拼接序列號實現,這樣只要獲得虛擬節點名截取其前綴就能夠獲得對應的真實節點,簡單方便。

經過增長節點和刪除節點模擬節點上線和下線的狀況,並測試集羣總節點變化過程當中的負載均衡狀況。

3.1 實現類

import java.util.*;
/**
 * 一致性哈希算法
 * author:Qcer
 * date:2018/07/18
 * */
public class ConsistentHash {
    // 每一個真實節點負責多少個虛擬節點
    private int virtualNodesPerRealNode;
    private int totalVirtualNodes;
    // 真實結點列表
    private List<String> realNodes = new LinkedList<String>();
    // 真實結點與各虛擬的映射關係
    private HashMap<String,LinkedList<String>> mapping = new HashMap<>();
    // 虛擬節點,key表示虛擬節點的hash值,value表示虛擬節點的名稱,採用平衡二叉搜索樹結構存儲
    private SortedMap<Integer, String> virtualNodes = new TreeMap<Integer, String>();

    public ConsistentHash(String[] nodes,int virtualNodesPerRealNode){
        this.virtualNodesPerRealNode = virtualNodesPerRealNode;
        addNode(nodes);
    }

    // 使用FNV1_32_HASH算法計算服務器的Hash值,hash空間爲[0,2^32-1],程序控制實現邏輯的環形結構
    private int getHash(String str){
        final int p = 16777619;
        int hash = (int)2166136261L;
        for (int i = 0; i < str.length(); i++){
            hash = (hash ^ str.charAt(i)) * 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;
    }

    // 根據某個key,首先訪問到虛擬節點,再訪問到真實節點。
    public String visit(String key){
        // 獲得該key的hash值
        int hash = getHash(key);
        // 獲得大於該hash值的全部Map
        SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash);
        String virtualNode = null;
        if (subMap.isEmpty()){
            // 若是沒有比該key的hash值更大的,代表該hash值恰好是一致性hash環的尾端
            // 此時從0開始,順時針取第一個虛擬節點
            Integer i = virtualNodes.firstKey();
            // 返回對應的虛擬節點
            virtualNode = virtualNodes.get(i);
        } else {
            // 按順時針方向當前最近的虛擬結點
            Integer i = subMap.firstKey();
            // 返回對應的虛擬節點
            virtualNode = subMap.get(i);
        }
        // 截取virtualNode的前綴,得到真實節點
        if(virtualNode != null){
            virtualNode = virtualNode.substring(0, virtualNode.indexOf("##"));
        }
        return virtualNode;
    }


    // 增長節點,模擬服務器上線的狀況。
    public void addNode(String[] nodes) {
        // 維護元數據,包括真實節點信息,虛擬節點信息
        for (String node : nodes){
            // 維護真實節點信息
            realNodes.add(node);
            LinkedList<String> list = new LinkedList<>();
            // 維護虛擬節點信息,key爲hash值,value的前綴爲真實節點
            for(int count = 0, sequence = 0; count < virtualNodesPerRealNode;){
                String virtualNodeName = node + "##VN" + String.valueOf(sequence++);
                int hash = getHash(virtualNodeName);

                // 通常來說,當虛擬節點數量<<hash空間時,hash函數碰撞的可能性比較小,但嚴謹其見,此處應該考慮衝突。
                if (!virtualNodes.containsKey(hash)) {
                    virtualNodes.put(hash, virtualNodeName);
                    count++;
                    list.add(virtualNodeName);//維護虛擬節點與真實節點的映射關係
                }
            }
            mapping.put(node,list);
        }
        // 維護虛擬節點總數
        totalVirtualNodes = realNodes.size() * virtualNodesPerRealNode;
    }

    // 刪除節點,模擬服務器下線的狀況。
    public void removeNode(String[] nodes) {
        for (String node : nodes) {
            if (realNodes.contains(node)) {
                realNodes.remove(node);
            }
            if (mapping.containsKey(node)) {
                LinkedList<String> list = mapping.remove(node);
                for (String virtual : list) {
                    virtualNodes.remove(getHash(virtual));
                }
            }
        }
        totalVirtualNodes = realNodes.size() * virtualNodesPerRealNode;
    }

    // 獲取元數據
    public void getMetaData() {
        System.out.println("真實節點:");
        for (int i = 0; i < realNodes.size(); i++) {
            System.out.println(realNodes.get(i));
        }
        System.out.println("虛擬節點數量:" + totalVirtualNodes);
        for (String str : mapping.keySet()) {
            System.out.println(mapping.get(str).size());
        }
    }
    
    // 測試增刪節點後各節點的負載
    public void testLoadBalance(String[] keys){
        System.out.println("真實節點數量:" + realNodes.size());
        System.out.println("虛擬節點數量:" + totalVirtualNodes);
        System.out.println("各節點負載狀況:");
        int keyNumber = keys.length;
        int realNodeNumber = realNodes.size();
        String hitNode = "";
        int[] count = new int[realNodeNumber];
        for(int i = 0; i < keyNumber; i++) {
            hitNode = visit(keys[i]);
            for (int j = 0; j < realNodeNumber; j++){
                if (hitNode.equals(realNodes.get(j))){
                    count[j] += 1;
                }
            }
        }
        for (int i = 0; i < realNodeNumber; i++) {
            System.out.println("[Node"+i+"-"+realNodes.get(i)+"]" +" : "+count[i]);
        }
    }
}

 

3.2 測試類

/**一致性哈希算法Test類
 * author:Qcer
 * date:2018/07/18
 * */
public class ConsistentHashTest {

    // 產生隨機字符串,視爲key
    public static String[] genKeys(int number) {
        String[] ary = new String[number];
        int length = 0;
        for(int j = 0; j < number; j++) {
            String temp = "";
            length = (int)(Math.random() * 8 + 2);
            for(int i = 0; i < length; i++) {
                int intValue = (int)(Math.random() * 26 + 97);
                temp += (char)intValue;
            }
            ary[j] = temp;
        }
        return ary;
    }

    public static void main(String[] args){
        String[] nodes = {
                "10.10.25.11:6379",
                "10.10.25.12:6379",
                "10.10.25.13:6379",
                "10.10.25.14:6379",
                "10.10.25.15:6379"};

        int keyCount = 10000;
        String[] keys = genKeys(keyCount);

        System.out.println("--------初始狀態-------");
        ConsistentHash ch = new ConsistentHash(nodes,200);
        ch.testLoadBalance(keys);

        System.out.println("--------模擬上線-------");
        String[] onLine = {"10.10.25.20:6379","10.10.25.21:6379"};
        ch.addNode(onLine);
        ch.testLoadBalance(keys);

        System.out.println("--------模擬下線-------");
        String[] offLine = {"10.10.25.11:6379","10.10.25.14:6379"};
        ch.removeNode(offLine);
        ch.testLoadBalance(keys);

        System.out.println("--------獲取元數據-------");
        ch.getMetaData();
    }
}

3.3 測試結果

--------初始狀態-------
真實節點數量:5
虛擬節點數量:1000
各節點負載狀況:
[Node0-10.10.25.11:6379] : 1982
[Node1-10.10.25.12:6379] : 2157
[Node2-10.10.25.13:6379] : 2063
[Node3-10.10.25.14:6379] : 1659
[Node4-10.10.25.15:6379] : 2139
--------模擬上線-------
真實節點數量:7
虛擬節點數量:1400
各節點負載狀況:
[Node0-10.10.25.11:6379] : 1373
[Node1-10.10.25.12:6379] : 1599
[Node2-10.10.25.13:6379] : 1382
[Node3-10.10.25.14:6379] : 1268
[Node4-10.10.25.15:6379] : 1416
[Node5-10.10.25.20:6379] : 1488
[Node6-10.10.25.21:6379] : 1474
--------模擬下線-------
真實節點數量:5
虛擬節點數量:1000
各節點負載狀況:
[Node0-10.10.25.12:6379] : 2097
[Node1-10.10.25.13:6379] : 1909
[Node2-10.10.25.15:6379] : 1769
[Node3-10.10.25.20:6379] : 2131
[Node4-10.10.25.21:6379] : 2094
--------獲取元數據-------
真實節點:
10.10.25.12:6379
10.10.25.13:6379
10.10.25.15:6379
10.10.25.20:6379
10.10.25.21:6379
虛擬節點數量:1000

可見,在上下線的過程當中,各節點可以大體的保持一個動態的負載平衡。

4、Redis Cluster中的虛擬槽分區

在Redis Cluster中,依然採用了虛擬槽的方式,總共有16384=2^14個虛擬槽,其鍵與槽的映射關係爲slot=CRC16(key)&16383,所以虛擬槽只是一個邏輯的概念,並不真實存存儲數據,虛擬槽背後的真實節點纔是數據存放的地方。

每一個真實節點會負責一部分的虛擬槽,採用虛擬槽分區的方式可以解耦數據與節點的關係,方便集羣的伸縮。在搭建集羣的過程當中,須要給定每一個虛擬槽與真實節點之間的映射關係。

例如,以6個節點搭建一個小規模redis集羣,其真實節點局域網IP和端口以下:

192.168.0.117:6390
192.168.0.117:6391
192.168.0.117:6392
192.168.0.117:6393
192.168.0.117:6394
192.168.0.117:6395

這裏採用redis-trib.rb集羣管理工具實現節點握手、虛擬槽分配、檢查等功能:

[root@localhost cluster]# 
[root@localhost cluster]# redis-trib.rb create --replicas 1 192.168.0.117:6390 192.168.0.117:6391 192.168.0.117:6392 192.168.0.117:6393 192.168.0.117:6394 192.168.0.117:6395
>>> Creating cluster
/usr/local/ruby/lib/ruby/gems/2.4.0/gems/redis-3.3.0/lib/redis/client.rb:459: warning: constant ::Fixnum is deprecated
>>> Performing hash slots allocation on 6 nodes...
Using 3 masters:
192.168.0.117:6390
192.168.0.117:6391
192.168.0.117:6392
Adding replica 192.168.0.117:6393 to 192.168.0.117:6390
Adding replica 192.168.0.117:6394 to 192.168.0.117:6391
Adding replica 192.168.0.117:6395 to 192.168.0.117:6392
M: c90dd52f29968f10bb99a8bdb9ad839009944406 192.168.0.117:6390 slots:0-5460 (5461 slots) master
M: 3b226aa47c0afe5aa76501a61db2ae2af6cfe5ff 192.168.0.117:6391 slots:5461-10922 (5462 slots) master
M: 03e45dc39322d0df04bf2cdaba2498f4918a3e76 192.168.0.117:6392 slots:10923-16383 (5461 slots) master
S: b3cb797097633c9f95bd4a706fcf9a3f09db5ca1 192.168.0.117:6393
   replicates c90dd52f29968f10bb99a8bdb9ad839009944406
S: 1149158458c4a2eaa249b1981e111b1ea1e2a542 192.168.0.117:6394
   replicates 3b226aa47c0afe5aa76501a61db2ae2af6cfe5ff
S: 5a5038176c110ff6f07d31f81c725caaa8ae7c74 192.168.0.117:6395
   replicates 03e45dc39322d0df04bf2cdaba2498f4918a3e76
Can I set the above configuration? (type 'yes' to accept): yes
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join..
>>> Performing Cluster Check (using node 192.168.0.117:6390)
M: c90dd52f29968f10bb99a8bdb9ad839009944406 192.168.0.117:6390
   slots:0-5460 (5461 slots) master
   1 additional replica(s)
M: 3b226aa47c0afe5aa76501a61db2ae2af6cfe5ff 192.168.0.117:6391 slots:5461-10922 (5462 slots) master 1 additional replica(s)
S: 1149158458c4a2eaa249b1981e111b1ea1e2a542 192.168.0.117:6394
   slots: (0 slots) slave
   replicates 3b226aa47c0afe5aa76501a61db2ae2af6cfe5ff
S: b3cb797097633c9f95bd4a706fcf9a3f09db5ca1 192.168.0.117:6393
   slots: (0 slots) slave
   replicates c90dd52f29968f10bb99a8bdb9ad839009944406
M: 03e45dc39322d0df04bf2cdaba2498f4918a3e76 192.168.0.117:6392 slots:10923-16383 (5461 slots) master 1 additional replica(s)
S: 5a5038176c110ff6f07d31f81c725caaa8ae7c74 192.168.0.117:6395
   slots: (0 slots) slave
   replicates 03e45dc39322d0df04bf2cdaba2498f4918a3e76
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
[root@localhost cluster]# 

從上面的過程當中能夠看到,總共的16384個虛擬槽被分爲546一、546二、5461三部分,分別分配給三個master節點,另外3個slave節點因爲只能從對應的master節點複製數據默認只讀不可寫,所以不分配虛擬槽。

當虛擬槽所有分配完成,集羣處於可用狀態:

192.168.0.117:6391> 
192.168.0.117:6391> cluster keyslot qcer
(integer) 7408
192.168.0.117:6391> set qcer "hello world"
OK
192.168.0.117:6391> 

在集羣伸縮的過程當中,因爲節點上線或者下線,須要進行虛擬槽的遷移。 

5、References

一、大型網站技術架構_核心原理與案例分析

二、分佈式系統原理介紹

三、Redis開發和運維

四、Consistent Hashing and Random Trees:Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web,SECTION 4 Consistent Hashing.

 

轉載請註明原文出處:http://www.javashuo.com/article/p-kdnphtrn-dh.html 

謝謝!

相關文章
相關標籤/搜索