Redis一致性hash算法

一、Redis集群的使用

我們在使用Redis的時候,為了保證Redis的高可用,提高Redis的讀寫性能,最簡單的方式我們會做主從復制,組成Master-Master或者Master-Slave的形式,或者搭建Redis集群,進行數據的讀寫分離,類似于數據庫的主從復制和讀寫分離。如下所示:

image

同樣類似于數據庫,當單表數據大于500W的時候需要對其進行分庫分表,當數據量很大的時候(標準可能不一樣,要看Redis服務器容量)我們同樣可以對Redis進行類似的操作,就是分庫分表。

假設,我們有一個社交網站,需要使用Redis存儲圖片資源,存儲的格式為鍵值對,key值為圖片名稱,value為該圖片所在文件服務器的路徑,我們需要根據文件名查找該文件所在文件服務器上的路徑,數據量大概有2000W左右,按照我們約定的規則進行分庫,規則就是隨機分配,我們可以部署8臺緩存服務器,每臺服務器大概含有500W條數據,并且進行主從復制,示意圖如下:

image

由于規則是隨機的,所有我們的一條數據都有可能存儲在任何一組Redis中,例如上圖我們用戶查找一張名稱為”a.png”的圖片,由于規則是隨機的,我們不確定具體是在哪一個Redis服務器上的,因此我們需要進行1、2、3、4,4次查詢才能夠查詢到(也就是遍歷了所有的Redis服務器),這顯然不是我們想要的結果,有了解過的小伙伴可能會想到,隨機的規則不行,可以使用類似于數據庫中的分庫分表規則:按照Hash值、取模、按照類別、按照某一個字段值等等常見的規則就可以出來了!好,按照我們的主題,我們就使用Hash的方式。

二、為Redis集群使用Hash

可想而知,如果我們使用Hash的方式,每一張圖片在進行分庫的時候都可以定位到特定的服務器,示意圖如下:

image

上圖中,假設我們查找的是”a.png”,由于有4臺服務器(排除從庫),因此公式為hash(a.png) % 4 = 2 ,可知定位到了第2號服務器,這樣的話就不會遍歷所有的服務器,大大提升了性能!

三、使用Hash的問題

上述的方式雖然提升了性能,我們不再需要對整個Redis服務器進行遍歷!但是,使用上述Hash算法進行緩存時,會出現一些缺陷,主要體現在服務器數量變動的時候,所有緩存的位置都要發生改變!

一般算法:

對對象先hash然后對redis數量取模,如果結果是0就存在0的節點上。
  1、2同上,假設有0-3四個redis節點、20個數據:

image.png

進行取模后分布如下:

image

現在因為壓力過大需要擴容,增加一臺redis4、第五個節點:

image

現在只有4個節點還能夠命中。命中率是:4/20 = 20%,命中率極其低下。(redis肯定是不會這樣用的)

試想一下,如果4臺緩存服務器已經不能滿足我們的緩存需求,那么我們應該怎么做呢?很簡單,多增加幾臺緩存服務器不就行了!假設:我們增加了一臺緩存服務器,那么緩存服務器的數量就由4臺變成了5臺。那么原本hash(a.png) % 4 = 2 的公式就變成了hash(a.png) % 5 = ? , 可想而知這個結果肯定不是2的,這種情況帶來的結果就是當服務器數量變動時,所有緩存的位置都要發生改變!換句話說,當服務器數量發生改變時,所有緩存在一定時間內是失效的,當應用無法從緩存中獲取數據時,則會向后端數據庫請求數據(還記得上一篇的《緩存雪崩》嗎?)!

同樣的,假設4臺緩存中突然有一臺緩存服務器出現了故障,無法進行緩存,那么我們則需要將故障機器移除,但是如果移除了一臺緩存服務器,那么緩存服務器數量從4臺變為3臺,也是會出現上述的問題!

所以,我們應該想辦法不讓這種情況發生,但是由于上述Hash算法本身的緣故,使用取模法進行緩存時,這種情況是無法避免的,為了解決這些問題,Hash一致性算法(一致性Hash算法)誕生了!

四、一致性Hash算法的神秘面紗

一致性Hash算法也是使用取模的方法,只是,剛才描述的取模法是對服務器的數量進行取模,而一致性Hash算法是對232取模,什么意思呢?簡單來說,一致性Hash算法將整個哈希值空間組織成一個虛擬的圓環,如假設某哈希函數H的值空間為0-232-1(即哈希值是一個32位無符號整形),整個哈希環如下:

image

整個空間按順時針方向組織,圓環的正上方的點代表0,0點右側的第一個點代表1,以此類推,2、3、4、5、6……直到232-1,也就是說0點左側的第一個點代表232-1, 0和232-1在零點中方向重合,我們把這個由232個點組成的圓環稱為Hash環。

下一步將各個服務器使用Hash進行一個哈希,具體可以選擇服務器的IP或主機名作為關鍵字進行哈希,這樣每臺機器就能確定其在哈希環上的位置,這里假設將上文中四臺服務器使用IP地址哈希后在環空間的位置如下:

image

接下來使用如下算法定位數據訪問到相應服務器:將數據key使用相同的函數Hash計算出哈希值,并確定此數據在環上的位置,從此位置沿環順時針“行走”,第一臺遇到的服務器就是其應該定位到的服務器!

例如我們有Object A、Object B、Object C、Object D四個數據對象,經過哈希計算后,在環空間上的位置如下:

image

根據一致性Hash算法,數據A會被定為到Node A上,B被定為到Node B上,C被定為到Node C上,D被定為到Node D上。

五、一致性Hash算法的容錯性和可擴展性

現假設Node C不幸宕機,可以看到此時對象A、B、D不會受到影響,只有C對象被重定位到Node D。一般的,在一致性Hash算法中,如果一臺服務器不可用,則受影響的數據僅僅是此服務器到其環空間中前一臺服務器(即沿著逆時針方向行走遇到的第一臺服務器)之間數據,其它不會受到影響,如下所示:

image

下面考慮另外一種情況,如果在系統中增加一臺服務器Node X,如下圖所示:

image

此時對象Object A、B、D不受影響,只有對象C需要重定位到新的Node X !一般的,在一致性Hash算法中,如果增加一臺服務器,則受影響的數據僅僅是新服務器到其環空間中前一臺服務器(即沿著逆時針方向行走遇到的第一臺服務器)之間數據,其它數據也不會受到影響。

綜上所述,一致性Hash算法對于節點的增減都只需重定位環空間中的一小部分數據,具有較好的容錯性和可擴展性。

六、Hash環的數據傾斜問題

一致性Hash算法在服務節點太少時,容易因為節點分部不均勻而造成數據傾斜(被緩存的對象大部分集中緩存在某一臺服務器上)問題,例如系統中只有兩臺服務器,其環分布如下:

image

此時必然造成大量數據集中到Node A上,而只有極少量會定位到Node B上。為了解決這種數據傾斜問題,一致性Hash算法引入了虛擬節點機制,即對每一個服務節點計算多個哈希,每個計算結果位置都放置一個此服務節點,稱為虛擬節點。具體做法可以在服務器IP或主機名的后面增加編號來實現。

例如上面的情況,可以為每臺服務器計算三個虛擬節點,于是可以分別計算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六個虛擬節點:

image

同時數據定位算法不變,只是多了一步虛擬節點到實際節點的映射,例如定位到“Node A#1”、“Node A#2”、“Node A#3”三個虛擬節點的數據均定位到Node A上。這樣就解決了服務節點少時數據傾斜的問題。在實際應用中,通常將虛擬節點數設置為32甚至更大,因此即使很少的服務節點也能做到相對均勻的數據分布。

七、總結

上文中,我們一步步分析了什么是一致性Hash算法,主要是考慮到分布式系統每個節點都有可能失效,并且新的節點很可能動態的增加進來的情況,如何保證當系統的節點數目發生變化的時候,我們的系統仍然能夠對外提供良好的服務,這是值得考慮的!


代碼實現

本機部署多個Redis節點

要對一致性Hash進行驗證,要做好準備工作,最直接地,首先要有一個Redis集群。這里我通過使用在本機上部署多個Redis實例指向不同端口來模擬這一形態。

建立項目目錄:$ mkdir redis-conf
之后將redis的配置copy一份過來并復制為5份,分別命名為redis-6379.conf~redis-6383.conf。

需要對其內容進行一些修改才能正常啟動,分別找到配置文件中的如下兩行并對數字進行相應修改。

port 6379
pidfile /var/run/redis_6379.pid

然后就可以分別啟動了:redis-server ./redis-6379 &
可以使用redis-cli -p 6379來指定連接的redis-server。
不妨進行一次嘗試,比如在6379設置key 1 2,而到6380 get 1只能得到nil,說明它們是各自工作的,已經滿足可以測試的條件。

image

代碼實現

先說一下思路。
部署4個節點,從6379到6382,通過一致性Hash算法,將key: 0~99999共100000個key分別set到這4個服務器上,然后再部署一個節點6383,這時再從0到99999開始get一遍,統計get到的次數來驗證命中率是否為期望的80%(4/5)。

一致性Hash算法的實現嚴重借鑒了這篇文章,使用紅黑樹來做數據結構,來實現log(n)的查找時間復雜度,使用FNV1_32_HASH哈希算法來盡可能使key與節點分布得更加均勻,引入了虛擬節點,來做負載均衡。
建議讀者詳細看下這篇文章,里面的講解非常詳細易懂。

下面是我改寫過后的代碼:

package org.guerbai.io.jedistry;

import redis.clients.jedis.Jedis;
import java.util.*;

class JedisProxy {

   private static String[][] redisNodeList = {
           {"localhost", "6379"},
           {"localhost", "6380"},
           {"localhost", "6381"},
           {"localhost", "6382"},
   };

   private static Map<String, Jedis> serverConnectMap = new HashMap<>();

   private static SortedMap<Integer, String> virtualNodes = new TreeMap<>();

   private static final int VIRTUAL_NODES = 100;

   static
   {
       for (String[] str: redisNodeList)
       {
           addServer(str[0], str[1]);
       }
       System.out.println();
   }

   private static 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;
   }

   private static String getServer(String node)
   {
       // 得到帶路由的結點的Hash值
       int hash = getHash(node);
       // 得到大于該Hash值的所有Map
       SortedMap<Integer, String> subMap =
               virtualNodes.tailMap(hash);
       // 第一個Key就是順時針過去離node最近的那個結點
       if (subMap.isEmpty()) {
           subMap = virtualNodes.tailMap(0);
       }
       Integer i = subMap.firstKey();
       // 返回對應的虛擬節點名稱,這里字符串稍微截取一下
       String virtualNode = subMap.get(i);
       return virtualNode.substring(0, virtualNode.indexOf("&&"));
   }

   public static void addServer(String ip, String port) {
       for (int i = 0; i < VIRTUAL_NODES; i++)
       {
           String virtualNodeName = ip + ":" + port + "&&VN" + String.valueOf(i);
           int hash = getHash(virtualNodeName);
           System.out.println("虛擬節點[" + virtualNodeName + "]被添加, hash值為" + hash);
           virtualNodes.put(hash, virtualNodeName);
       }
       serverConnectMap.put(ip+":"+port, new Jedis(ip, Integer.parseInt(port)));
   }

   public String get(String key) {
       String server = getServer(key);
       Jedis serverConnector = serverConnectMap.get(server);
       if (serverConnector.get(key) == null) {
           System.out.println(key + "not in host: " + server);
       }
       return serverConnector.get(key);
   }

   public void set(String key, String value) {
       String server = getServer(key);
       Jedis serverConnector = serverConnectMap.get(server);
       serverConnector.set(key, value);
       System.out.println("set " + key + " into host: " + server);
   }

   public void flushdb() {
       for (String str: serverConnectMap.keySet()) {
           System.out.println("清空host: " + str);
           serverConnectMap.get(str).flushDB();
       }
   }

   public float targetPercent(List<String> keyList) {
       int mingzhong = 0;
       for (String key: keyList) {
           String server = getServer(key);
           Jedis serverConnector = serverConnectMap.get(server);
           if (serverConnector.get(key) != null) {
               mingzhong++;
           }
       }
       return (float) mingzhong / keyList.size();
   }

}

public class ConsistencyHashDemo {

   public static void main(String[] args) {
       JedisProxy jedis = new JedisProxy();
       jedis.flushdb();
       List<String> keyList = new ArrayList<>();
       for (int i=0; i<100000; i++) {
           keyList.add(Integer.toString(i));
           jedis.set(Integer.toString(i), "value");
       }
       System.out.println("target percent before add a server node: " + jedis.targetPercent(keyList));
       JedisProxy.addServer("localhost", "6383");
       System.out.println("target percent after add a server node: " + jedis.targetPercent(keyList));
   }
}

首先,他的getServer方法會有些問題,當key大于最大的虛擬節點hash值時tailMap方法會返回空,找不到節點會報錯,其實這時應該去找hash值最小的一個虛擬節點。我加了處理,把這個環連上了。
getHash方法為FNV1_32_HASH算法,可以不用太在意。
VIRTUAL_NODES的值比較重要,當節點數目較少時,虛擬節點數目越大,命中率越高。

在程序設計上也有很大的不同,我寫了JedisProxy類,來做為client訪問Redis的中間層,在該類的static塊中利用服務器節點生成虛擬節點構造好紅黑樹,getServer里根據tailMap方法取出實際節點的地址,再由實際節點的地址直接拿到jedis對象,提供簡單的get與set方法,先根據key拿特定的jedis對象,再進行get, set操作。

addServer靜態方法給了其動態擴容的能力,可以看到在main方法中,通過調用JedisProxy.addServer("localhost", "6383")便直接增加了節點,不用停應用。
targetPercent方法是用來統計命中率用。

當虛擬節點為5時,命中率約為60%左右,把它加大到100后,可以到達預期的80%的命中率。

image

作者:古二白
鏈接:http://www.lxweimin.com/p/ed83515cb46c
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。