Redis一致性hash算法

一、Redis集群的使用

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

image

同樣類似于數(shù)據(jù)庫,當(dāng)單表數(shù)據(jù)大于500W的時(shí)候需要對其進(jìn)行分庫分表,當(dāng)數(shù)據(jù)量很大的時(shí)候(標(biāo)準(zhǔn)可能不一樣,要看Redis服務(wù)器容量)我們同樣可以對Redis進(jìn)行類似的操作,就是分庫分表。

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

image

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

二、為Redis集群使用Hash

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

image

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

三、使用Hash的問題

上述的方式雖然提升了性能,我們不再需要對整個(gè)Redis服務(wù)器進(jìn)行遍歷!但是,使用上述Hash算法進(jìn)行緩存時(shí),會(huì)出現(xiàn)一些缺陷,主要體現(xiàn)在服務(wù)器數(shù)量變動(dòng)的時(shí)候,所有緩存的位置都要發(fā)生改變!

一般算法:

對對象先hash然后對redis數(shù)量取模,如果結(jié)果是0就存在0的節(jié)點(diǎn)上。
  1、2同上,假設(shè)有0-3四個(gè)redis節(jié)點(diǎn)、20個(gè)數(shù)據(jù):

image.png

進(jìn)行取模后分布如下:

image

現(xiàn)在因?yàn)閴毫^大需要擴(kuò)容,增加一臺(tái)redis4、第五個(gè)節(jié)點(diǎn):

image

現(xiàn)在只有4個(gè)節(jié)點(diǎn)還能夠命中。命中率是:4/20 = 20%,命中率極其低下。(redis肯定是不會(huì)這樣用的)

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

同樣的,假設(shè)4臺(tái)緩存中突然有一臺(tái)緩存服務(wù)器出現(xiàn)了故障,無法進(jìn)行緩存,那么我們則需要將故障機(jī)器移除,但是如果移除了一臺(tái)緩存服務(wù)器,那么緩存服務(wù)器數(shù)量從4臺(tái)變?yōu)?臺(tái),也是會(huì)出現(xiàn)上述的問題!

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

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

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

image

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

下一步將各個(gè)服務(wù)器使用Hash進(jìn)行一個(gè)哈希,具體可以選擇服務(wù)器的IP或主機(jī)名作為關(guān)鍵字進(jìn)行哈希,這樣每臺(tái)機(jī)器就能確定其在哈希環(huán)上的位置,這里假設(shè)將上文中四臺(tái)服務(wù)器使用IP地址哈希后在環(huán)空間的位置如下:

image

接下來使用如下算法定位數(shù)據(jù)訪問到相應(yīng)服務(wù)器:將數(shù)據(jù)key使用相同的函數(shù)Hash計(jì)算出哈希值,并確定此數(shù)據(jù)在環(huán)上的位置,從此位置沿環(huán)順時(shí)針“行走”,第一臺(tái)遇到的服務(wù)器就是其應(yīng)該定位到的服務(wù)器!

例如我們有Object A、Object B、Object C、Object D四個(gè)數(shù)據(jù)對象,經(jīng)過哈希計(jì)算后,在環(huán)空間上的位置如下:

image

根據(jù)一致性Hash算法,數(shù)據(jù)A會(huì)被定為到Node A上,B被定為到Node B上,C被定為到Node C上,D被定為到Node D上。

五、一致性Hash算法的容錯(cuò)性和可擴(kuò)展性

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

image

下面考慮另外一種情況,如果在系統(tǒng)中增加一臺(tái)服務(wù)器Node X,如下圖所示:

image

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

綜上所述,一致性Hash算法對于節(jié)點(diǎn)的增減都只需重定位環(huán)空間中的一小部分?jǐn)?shù)據(jù),具有較好的容錯(cuò)性和可擴(kuò)展性。

六、Hash環(huán)的數(shù)據(jù)傾斜問題

一致性Hash算法在服務(wù)節(jié)點(diǎn)太少時(shí),容易因?yàn)楣?jié)點(diǎn)分部不均勻而造成數(shù)據(jù)傾斜(被緩存的對象大部分集中緩存在某一臺(tái)服務(wù)器上)問題,例如系統(tǒng)中只有兩臺(tái)服務(wù)器,其環(huán)分布如下:

image

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

例如上面的情況,可以為每臺(tái)服務(wù)器計(jì)算三個(gè)虛擬節(jié)點(diǎn),于是可以分別計(jì)算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六個(gè)虛擬節(jié)點(diǎn):

image

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

七、總結(jié)

上文中,我們一步步分析了什么是一致性Hash算法,主要是考慮到分布式系統(tǒng)每個(gè)節(jié)點(diǎn)都有可能失效,并且新的節(jié)點(diǎn)很可能動(dòng)態(tài)的增加進(jìn)來的情況,如何保證當(dāng)系統(tǒng)的節(jié)點(diǎn)數(shù)目發(fā)生變化的時(shí)候,我們的系統(tǒng)仍然能夠?qū)ν馓峁┝己玫姆?wù),這是值得考慮的!


代碼實(shí)現(xiàn)

本機(jī)部署多個(gè)Redis節(jié)點(diǎn)

要對一致性Hash進(jìn)行驗(yàn)證,要做好準(zhǔn)備工作,最直接地,首先要有一個(gè)Redis集群。這里我通過使用在本機(jī)上部署多個(gè)Redis實(shí)例指向不同端口來模擬這一形態(tài)。

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

需要對其內(nèi)容進(jìn)行一些修改才能正常啟動(dòng),分別找到配置文件中的如下兩行并對數(shù)字進(jìn)行相應(yīng)修改。

port 6379
pidfile /var/run/redis_6379.pid

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

image

代碼實(shí)現(xiàn)

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

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

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

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;

       // 如果算出來的值為負(fù)數(shù)則取其絕對值
       if (hash < 0)
           hash = Math.abs(hash);
       return hash;
   }

   private static String getServer(String node)
   {
       // 得到帶路由的結(jié)點(diǎn)的Hash值
       int hash = getHash(node);
       // 得到大于該Hash值的所有Map
       SortedMap<Integer, String> subMap =
               virtualNodes.tailMap(hash);
       // 第一個(gè)Key就是順時(shí)針過去離node最近的那個(gè)結(jié)點(diǎn)
       if (subMap.isEmpty()) {
           subMap = virtualNodes.tailMap(0);
       }
       Integer i = subMap.firstKey();
       // 返回對應(yīng)的虛擬節(jié)點(diǎn)名稱,這里字符串稍微截取一下
       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("虛擬節(jié)點(diǎn)[" + 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方法會(huì)有些問題,當(dāng)key大于最大的虛擬節(jié)點(diǎn)hash值時(shí)tailMap方法會(huì)返回空,找不到節(jié)點(diǎn)會(huì)報(bào)錯(cuò),其實(shí)這時(shí)應(yīng)該去找hash值最小的一個(gè)虛擬節(jié)點(diǎn)。我加了處理,把這個(gè)環(huán)連上了。
getHash方法為FNV1_32_HASH算法,可以不用太在意。
VIRTUAL_NODES的值比較重要,當(dāng)節(jié)點(diǎn)數(shù)目較少時(shí),虛擬節(jié)點(diǎn)數(shù)目越大,命中率越高。

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

addServer靜態(tài)方法給了其動(dòng)態(tài)擴(kuò)容的能力,可以看到在main方法中,通過調(diào)用JedisProxy.addServer("localhost", "6383")便直接增加了節(jié)點(diǎn),不用停應(yīng)用。
targetPercent方法是用來統(tǒng)計(jì)命中率用。

當(dāng)虛擬節(jié)點(diǎn)為5時(shí),命中率約為60%左右,把它加大到100后,可以到達(dá)預(yù)期的80%的命中率。

image

作者:古二白
鏈接:http://www.itdecent.cn/p/ed83515cb46c
來源:簡書
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請注明出處。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容