Redis集羣一致性Hash效果的代碼演示

在微服務領域,使用Redis作緩存可並非一件容易的事情。html

像新浪、推特這樣的應用,許許多多的熱點數據全都存放在Redis這一層,打到DB層的請求並很少,能夠說很是依賴緩存了。若是緩存掛掉,流量所有穿透到DB層,其必然不堪其重,整個系統也會隨之癱瘓,後果很是嚴重。 因爲緩存數據量很大,Redis快正是快在其基於內存的快速存取,而計算機的內存資源又是十分有限的,故分佈式緩存集羣面臨着伸縮性的要求。java

一致性Hash存在的意義

Redis集羣中的各實例之間是並不知道對方的,須要在客戶端實現路由法來將key路由到不一樣的redis節點。node

該路由算法是關鍵,它必須讓新上線的緩存服務器對整個分佈式緩存集羣影響最小,使得擴容後,整個緩存服務器集羣中已經緩存的數據儘量還被訪問到。redis

如果使用通常的對key進行一次hash的算法,則會致使擴容後命中率極低。 以下表所示,當集羣由3個節點擴容到4個節點時,會有75%的key沒法命中。算法

hash(key) hash(key)/3 hash(key)/4 是否命中
1 1 1
2 2 2
3 0 3
4 1 0
5 2 1
6 0 2
7 1 3
8 2 0
9 0 1
10 1 2
11 2 3
12 0 0

這可太糟糕了,當服務器數量爲100臺時,再增長一臺新服務器,不能命中率將達到99%,這和整個緩存服務掛了一個效果。緩存

而一致性Hash正是爲了解決這個問題而出現的,該路由算法經過引入一個一致性Hash環,以及進一步增長虛擬節點層,來實現儘量高的命中率。 使用該算法,當節點由n擴容爲n+1時,命中率可保持在n/(n+1)左右。bash

關於該算法的具體原理與網上已經有一些說得很透徹的文章,本文再也不贅述。 下面主要從代碼實現及運行的方式來對此算法的效果進行展現。服務器

本機部署多個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%的命中率。

測試結果

好的,完美。

相關文章
相關標籤/搜索