一致性雜湊負載均衡演算法的探討

Kirito的技術分享發表於2019-03-04

前言

一致性雜湊演算法在很多領域有應用,例如分散式快取領域的 MemCache,Redis,負載均衡領域的 Nginx,各類 RPC 框架。不同領域場景不同,需要顧及的因素也有所差異,本文主要討論在負載均衡中一致性雜湊演算法的設計。

在介紹一致性雜湊演算法之前,我將會介紹一些雜湊演算法,討論它們的區別和使用場景。也會給出一致性雜湊演算法的 Java 通用實現,可以直接引用,文末會給出 github 地址。

友情提示:閱讀本文前,最好對一致性雜湊演算法有所瞭解,例如你最好聽過一致性雜湊環這個概念,我會在基本概念上縮短篇幅。

一致性雜湊負載均衡介紹

負載均衡這個概念可以抽象為:從 n 個候選伺服器中選擇一個進行通訊的過程。負載均衡演算法有多種多樣的實現方式:隨機、輪詢、最小負載優先等,其中也包括了今天的主角:一致性雜湊負載均衡。一致性雜湊負載均衡需要保證的是“相同的請求儘可能落到同一個伺服器上”,注意這短短的一句描述,卻包含了相當大的資訊量。“相同的請求” — 什麼是相同的請求?一般在使用一致性雜湊負載均衡時,需要指定一個 key 用於 hash 計算,可能是:

  1. 請求方 IP
  2. 請求服務名稱,引數列表構成的串
  3. 使用者 ID

“儘可能” —為什麼不是一定?因為伺服器可能發生上下線,所以少數伺服器的變化不應該影響大多數的請求。這也呼應了演算法名稱中的“一致性”。

同時,一個優秀的負載均衡演算法還有一個隱性要求:流量儘可能均勻分佈。

綜上所述,我們可以概括出一致性雜湊負載均衡演算法的設計思路。

  • 儘可能保證每個伺服器節點均勻的分攤流量
  • 儘可能保證伺服器節點的上下線不影響流量的變更

雜湊演算法介紹

雜湊演算法是一致性雜湊演算法中重要的一個組成部分,你可以藉助 Java 中的 int hashCode()去理解它。 說到雜湊演算法,你想到了什麼?Jdk 中的 hashCode、SHA-1、MD5,除了這些耳熟能詳的雜湊演算法,還存在很多其他實現,詳見 HASH 演算法一覽。可以將他們分成三代:

  • 第一代:SHA-1(1993),MD5(1992),CRC(1975),Lookup3(2006)
  • 第二代:MurmurHash(2008)
  • 第三代:CityHash, SpookyHash(2011)

這些都可以認為是廣義上的雜湊演算法,你可以在 wiki 百科 中檢視所有的雜湊演算法。當然還有一些雜湊演算法如:Ketama,專門為一致性雜湊演算法而設計。

既然有這麼多雜湊演算法,那必然會有人問:當我們在討論雜湊演算法時,我們再考慮哪些東西?我大概總結下有以下四點:

  1. 實現複雜程度
  2. 分佈均勻程度
  3. 雜湊碰撞概率
  4. 效能

先聊聊效能,是不是效能越高就越好呢?你如果有看過我曾經的文章 《該如何設計你的 PasswordEncoder?》 ,應該能瞭解到,在設計加密器這個場景下,慢 hash 演算法反而有優勢;而在負載均衡這個場景下,安全性不是需要考慮的因素,所以效能自然是越高越好。

優秀的演算法通常比較複雜,但不足以構成評價標準,有點黑貓白貓論,所以 2,3 兩點:分佈均勻程度,雜湊碰撞概率成了主要考慮的因素。

我挑選了幾個值得介紹的雜湊演算法,重點介紹下。

  1. MurmurHash 演算法:高運算效能,低碰撞率,由 Austin Appleby 建立於 2008 年,現已應用到 Hadoop、libstdc++、nginx、libmemcached 等開源系統。2011 年 Appleby 被 Google 僱傭,隨後 Google 推出其變種的 CityHash 演算法。官方只提供了 C 語言的實現版本。

    Java 界中 Redis,Memcached,Cassandra,HBase,Lucene 都在使用它。

    在 Java 的實現,Guava 的 Hashing 類裡有,上面提到的 Jedis,Cassandra 裡都有相關的 Util 類。

  2. FNV 演算法:全名為 Fowler-Noll-Vo 演算法,是以三位發明人 Glenn Fowler,Landon Curt Noll,Phong Vo 的名字來命名的,最早在 1991 年提出。

    特點和用途:FNV 能快速 hash 大量資料並保持較小的衝突率,它的高度分散使它適用於 hash 一些非常相近的字串,比如 URL,hostname,檔名,text,IP 地址等。

  3. Ketama 演算法:將它稱之為雜湊演算法其實不太準確,稱之為一致性雜湊演算法可能更為合適,其他的雜湊演算法有通用的一致性雜湊演算法實現,只不過是替換了雜湊方式而已,但 Ketama 是一整套的流程,我們將在後面介紹。

以上三者都是最合適的一致性雜湊演算法的強力爭奪者。

一致性雜湊演算法實現

一致性hash

一致性雜湊的概念我不做贅述,簡單介紹下這個負載均衡中的一致性雜湊環。首先將伺服器(ip+埠號)進行雜湊,對映成環上的一個節點,在請求到來時,根據指定的 hash key 同樣對映到環上,並順時針選取最近的一個伺服器節點進行請求(在本圖中,使用的是 userId 作為 hash key)。

當環上的伺服器較少時,即使雜湊演算法選擇得當,依舊會遇到大量請求落到同一個節點的問題,為避免這樣的問題,大多數一致性雜湊演算法的實現度引入了虛擬節點的概念。

一致性hash虛擬節點

在上圖中,只有兩臺物理伺服器節點:11.1.121.1 和 11.1.121.2,我們通過新增字尾的方式,克隆出了另外三份節點,使得環上的節點分佈的均勻。一般來說,物理節點越多,所需的虛擬節點就越少。

介紹完了一致性雜湊換,我們便可以對負載均衡進行建模了:

public interface LoadBalancer {
    Server select(List<Server> servers, Invocation invocation);
}
複製程式碼

下面直接給出通用的演算法實現:

public class ConsistentHashLoadBalancer implements LoadBalancer{

    private HashStrategy hashStrategy = new JdkHashCodeStrategy();

    private final static int VIRTUAL_NODE_SIZE = 10;
    private final static String VIRTUAL_NODE_SUFFIX = "&&";

    @Override
    public Server select(List<Server> servers, Invocation invocation) {
        int invocationHashCode = hashStrategy.getHashCode(invocation.getHashKey());
        TreeMap<Integer, Server> ring = buildConsistentHashRing(servers);
        Server server = locate(ring, invocationHashCode);
        return server;
    }

    private Server locate(TreeMap<Integer, Server> ring, int invocationHashCode) {
        // 向右找到第一個 key
        Map.Entry<Integer, Server> locateEntry = ring.ceilingEntry(invocationHashCode);
        if (locateEntry == null) {
            // 想象成一個環,超過尾部則取第一個 key
            locateEntry = ring.firstEntry();
        }
        return locateEntry.getValue();
    }

    private TreeMap<Integer, Server> buildConsistentHashRing(List<Server> servers) {
        TreeMap<Integer, Server> virtualNodeRing = new TreeMap<>();
        for (Server server : servers) {
            for (int i = 0; i < VIRTUAL_NODE_SIZE; i++) {
                // 新增虛擬節點的方式如果有影響,也可以抽象出一個由物理節點擴充套件虛擬節點的類
                virtualNodeRing.put(hashStrategy.getHashCode(server.getUrl() + VIRTUAL_NODE_SUFFIX + i), server);
            }
        }
        return virtualNodeRing;
    }

}
複製程式碼

對上述的程式做簡單的解讀:

Server 是對伺服器的抽象,一般是 ip+port 的形式。

public class Server {
    private String url;
}
複製程式碼

Invocation 是對請求的抽象,包含一個用於 hash 的 key。

public class Invocation {
    private String hashKey;
}
複製程式碼

使用 TreeMap 作為一致性雜湊環的資料結構,ring.ceilingEntry 可以獲取環上最近的一個節點。在 buildConsistentHashRing 之中包含了構建一致性雜湊環的過程,預設加入了 10 個虛擬節點。

計算方差,標準差的公式:

public class StatisticsUtil {

    //方差s^2=[(x1-x)^2 +...(xn-x)^2]/n
    public static double variance(Long[] x) {
        int m = x.length;
        double sum = 0;
        for (int i = 0; i < m; i++) {//求和
            sum += x[i];
        }
        double dAve = sum / m;//求平均值
        double dVar = 0;
        for (int i = 0; i < m; i++) {//求方差
            dVar += (x[i] - dAve) * (x[i] - dAve);
        }
        return dVar / m;
    }

    //標準差σ=sqrt(s^2)
    public static double standardDeviation(Long[] x) {
        int m = x.length;
        double sum = 0;
        for (int i = 0; i < m; i++) {//求和
            sum += x[i];
        }
        double dAve = sum / m;//求平均值
        double dVar = 0;
        for (int i = 0; i < m; i++) {//求方差
            dVar += (x[i] - dAve) * (x[i] - dAve);
        }
        return Math.sqrt(dVar / m);
    }

}
複製程式碼

其中,HashStrategy 是下文中重點討論的一個內容,他是對 hash 演算法的抽象,我們將會著重對比各種 hash 演算法給測評結果帶來的差異性。

public interface HashStrategy {
    int getHashCode(String origin);
}
複製程式碼

測評程式

前面我們已經明確了一個優秀的一致性雜湊演算法的設計思路。這一節我們給出實際的量化指標:假設 m 次請求打到 n 個候選伺服器上

  • 統計每個服務節點收到的流量,計算方差、標準差。測量流量分佈均勻情況,我們可以模擬 10000 個隨機請求,打到 100 個指定伺服器,測試最後個節點的方差,標準差。
  • 記錄 m 次請求落到的伺服器節點,下線 20% 的伺服器,重放流量,統計 m 次請求中落到跟原先相同伺服器的概率。測量節點上下線的情況,我們可以模擬 10000 個隨機請求,打到 100 個指定伺服器,之後下線 20 個伺服器並重放流量,統計請求到相同伺服器的比例。
public class LoadBalanceTest {
    
    static String[] ips = {...}; // 100 臺隨機 ip

    /**
     * 測試分佈的離散情況
     */
    @Test
    public void testDistribution() {
        List<Server> servers = new ArrayList<>();
        for (String ip : ips) {
            servers.add(new Server(ip+":8080"));
        }
        LoadBalancer chloadBalance = new ConsistentHashLoadBalancer();
        // 構造 10000 隨機請求
        List<Invocation> invocations = new ArrayList<>();
        for (int i = 0; i < 10000; i++) {
            invocations.add(new Invocation(UUID.randomUUID().toString()));
        }
        // 統計分佈
        AtomicLongMap<Server> atomicLongMap = AtomicLongMap.create();
        for (Server server : servers) {
            atomicLongMap.put(server, 0);
        }
        for (Invocation invocation : invocations) {
            Server selectedServer = chloadBalance.select(servers, invocation);
            atomicLongMap.getAndIncrement(selectedServer);
        }
        System.out.println(StatisticsUtil.variance(atomicLongMap.asMap().values().toArray(new Long[]{})));
        System.out.println(StatisticsUtil.standardDeviation(atomicLongMap.asMap().values().toArray(new Long[]{})));
    }

    /**
     * 測試節點新增刪除後的變化程度
     */
    @Test
    public void testNodeAddAndRemove() {
        List<Server> servers = new ArrayList<>();
        for (String ip : ips) {
            servers.add(new Server(ip));
        }
        List<Server> serverChanged = servers.subList(0, 80);
        ConsistentHashLoadBalancer chloadBalance = new ConsistentHashLoadBalancer();
        // 構造 10000 隨機請求
        List<Invocation> invocations = new ArrayList<>();
        for (int i = 0; i < 10000; i++) {
            invocations.add(new Invocation(UUID.randomUUID().toString()));
        }
        int count = 0;
        for (Invocation invocation : invocations) {
            Server origin = chloadBalance.select(servers, invocation);
            Server changed = chloadBalance.select(serverChanged, invocation);
            if (origin.getUrl().equals(changed.getUrl())) count++;
        }
        System.out.println(count / 10000D);
    }
複製程式碼

不同雜湊演算法的實現及測評

最簡單、經典的 hashCode 實現:

public class JdkHashCodeStrategy implements HashStrategy {
    @Override
    public int getHashCode(String origin) {
        return origin.hashCode();
    }
}
複製程式碼

FNV1_32_HASH 演算法實現:

public class FnvHashStrategy implements HashStrategy {

    private static final long FNV_32_INIT = 2166136261L;
    private static final int FNV_32_PRIME = 16777619;

    @Override
    public int getHashCode(String origin) {
        final int p = FNV_32_PRIME;
        int hash = (int) FNV_32_INIT;
        for (int i = 0; i < origin.length(); i++)
            hash = (hash ^ origin.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;
        hash = Math.abs(hash);
        return hash;
    }
}
複製程式碼

CRC 演算法:

public class CRCHashStrategy implements HashStrategy {

    private static final int LOOKUP_TABLE[] = {0x0000, 0x1021, 0x2042, 0x3063,
            0x4084, 0x50A5, 0x60C6, 0x70E7, 0x8108, 0x9129, 0xA14A, 0xB16B,
            0xC18C, 0xD1AD, 0xE1CE, 0xF1EF, 0x1231, 0x0210, 0x3273, 0x2252,
            0x52B5, 0x4294, 0x72F7, 0x62D6, 0x9339, 0x8318, 0xB37B, 0xA35A,
            0xD3BD, 0xC39C, 0xF3FF, 0xE3DE, 0x2462, 0x3443, 0x0420, 0x1401,
            0x64E6, 0x74C7, 0x44A4, 0x5485, 0xA56A, 0xB54B, 0x8528, 0x9509,
            0xE5EE, 0xF5CF, 0xC5AC, 0xD58D, 0x3653, 0x2672, 0x1611, 0x0630,
            0x76D7, 0x66F6, 0x5695, 0x46B4, 0xB75B, 0xA77A, 0x9719, 0x8738,
            0xF7DF, 0xE7FE, 0xD79D, 0xC7BC, 0x48C4, 0x58E5, 0x6886, 0x78A7,
            0x0840, 0x1861, 0x2802, 0x3823, 0xC9CC, 0xD9ED, 0xE98E, 0xF9AF,
            0x8948, 0x9969, 0xA90A, 0xB92B, 0x5AF5, 0x4AD4, 0x7AB7, 0x6A96,
            0x1A71, 0x0A50, 0x3A33, 0x2A12, 0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E,
            0x9B79, 0x8B58, 0xBB3B, 0xAB1A, 0x6CA6, 0x7C87, 0x4CE4, 0x5CC5,
            0x2C22, 0x3C03, 0x0C60, 0x1C41, 0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD,
            0xAD2A, 0xBD0B, 0x8D68, 0x9D49, 0x7E97, 0x6EB6, 0x5ED5, 0x4EF4,
            0x3E13, 0x2E32, 0x1E51, 0x0E70, 0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC,
            0xBF1B, 0xAF3A, 0x9F59, 0x8F78, 0x9188, 0x81A9, 0xB1CA, 0xA1EB,
            0xD10C, 0xC12D, 0xF14E, 0xE16F, 0x1080, 0x00A1, 0x30C2, 0x20E3,
            0x5004, 0x4025, 0x7046, 0x6067, 0x83B9, 0x9398, 0xA3FB, 0xB3DA,
            0xC33D, 0xD31C, 0xE37F, 0xF35E, 0x02B1, 0x1290, 0x22F3, 0x32D2,
            0x4235, 0x5214, 0x6277, 0x7256, 0xB5EA, 0xA5CB, 0x95A8, 0x8589,
            0xF56E, 0xE54F, 0xD52C, 0xC50D, 0x34E2, 0x24C3, 0x14A0, 0x0481,
            0x7466, 0x6447, 0x5424, 0x4405, 0xA7DB, 0xB7FA, 0x8799, 0x97B8,
            0xE75F, 0xF77E, 0xC71D, 0xD73C, 0x26D3, 0x36F2, 0x0691, 0x16B0,
            0x6657, 0x7676, 0x4615, 0x5634, 0xD94C, 0xC96D, 0xF90E, 0xE92F,
            0x99C8, 0x89E9, 0xB98A, 0xA9AB, 0x5844, 0x4865, 0x7806, 0x6827,
            0x18C0, 0x08E1, 0x3882, 0x28A3, 0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E,
            0x8BF9, 0x9BD8, 0xABBB, 0xBB9A, 0x4A75, 0x5A54, 0x6A37, 0x7A16,
            0x0AF1, 0x1AD0, 0x2AB3, 0x3A92, 0xFD2E, 0xED0F, 0xDD6C, 0xCD4D,
            0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9, 0x7C26, 0x6C07, 0x5C64, 0x4C45,
            0x3CA2, 0x2C83, 0x1CE0, 0x0CC1, 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C,
            0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, 0x6E17, 0x7E36, 0x4E55, 0x5E74,
            0x2E93, 0x3EB2, 0x0ED1, 0x1EF0,};

    /**
     * Create a CRC16 checksum from the bytes. implementation is from
     * mp911de/lettuce, modified with some more optimizations
     *
     * @param bytes
     * @return CRC16 as integer value
     */
    public static int getCRC16(byte[] bytes) {
        int crc = 0x0000;

        for (byte b : bytes) {
            crc = ((crc << 8) ^ LOOKUP_TABLE[((crc >>> 8) ^ (b & 0xFF)) & 0xFF]);
        }
        return crc & 0xFFFF;
    }

    public static int getCRC16(String key) {
        return getCRC16(key.getBytes(Charset.forName("UTF-8")));
    }

    @Override
    public int getHashCode(String origin) {
        // optimization with modulo operator with power of 2
        // equivalent to getCRC16(key) % 16384
        return getCRC16(origin) & (16384 - 1);
    }
}
複製程式碼

Ketama 演算法:

public class KetamaHashStrategy implements HashStrategy {

    private static MessageDigest md5Digest;

    static {
        try {
            md5Digest = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("MD5 not supported", e);
        }
    }

    @Override
    public int getHashCode(String origin) {
        byte[] bKey = computeMd5(origin);
        long rv = ((long) (bKey[3] & 0xFF) << 24)
                | ((long) (bKey[2] & 0xFF) << 16)
                | ((long) (bKey[1] & 0xFF) << 8)
                | (bKey[0] & 0xFF);
        return (int) (rv & 0xffffffffL);
    }

    /**
     * Get the md5 of the given key.
     */
    public static byte[] computeMd5(String k) {
        MessageDigest md5;
        try {
            md5 = (MessageDigest) md5Digest.clone();
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException("clone of MD5 not supported", e);
        }
        md5.update(k.getBytes());
        return md5.digest();
    }
}
複製程式碼

MurmurHash 演算法:

public class MurmurHashStrategy implements HashStrategy {
    @Override
    public int getHashCode(String origin) {

        ByteBuffer buf = ByteBuffer.wrap(origin.getBytes());
        int seed = 0x1234ABCD;

        ByteOrder byteOrder = buf.order();
        buf.order(ByteOrder.LITTLE_ENDIAN);

        long m = 0xc6a4a7935bd1e995L;
        int r = 47;

        long h = seed ^ (buf.remaining() * m);

        long k;
        while (buf.remaining() >= 8) {
            k = buf.getLong();

            k *= m;
            k ^= k >>> r;
            k *= m;

            h ^= k;
            h *= m;
        }

        if (buf.remaining() > 0) {
            ByteBuffer finish = ByteBuffer.allocate(8).order(
                    ByteOrder.LITTLE_ENDIAN);
            // for big-endian version, do this first:
            // finish.position(8-buf.remaining());
            finish.put(buf).rewind();
            h ^= finish.getLong();
            h *= m;
        }
        h ^= h >>> r;
        h *= m;
        h ^= h >>> r;

        buf.order(byteOrder);
        return (int) (h & 0xffffffffL);
    }
}
複製程式碼

測評結果:

方差 標準差 不變流量比例
JdkHashCodeStrategy 29574.08 171.97 0.6784
CRCHashStrategy 3013.02 54.89 0.7604
FnvHashStrategy 961.64 31.01 0.7892
KetamaHashStrategy 1254.64 35.42 0.7986
MurmurHashStrategy 815.72 28.56 0.7971

其中方差和標準差反映了均勻情況,越低越好,可以發現 MurmurHashStrategy,KetamaHashStrategy,FnvHashStrategy 都表現的不錯。

不變流量比例體現了伺服器上下線對原有請求的影響程度,不變流量比例越高越高,可以發現 KetamaHashStrategy 和 MurmurHashStrategy 表現最為優秀。

我並沒有對小叢集,小流量進行測試,樣本偏差性較大,僅從這個常見場景來看,MurmurHashStrategy 是一個不錯的選擇,多次測試後發現 FnvHashStrategyKetamaHashStrategyMurmurHashStrategy 差距不是很大。

至於效能測試,MurmurHash 也十分的高效能,我並沒有做測試(感興趣的同學可以對幾種 strategy 用 JMH 測評一下),這裡我貼一下 MurmurHash 官方的測評資料:

OneAtATime - 354.163715 mb/sec
FNV - 443.668038 mb/sec
SuperFastHash - 985.335173 mb/sec
lookup3 - 988.080652 mb/sec
MurmurHash 1.0 - 1363.293480 mb/sec
MurmurHash 2.0 - 2056.885653 mb/sec
複製程式碼

擴大虛擬節點可以明顯降低方差和標準差,但虛擬節點的增加會加大記憶體佔用量以及計算量

Ketama 一致性雜湊演算法實現

Ketama 演算法有其專門的配套實現方式

public class KetamaConsistentHashLoadBalancer implements LoadBalancer {

    private static MessageDigest md5Digest;

    static {
        try {
            md5Digest = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("MD5 not supported", e);
        }
    }

    private final static int VIRTUAL_NODE_SIZE = 12;
    private final static String VIRTUAL_NODE_SUFFIX = "-";

    @Override
    public Server select(List<Server> servers, Invocation invocation) {
        long invocationHashCode = getHashCode(invocation.getHashKey());
        TreeMap<Long, Server> ring = buildConsistentHashRing(servers);
        Server server = locate(ring, invocationHashCode);
        return server;
    }

    private Server locate(TreeMap<Long, Server> ring, Long invocationHashCode) {
        // 向右找到第一個 key
        Map.Entry<Long, Server> locateEntry = ring.ceilingEntry(invocationHashCode);
        if (locateEntry == null) {
            // 想象成一個環,超過尾部則取第一個 key
            locateEntry = ring.firstEntry();
        }
        return locateEntry.getValue();
    }

    private TreeMap<Long, Server> buildConsistentHashRing(List<Server> servers) {
        TreeMap<Long, Server> virtualNodeRing = new TreeMap<>();
        for (Server server : servers) {
            for (int i = 0; i < VIRTUAL_NODE_SIZE / 4; i++) {
                byte[] digest = computeMd5(server.getUrl() + VIRTUAL_NODE_SUFFIX + i);
                for (int h = 0; h < 4; h++) {
                    Long k = ((long) (digest[3 + h * 4] & 0xFF) << 24)
                            | ((long) (digest[2 + h * 4] & 0xFF) << 16)
                            | ((long) (digest[1 + h * 4] & 0xFF) << 8)
                            | (digest[h * 4] & 0xFF);
                    virtualNodeRing.put(k, server);

                }
            }
        }
        return virtualNodeRing;
    }

    private long getHashCode(String origin) {
        byte[] bKey = computeMd5(origin);
        long rv = ((long) (bKey[3] & 0xFF) << 24)
                | ((long) (bKey[2] & 0xFF) << 16)
                | ((long) (bKey[1] & 0xFF) << 8)
                | (bKey[0] & 0xFF);
        return rv;
    }

    private static byte[] computeMd5(String k) {
        MessageDigest md5;
        try {
            md5 = (MessageDigest) md5Digest.clone();
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException("clone of MD5 not supported", e);
        }
        md5.update(k.getBytes());
        return md5.digest();
    }

}
複製程式碼

稍微不同的地方便在於:Ketama 將四個節點標為一組進行了虛擬節點的設定。

方差 標準差 不變流量比例
KetamaConsistentHashLoadBalancer 911.08 30.18 0.7936

實際結果並沒有太大的提升,可能和測試資料的樣本規模有關。

總結

優秀的雜湊演算法和一致性雜湊演算法可以幫助我們在大多數場景下應用的高效能,高穩定性,但在實際使用一致性雜湊負載均衡的場景中,最好針對實際的叢集規模和請求雜湊方式進行壓測,力保流量均勻打到所有的機器上,這才是王道。

不僅僅是分散式快取,負載均衡等等有限的場景,一致性雜湊演算法、雜湊演算法,尤其是後者,是一個用處很廣泛的常見演算法,瞭解它的經典實現是很有必要的,例如 MurmurHash,在 guava 中就有其 Java 實現,當需要高效能,分佈均勻,碰撞概率小的雜湊演算法時,可以考慮使用它。

本文程式碼的 github 地址:https://github.com/lexburner/consistent-hash-algorithm

擴充套件閱讀

深入理解 RPC 之叢集篇

《該如何設計你的 PasswordEncoder?》

參考文章

MurmurHash

memcached Java客戶端spymemcached的一致性Hash演算法

相關文章