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

同樣類似于數(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ù)制,示意圖如下:

由于規(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ù)器,示意圖如下:

上圖中,假設(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ù):

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

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

現(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)如下:

整個(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)空間的位置如下:

接下來使用如下算法定位數(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)空間上的位置如下:

根據(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ì)受到影響,如下所示:

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

此時(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)分布如下:

此時(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):

同時(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)滿足可以測試的條件。

代碼實(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%的命中率。

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