一、Redis集群的使用
我們在使用Redis的時候,為了保證Redis的高可用,提高Redis的讀寫性能,最簡單的方式我們會做主從復制,組成Master-Master或者Master-Slave的形式,或者搭建Redis集群,進行數據的讀寫分離,類似于數據庫的主從復制和讀寫分離。如下所示:
同樣類似于數據庫,當單表數據大于500W的時候需要對其進行分庫分表,當數據量很大的時候(標準可能不一樣,要看Redis服務器容量)我們同樣可以對Redis進行類似的操作,就是分庫分表。
假設,我們有一個社交網站,需要使用Redis存儲圖片資源,存儲的格式為鍵值對,key值為圖片名稱,value為該圖片所在文件服務器的路徑,我們需要根據文件名查找該文件所在文件服務器上的路徑,數據量大概有2000W左右,按照我們約定的規則進行分庫,規則就是隨機分配,我們可以部署8臺緩存服務器,每臺服務器大概含有500W條數據,并且進行主從復制,示意圖如下:
由于規則是隨機的,所有我們的一條數據都有可能存儲在任何一組Redis中,例如上圖我們用戶查找一張名稱為”a.png”的圖片,由于規則是隨機的,我們不確定具體是在哪一個Redis服務器上的,因此我們需要進行1、2、3、4,4次查詢才能夠查詢到(也就是遍歷了所有的Redis服務器),這顯然不是我們想要的結果,有了解過的小伙伴可能會想到,隨機的規則不行,可以使用類似于數據庫中的分庫分表規則:按照Hash值、取模、按照類別、按照某一個字段值等等常見的規則就可以出來了!好,按照我們的主題,我們就使用Hash的方式。
二、為Redis集群使用Hash
可想而知,如果我們使用Hash的方式,每一張圖片在進行分庫的時候都可以定位到特定的服務器,示意圖如下:
上圖中,假設我們查找的是”a.png”,由于有4臺服務器(排除從庫),因此公式為hash(a.png) % 4 = 2
,可知定位到了第2號服務器,這樣的話就不會遍歷所有的服務器,大大提升了性能!
三、使用Hash的問題
上述的方式雖然提升了性能,我們不再需要對整個Redis服務器進行遍歷!但是,使用上述Hash算法進行緩存時,會出現一些缺陷,主要體現在服務器數量變動的時候,所有緩存的位置都要發生改變!
一般算法:
對對象先hash然后對redis數量取模,如果結果是0就存在0的節點上。
1、2同上,假設有0-3四個redis節點、20個數據:
進行取模后分布如下:
現在因為壓力過大需要擴容,增加一臺redis4、第五個節點:
現在只有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位無符號整形),整個哈希環如下:
整個空間按順時針方向組織,圓環的正上方的點代表0,0點右側的第一個點代表1,以此類推,2、3、4、5、6……直到232-1,也就是說0點左側的第一個點代表232-1, 0和232-1在零點中方向重合,我們把這個由232個點組成的圓環稱為Hash環。
下一步將各個服務器使用Hash進行一個哈希,具體可以選擇服務器的IP或主機名作為關鍵字進行哈希,這樣每臺機器就能確定其在哈希環上的位置,這里假設將上文中四臺服務器使用IP地址哈希后在環空間的位置如下:
接下來使用如下算法定位數據訪問到相應服務器:將數據key使用相同的函數Hash計算出哈希值,并確定此數據在環上的位置,從此位置沿環順時針“行走”,第一臺遇到的服務器就是其應該定位到的服務器!
例如我們有Object A、Object B、Object C、Object D四個數據對象,經過哈希計算后,在環空間上的位置如下:
根據一致性Hash算法,數據A會被定為到Node A上,B被定為到Node B上,C被定為到Node C上,D被定為到Node D上。
五、一致性Hash算法的容錯性和可擴展性
現假設Node C不幸宕機,可以看到此時對象A、B、D不會受到影響,只有C對象被重定位到Node D。一般的,在一致性Hash算法中,如果一臺服務器不可用,則受影響的數據僅僅是此服務器到其環空間中前一臺服務器(即沿著逆時針方向行走遇到的第一臺服務器)之間數據,其它不會受到影響,如下所示:
下面考慮另外一種情況,如果在系統中增加一臺服務器Node X,如下圖所示:
此時對象Object A、B、D不受影響,只有對象C需要重定位到新的Node X !一般的,在一致性Hash算法中,如果增加一臺服務器,則受影響的數據僅僅是新服務器到其環空間中前一臺服務器(即沿著逆時針方向行走遇到的第一臺服務器)之間數據,其它數據也不會受到影響。
綜上所述,一致性Hash算法對于節點的增減都只需重定位環空間中的一小部分數據,具有較好的容錯性和可擴展性。
六、Hash環的數據傾斜問題
一致性Hash算法在服務節點太少時,容易因為節點分部不均勻而造成數據傾斜(被緩存的對象大部分集中緩存在某一臺服務器上)問題,例如系統中只有兩臺服務器,其環分布如下:
此時必然造成大量數據集中到Node A上,而只有極少量會定位到Node B上。為了解決這種數據傾斜問題,一致性Hash算法引入了虛擬節點機制,即對每一個服務節點計算多個哈希,每個計算結果位置都放置一個此服務節點,稱為虛擬節點。具體做法可以在服務器IP或主機名的后面增加編號來實現。
例如上面的情況,可以為每臺服務器計算三個虛擬節點,于是可以分別計算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六個虛擬節點:
同時數據定位算法不變,只是多了一步虛擬節點到實際節點的映射,例如定位到“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,說明它們是各自工作的,已經滿足可以測試的條件。
代碼實現
先說一下思路。
部署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%的命中率。
作者:古二白
鏈接:http://www.lxweimin.com/p/ed83515cb46c
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。