一致性Hash演算法
最近在做Redis方面的一些工作,其中Redis3.0以前的版本,伺服器端沒有提供叢集的方式。需要在客戶端做sharding。redis客戶端做sharding的話,需要用到一致性Hash演算法。
假設我們有3臺redis伺服器。

一、普通Hash演算法
1、首先對3臺redis伺服器的ip地址,進行hash運算。得到一個int型別的值。hash演算法如下:
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; }
2、利用得到的hash值對3進行取模
getHash(192.168.7.1)=196713682%3=1 getHash(192.168.7.2)=467665103%3=2 getHash(192.168.7.3)=542751888%3=0
3、假如我們想要把鍵值對name=linlin。存入到redis中,因為有3臺redis存在,所以必須要決定將鍵值對存入到哪一臺中。對key值name進行hash,然後對3進行取模。
getHash("name")=117328155%3=0
所以鍵值對name=linlin會被路由到192.168.7.3這臺伺服器中。
4、使用Java來實現
- 建立RedisNode類
package com.lin.hash.redis; import java.util.HashMap; import java.util.Map; public class RedisNode { private String ip; private Map<String,Object> data; public RedisNode(String ip) { this.ip = ip; data = new HashMap<>(); } public String getIp() { return ip; } public void setIp(String ip) { this.ip = ip; } public Map<String, Object> getData() { return data; } public void setData(Map<String, Object> data) { this.data = data; } public Object getNodeValue(String key){ return data.get(key); } }
2.建立抽象類
package com.lin.hash.redis; import java.util.ArrayList; import java.util.Iterator; import java.util.List; public abstract class RedisCluster { protected List<RedisNode> redisNodes; public RedisCluster() { redisNodes = new ArrayList<>(); } public List<RedisNode> getRedisNodes() { return redisNodes; } public void setRedisNodes(List<RedisNode> redisNodes) { this.redisNodes = redisNodes; } public abstract void addRedisNode(RedisNode redisNode); public abstract RedisNode getRedisNode(String key); public abstract void removeRedisNode(RedisNode redisNode); }
3.建立NormalRedisCluster類
package com.lin.hash.redis; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; public class NormalRedisCluster extends RedisCluster { private List<RedisNode> redisNodes; public NormalRedisCluster() { redisNodes = new ArrayList<>(); } public List<RedisNode> getRedisNodes() { return redisNodes; } public void setRedisNodes(List<RedisNode> redisNodes) { this.redisNodes = redisNodes; } public void addRedisNode(RedisNode redisNode){ redisNodes.add(redisNode); } public RedisNode getRedisNode(String key){ int hash = HashUtils.getHash(key); /**使用key的hash值對key進行取模*/ RedisNode redisNode = redisNodes.get(hash % redisNodes.size()); return redisNode; } public void removeRedisNode(RedisNode redisNode){ Iterator<RedisNode> iterator = redisNodes.iterator(); while (iterator.hasNext()){ RedisNode next = iterator.next(); if (next.getIp().equals(redisNode.getIp())){ iterator.remove(); } } } }
4.建立HashUtils
package com.lin.hash.redis; public class HashUtils { /** * 使用FNV1_32_HASH演算法計算伺服器的Hash值,這裡不使用重寫hashCode的方法,最終效果沒區別 */ public 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; } }
5.建立RedisHashTest類
package com.lin.hash.redis; import com.alibaba.fastjson.JSONObject; import java.util.List; import java.util.Map; public class RedisHashTest { public static void main(String[] args) { String[] servers = {"192.168.7.1","192.168.7.2","192.168.7.3"}; /** * 新增redis節點 */ RedisCluster redisCluster = new NormalRedisCluster(); for (String server:servers){ redisCluster.addRedisNode(new RedisNode(server)); } /** * 往redis叢集中加入100條資料 * */ int dataCount = 100; for (int i=0;i<dataCount;i++){ String key = "name"+i; String value = "linlin"+i; /**獲取節點*/ RedisNode redisNode = redisCluster.getRedisNode(key); Map<String, Object> data = redisNode.getData(); data.put(key,value); } /**獲取redis節點中的資料分佈*/ List<RedisNode> redisNodes = redisCluster.getRedisNodes(); for (RedisNode redisNode:redisNodes){ System.out.println(JSONObject.toJSONString(redisNode.getData())); } /**檢視節點不變的情況下,redis資料的命中率*/ int hitCount = 0; for (int i=0;i<dataCount;i++){ String key = "name"+i; String value = "linlin"+i; Object nodeValue = redisCluster.getRedisNode(key).getNodeValue(key); if (nodeValue != null){ hitCount++; } } System.out.println("資料命中率為:"+hitCount*1.0/dataCount); /**新增一個節點的情況下資料的命中率*/ redisCluster.addRedisNode(new RedisNode("192.168.7.4")); hitCount=0; for (int i=0;i<dataCount;i++){ String key = "name"+i; String value = "linlin"+i; Object nodeValue = redisCluster.getRedisNode(key).getNodeValue(key); if (nodeValue != null){ hitCount++; } } System.out.println("新增節點的情況下,redis資料的命中率:"+hitCount*1.0/dataCount); ///**刪除一個節點的情況下資料的命中率*/ //normalRedisCluster.removeRedisNode(new RedisNode("192.168.7.2")); //hitCount=0; //for (int i=0;i<dataCount;i++){ //String key = "name"+i; //String value = "linlin"+i; //Object nodeValue = normalRedisCluster.getRedisNode(key).getNodeValue(key); //if (nodeValue != null){ //hitCount++; //} //} //System.out.println("刪除節點的情況下,redis資料的命中率:"+hitCount*1.0/dataCount); } }
5.我們可以看到在節點保持不變的情況下,資料的命中率為1.0。
新增一個節點的命中率為:0.27
刪除一個節點的命中率為:0.4
但是無論是新增一個節點或者刪除一個節點。資料的命中率都會顯著降低。
一致性Hash演算法
關於一致性Hash演算法的原理,網路上已經有很多非常棒的文章,我在這裡就不解釋了,為了文章的完整性,在這裡將原理貼出來:
先構造一個長度為232的整數環(這個環被稱為一致性Hash環),根據節點名稱的Hash值(其分佈為[0, 232-1])將伺服器節點放置在這個Hash環上,然後根據資料的Key值計算得到其Hash值(其分佈也為[0, 232-1]),接著在Hash環上順時針查詢距離這個Key值的Hash值最近的伺服器節點,完成Key到伺服器的對映查詢。
一直性Hash演算法會導致資料分佈不均勻的情況產生。如下圖所示

image.png
按照一致性Hash的原理,因為A-B區間佔了圓環的絕大部分,所以大部分的資料都會落入到B節點上,這樣就會導致資料不會均勻的分不到A-B兩個節點中。從而導致負載不均衡。可以通過引入虛擬節點來解決這個問題。
不帶虛擬節點的一致性Hash演算法
下面是不帶虛擬節點的ConsistentWithoutVirtualNodeRedisCluster
package com.lin.hash.redis; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; public class ConsistentWithoutVirtualNodeRedisCluster extends RedisCluster{ private List<RedisNode> redisNodes; private SortedMap<Integer,RedisNode> hashNodeMap = new TreeMap<>(); public ConsistentWithoutVirtualNodeRedisCluster() { redisNodes = new ArrayList<>(); } public List<RedisNode> getRedisNodes() { return redisNodes; } public void setRedisNodes(List<RedisNode> redisNodes) { this.redisNodes = redisNodes; } public void addRedisNode(RedisNode redisNode){ redisNodes.add(redisNode); int hash = HashUtils.getHash(redisNode.getIp()); hashNodeMap.put(hash,redisNode); } public RedisNode getRedisNode(String key){ int hash = HashUtils.getHash(key); SortedMap<Integer, RedisNode> subMap = hashNodeMap.tailMap(hash); int i = 0; if (subMap.size()==0){ i = hashNodeMap.firstKey(); return hashNodeMap.get(i); }else { i = subMap.firstKey(); return subMap.get(i); } } public void removeRedisNode(RedisNode redisNode){ Iterator<RedisNode> iterator = redisNodes.iterator(); while (iterator.hasNext()){ RedisNode next = iterator.next(); if (next.getIp().equals(redisNode.getIp())){ iterator.remove(); } } } }
如果要測試不帶虛擬節點的一直性Hash演算法的命中率,將
RedisCluster redisCluster = new NormalRedisCluster(); 替換成 RedisCluster redisCluster = new ConsistentWithoutVirtualNodeRedisCluster();
就可以進行測試了
帶虛擬節點的一致性Hash演算法
假設我們將一個真實節點對映成10個虛擬節點。例如真正節點的ip是192.168.7.1。那麼對應的十個虛擬ip分別是
真實:192.168.7.1 虛擬: 192.168.7.1&&VN1 192.168.7.1&&VN2 192.168.7.1&&VN3 192.168.7.1&&VN4 192.168.7.1&&VN5 192.168.7.1&&VN6 192.168.7.1&&VN7 192.168.7.1&&VN8 192.168.7.1&&VN9 192.168.7.1&&VN10
當真正進行資料儲存的時候,我們根據虛擬節點的ip資訊, 取出真正的ip,然後進行儲存。
演算法如下:
package com.lin.hash.redis; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.SortedMap; import java.util.TreeMap; public class ConsistentWithVirtualNodeRedisCluster extends RedisCluster{ private List<RedisNode> redisNodes; //定義每個真實節點對應10個虛擬節點 private Integer virtualCount = 10; private SortedMap<Integer,RedisNode> virtualHashNodeMap = new TreeMap<>(); public ConsistentWithVirtualNodeRedisCluster() { redisNodes = new ArrayList<>(); } public List<RedisNode> getRedisNodes() { return redisNodes; } public void setRedisNodes(List<RedisNode> redisNodes) { this.redisNodes = redisNodes; } public void addRedisNode(RedisNode redisNode){ redisNodes.add(redisNode); for (int i=0;i<10;i++){ int hash = HashUtils.getHash(redisNode.getIp()+"&&VN"+(i+1)); virtualHashNodeMap.put(hash,redisNode); } } public RedisNode getRedisNode(String key){ int hash = HashUtils.getHash(key); SortedMap<Integer, RedisNode> subMap = virtualHashNodeMap.tailMap(hash); int i = 0; if (subMap.size()==0){ i = virtualHashNodeMap.firstKey(); return virtualHashNodeMap.get(i); }else { i = subMap.firstKey(); return subMap.get(i); } } public void removeRedisNode(RedisNode redisNode){ Iterator<RedisNode> iterator = redisNodes.iterator(); while (iterator.hasNext()){ RedisNode next = iterator.next(); if (next.getIp().equals(redisNode.getIp())){ iterator.remove(); } } } }
如果要測試帶虛擬節點的一直性Hash演算法的命中率,將
RedisCluster redisCluster = new NormalRedisCluster(); 替換成 RedisCluster redisCluster = new ConsistentWithVirtualNodeRedisCluster();
就可以進行測試了
從測試結果中可以看到,無論是新增節點還是刪除節點,帶虛擬節點的一致性Hash演算法的命中率都大大的提高了。