一致性哈希算法(Java实现)

前言

一致性哈希算法在分布式系统的应用中是十分广泛的。常见的应用场景是分布式缓存。它主要解决了哈希取模算法在分布式系统中存在的动态伸缩等问题。

哈希取模算法的局限性

在分布式缓存集群中,当新增加缓存服务器或其中一台挂掉后,由路由算法发生改变,导致大量的缓存数据不能命中。从而造成数据库面临巨大压力而崩溃,可能导致整个系统崩溃。

一致性哈希算法原理

一致性哈希算法通过一个叫作一致性哈希环的数据结构实现。这个环的起点是,终点是,并且起点和终点相连接,故这个环的整数分布范围是。

将服务器节点和key放到哈希环上

我们将服务器节点和key的hash值放置到哈希环上,如下图:

服务器节点分别是NODE0、NODE1、NODE2。key分别代表key1 ~ key8。

将key和服务器节点都放置到同一个哈希环后,在哈希环上顺时针查找距离这个 key 的 hash 值最近的机器,即是这个key所属的机器。

key1、key8在节点NODE0上;key2、key5在节点NODE1上;key3、key4、key6在节点NODE2上。

增加服务器(扩容)

由于业务需要,如缓存集群压力过大,我们需要增加一台服务器(NODE3)。经过hash函数计算,NODE3节点落在NODE1和NODE2之间。如下图:

对上述情况,只有NODE1和NODE2节点之间的key需要重新分配。key4没有变,还在NODE2节点。只是key3、key6重新分配新的节点NODE3上。我们发现,一致性哈希算法只需要很少部分key需要重新分配。而哈希取模方式则大部分缓存会失效。

减少服务器(缩容)

由于某个服务器出现故障导致下线,如NODE3下线,这时原本key3、key6存储在NODE3上,需要重新被分配至NODE2节点上,其它key不受此影响。

服务器节点分布不均匀

  1. 如果服务器节点不是均匀的分布在哈希环上,那么有可能造成服务节点负载压力不均衡。
  2. 当新增加一台服务器时,节点NODE3只是分担了节点NODE2的压力,造成服务器压力不均摊。显然这个结果不是我们期望的。

针对这个问题,我们可以通过引入虚拟节点来解决负载不均衡的问题。即将每台物理服务器虚拟为一组虚拟服务器,将虚拟服务器放置到哈希环上,如果要确定key所在的服务器,需先确定key所在的虚拟服务器,再由虚拟服务器确定物理服务器。

基于虚拟节点的一致性哈希

一台物理服务器,虚拟成若干个虚拟节点,随机分布在环上,压力近似均衡分摊。如有三台物理服务器,每台物理服务器虚拟出150个虚拟节点,随机分配在Hash环上的150个位置上。最后可使三台物理服务器近似均摊压力。当增加一台服务器时,先虚拟150个节点,然后散落在哈希环上。

一致性哈希算法实现

Java代码:

public class ConsistentHashing {

    private SortedMap hashCircle = new TreeMap<>();
    private int virtualNums; // 虚拟节点数

    public ConsistentHashing(Node[] nodes, int virtualNums) {
        this.virtualNums = virtualNums;
        // 初始化一致性hash环
        for (Node node : nodes) {
            // 创建虚拟节点
            add(node);
        }
    }

    /**
     * 添加服务器节点
     *
     * @param node the server
     */
    public void add(Node node) {
        for (int i = 0; i < virtualNums; i++) {
            hashCircle.put(hash(node.toString() + i), node);
        }
    }

    /**
     * 删除服务器节点
     *
     * @param node the server
     */
    public void remove(Node node) {
        for (int i = 0; i < virtualNums; i++) {
            hashCircle.remove(hash(node.toString() + i));
        }
    }

    /**
     * 获取服务器节点
     *
     * @param key the key
     * @return the server
     */
    public Node getNode(String key) {
        if (key == null || hashCircle.isEmpty())
            return null;
        int hash = hash(key);
        if (!hashCircle.containsKey(hash)) {
            // 未命中对应的节点
            SortedMap tailMap = hashCircle.tailMap(hash);
            hash = tailMap.isEmpty() ? hashCircle.firstKey() : tailMap.firstKey();
        }
        return hashCircle.get(hash);
    }

    /**
     * FNV1_32_HASH算法
     *
     * @param key the key
     * @return
     */
    private int hash(String key) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < key.length(); i++) {
            hash = (hash ^ key.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;
    }

    /**
     * 集群节点的机器地址
     */
    public static class Node {
        private String ipAddr;
        private int port;
        private String name;

        public Node(String ipAddr, int port, String name) {
            this.ipAddr = ipAddr;
            this.port = port;
            this.name = name;
        }

        @Override
        public String toString() {
            return name + ":<" + ipAddr + ":" + port + ">";
        }
    }
}

评估服务器节点的负载均衡性

我们通过方差计算服务器节点的均衡性,代码如下:

public class ConsistentHashingTest {

    public static void main(String[] args) {
        ConsistentHashing.Node[] nodes = new ConsistentHashing.Node[4];
        Map> map = new HashMap<>();

        // make nodes 4台服务器节点
        for (int i = 0; i < nodes.length; i++) {
            nodes[i] = new ConsistentHashing.Node("10.1.32.2" + i, 8070, "myNode" + i);
        }

        ConsistentHashing ch = new ConsistentHashing(nodes, 160);

        // make keys 100万个key
        String[] keys = new String[1_000_000];
        for (int i = 0; i < keys.length; i++) {
            keys[i] = "key" + (i + 17) + "ss" + (i * 19);
        }

        // make results
        for (String key : keys) {
            ConsistentHashing.Node n = ch.getNode(key);
            List list = map.computeIfAbsent(n, k -> new ArrayList<>());
            list.add(key);
        }

        // 统计标准差,评估服务器节点的负载均衡性
        int[] loads = new int[nodes.length];
        int x = 0;
        for (Iterator i = map.keySet().iterator(); i.hasNext(); ) {
            ConsistentHashing.Node key = i.next();
            List list = map.get(key);
            loads[x++] = list.size();
        }
        int min = Integer.MAX_VALUE;
        int max = 0;
        for (int load : loads) {
            min = Math.min(min, load);
            max = Math.max(max, load);
        }
        System.out.println("最小值: " + min + "; 最大值: " + max);
        System.out.println("方差:" + variance(loads));
    }

    public static double variance(int[] data) {
        double variance = 0;
        double expect = (double) sum(data) / data.length;
        for (double datum : data) {
            variance += (Math.pow(datum - expect, 2));
        }
        variance /= data.length;
        return Math.sqrt(variance);
    }

    private static int sum(int[] data) {
        int sum = 0;
        for (int i = 0; i < data.length; i++) {
            sum += data[i];
        }
        return sum;
    }
}

测试结果:

# 虚拟节点是 120
最小值: 243919; 最大值: 253236
方差:3692.7378054771234

# 虚拟节点是 130
最小值: 240190; 最大值: 257384
方差:7432.346466628153

# 虚拟节点是 150
最小值: 233002; 最大值: 260369
方差:10227.744937179456

# 虚拟节点是 160
最小值: 241154; 最大值: 253743
方差:5150.429156876153

# 虚拟节点是 170
最小值: 235938; 最大值: 260044
方差:9244.906895150432

# 虚拟节点是 200
最小值: 233187; 最大值: 263222
方差:11395.83342717855

通过测试,每台物理服务的虚拟节点在120到200之间,均衡性更好。

总结

一致性hash算法解决了分布式环境下机器增加或者减少时,简单的取模运算无法获取较高命中率的问题。通过虚拟节点的使用,一致性hash算法可以均匀分担机器的负载,使得这一算法更具现实的意义。正因如此,一致性hash算法被广泛应用于分布式系统中。

参考资源

  • 维基百科
  • InfoQ - 一致性 hash 算法

你可能感兴趣的:(一致性哈希算法(Java实现))