摆个地摊儿:一致性哈希算法的三种实现

Posted 方家小白

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了摆个地摊儿:一致性哈希算法的三种实现相关的知识,希望对你有一定的参考价值。

哈希算法的实现

上一篇介绍了 哈希算法和一致性哈希算法的原理,我们知道哈希算法在分布式场景应用中存在着定位问题。所有有一致性哈希算法。今天我们就动手实现一下哈希算法。

可选性 回顾一下 哈希算法和一致性哈希算法

说明下。

以下多次出现服务端节点,客户端节点这两个名字,含义如下:

服务端节点:在实际场景中,比如分布式缓存,上一篇文章中的例子,服务端节点就是多个 Redis机器。

客户端节点:就是要缓存的数据,这里使用这两个名词来代表不同的两个部分。

实现功能

以上篇中提及的分布式缓存的例子为背景实现一致性哈希算法,主要实现两个功能:

  • 新增服务节点

    创建一个哈希环,然后可以存放服务端节点信息。后续新增节点也能正常存储。

  • 根据客户端节点找到对应的服务节点

    传入客户端的信息,我们可以根据client的信息或者其他的信息进行哈希运算,然后确定存储的服务端节点。

  • 实现根据服务端节点进行删除

    模拟实现部分服务端不可用。即例子中的缓存节点挂掉。

实现方式

首先我们需要定义一个接口规范,规定好要实现的内容,比如,保存服务端节点信息,即新增操作,根据客户端节点找到服务节点的功能,即查询操作。

  • 排序+List的实现方式

主要思路如下:将所有的节点保存到一个List中。然后对List进行排序,当获取服务端节点的时候,只需要找到第一个 哈希值比他大的服务端节点的就可以了。

不考虑排序的时间复杂度:最优时间复杂度:O(1),第一个节点就是目标节点。最坏情况下:O(n+1),找了一圈都没有找到。所以平均的时间复杂度是 O(N)。那排序的时间复杂度呢?最快的就是 O(NlogN);

综合下来,这种实现方案的时间复杂度就是:O(NlogN)。

主要实现代码如下(文末完整代码,注释颇多):

public class SortListConsistentHash implements ConsistentHash {

    /**
     * hash环容器
     */

    private List<NodeBucket> hashCircle = null;

    /**
     * 哈希算法
     * 默认使用 {@link ConsistentHash#hash }算法实现
     */

    private final HashHandle<Node> hashHandle;

    public SortListConsistentHash() {
        this.hashHandle = this::hash;
        virtualNumber = 1;
    }

    public SortListConsistentHash(HashHandle<Node> hashHandle) {
        this.hashHandle = hashHandle;
        virtualNumber = 1;
    }

    public SortListConsistentHash(HashHandle<Node> hashHandle, Integer virtualNumber) {
        this.hashHandle = hashHandle;
        this.virtualNumber = virtualNumber;
    }

    public SortListConsistentHash(Integer virtualNumber) {
        this.hashHandle = this::hash;
        this.virtualNumber = virtualNumber;
    }

    /**
     * 虚拟节点的数目,默认为1
     */

    private final Integer virtualNumber;

    /**
     * 新增服务节点
     *
     * @param value 服务节点
     */

    @Override
    public void add(ServerNode value) {
        if (hashCircle == null) {
            hashCircle = new ArrayList<>();
        }
        for (int i = 0; i < virtualNumber; i++) {
            value.setVirtualNodeId(i);
            hashCircle.add(DefaultNodeBucket.of(value, (node) -> hashHandle.hash(value)));
        }
    }

    /**
     * 排序
     */

    @Override
    public void sort() {
        hashCircle.sort(Comparator.comparingInt(NodeBucket::getHash));
    }

    /**
     * 找不到符合条件的第一个节点
     *
     * @param clientNode 根据 客户端节点的Hash值获取到目标服务端节点
     * @return
     */

    @Override
    public ServerNode getFirstNode(ClientNode clientNode) {

        Integer hash = hash(clientNode);

        Optional<NodeBucket> first = hashCircle.stream().filter(item -> item.getHash() > hash).findFirst();

        return first.map(NodeBucket::getNode).orElse(hashCircle.get(0).getNode());

    }

    /**
     * 获取所有服务节点
     *
     * @return 所有服务端节点
     */

    @Override
    public List<ServerNode> getAllServerNodes() {
        return this.hashCircle.stream().map(NodeBucket::getNode).collect(Collectors.toList());
    }

    /**
     * 哈希算法
     *
     * @param node 节点的信息,可能是客户端,也可能是服务端
     * @return 哈希值
     */

    @Override
    public Integer hash(Node node) {
        if (node instanceof ServerNode) {
            return defaultStringHash(node.getIdentifier() + "#" + ((ServerNode) node).getVirtualNodeId());
        } else {
            return defaultStringHash(node.getIdentifier());
        }
    }

    @Override
    public void delete(Node node) {
        if (node instanceof ServerNode) {
            this.hashCircle.remove(DefaultNodeBucket.of((ServerNode) node, hashHandle));
        }
    }

}
  • 线性表遍历的实现方式

首先说明一下,这里使用的是线性表遍历的方式,并没有指定说使用的是数组还是链表,根据具体场景来选择吧,实现方式略有不同。我用数组的形式来实现。

上一种实现方式,使用了排序导致了时间复杂度为 O(NlogN)。那么我不用排序行不行?

可以的!我们首先将服务端节点保存到数组中,然后根据客户端哈希值和服务端节点哈希值的差,找出最小的那个节点就可以了。每次遍历实现的话,遍历一次就可以了,时间复杂度为O(N).

主要实现逻辑代码如下(文末有完整实现代码,注释颇多):

public class TraverseArrayConsistentHash implements ConsistentHash {

    /**
     * 使用数组实现的哈希环
     */

    private NodeBucket[] hashCircle = new NodeBucket[]{};

    private final HashHandle<Node> hashHandle;

    private static final int defaultSize = 10;

    private static final int MaxSize = Integer.MAX_VALUE;

    private int length = 0;

    private int size = 0;


    /**
     * 虚拟节点的数目,默认为1
     */

    private final Integer virtualNumber;

    public TraverseArrayConsistentHash(Integer initialCapacity) {
        if (initialCapacity > 0) {
            this.size = initialCapacity;
            this.hashCircle = new NodeBucket[initialCapacity];
        } else {
            throw new IllegalArgumentException("initialCapacity must > 0 ");
        }

        this.hashHandle = this::hash;
        virtualNumber = 1;
    }

    public TraverseArrayConsistentHash(Integer initialCapacity, Integer virtualNumber) {
        if (initialCapacity > 0) {
            this.size = initialCapacity;
            this.hashCircle = new NodeBucket[initialCapacity];
        } else {
            throw new IllegalArgumentException("initialCapacity must > 0 ");
        }

        this.hashHandle = this::hash;
        this.virtualNumber = virtualNumber;
    }

    public TraverseArrayConsistentHash(Integer initialCapacity, Integer virtualNumber, HashHandle<Node> hashHandle) {
        if (initialCapacity > 0) {
            this.size = initialCapacity;
            this.hashCircle = new NodeBucket[initialCapacity];
        } else {
            throw new IllegalArgumentException("initialCapacity must > 0 ");
        }

        this.virtualNumber = virtualNumber;
        this.hashHandle = hashHandle;
    }

    public TraverseArrayConsistentHash() {
        this.hashHandle = this::hash;
        this.virtualNumber = 1;
    }

    @Override
    public void add(ServerNode serverNode) {
        // 检查大小是否需要扩容
        checkSize();
        for (int i = 0; i < virtualNumber; i++) {
            serverNode.setVirtualNodeId(i);
            this.hashCircle[length++] = DefaultNodeBucket.of(serverNode, hashHandle);
        }
    }


    /**
     * 检查数组大小
     */

    private void checkSize() {
        if (size == 0) {
            this.size = defaultSize;
            this.hashCircle = new NodeBucket[size];
        }
        if (size >= MaxSize) {
            throw new IllegalArgumentException();
        }
        if (length + 1 > size) {
            this.size = size << 1;
            this.hashCircle = Arrays.copyOf(this.hashCircle, size, NodeBucket[].class);
        }
    }

    @Override
    public Integer hash(Node node) {
        if (node instanceof ServerNode) {
            return defaultStringHash(node.getIdentifier() + "#" + ((ServerNode) node).getVirtualNodeId());
        } else {
            return defaultStringHash(node.getIdentifier());
        }
    }

    @Override
    public void delete(Node node) {
        for (int index = 0; index < size; index++) {
            if (node.equals(hashCircle[index].getNode())) {
                fastRemove(index);
                break;
            }
        }
    }

    private void fastRemove(int index) {
        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(hashCircle, index+1, hashCircle, index,
                    numMoved);
        hashCircle[--size] = null;
    }


    @Override
    public ServerNode getFirstNode(ClientNode clientNode) {
        Integer hash = this.hashHandle.hash(clientNode);
        ServerNode findNode = null;
        int min = Integer.MAX_VALUE;
        // 查找符合条件的服务端节点
        for (int i = 0; i < length; i++) {
            NodeBucket nodeBucket = this.hashCircle[i];
            int difference;
            if ((difference = Math.abs(nodeBucket.getHash() - hash)) < min) {
                min = difference;
                findNode = nodeBucket.getNode();
            }
        }
        return findNode;
    }

    @Override
    public List<ServerNode> getAllServerNodes() {
        return Stream.of(this.hashCircle).map(NodeBucket::getNode).collect(Collectors.toList());
    }

}

SortedMap实现方案

上面两种的实现方案,并非是最优的。根本原因就是数据结构的限制。线性表决定了这一切。我们考虑换一种数据结构呢?

考虑下使用树形结构

最快的树形数据结构,就是二叉平衡树了. 二叉平衡树有两种 AVL树和红黑树。

我们使用红黑树,因为红黑树的主要功能就是存储有序的数据,并且查询的效率是O(logN)。

考虑到手写实现一个红黑树,着实有点复杂,这种我们使用JDK中的TreeMap来实现。

将所有的服务节点放到TreeMap中,这种结构天然支持排序的,所以我们只需要主要的实现找到服务节点的这个过程就好了。

首先计算出客户端的哈希值,查询出大于该哈希值的服务节点的子序列,如果子序列为空返回原来哈希环的第一个元素,否则,返回子序列的第一个元素即可。

主要实现代码如下(文末有完整代码,注释真的多!)

public class SortedMapConsistentHash implements ConsistentHash {

    private final SortedMap<Integer, ServerNode> hashCircle = new TreeMap<>();

    private final HashHandle<Node> hashHandle;

    /**
     * 虚拟节点的数目,默认为1
     */

    private final Integer virtualNumber;

    public SortedMapConsistentHash() {
        virtualNumber = 1;
        hashHandle = this::hash;
    }

    public SortedMapConsistentHash(int virtualNumber, HashHandle<Node> hashHandle) {
        this.virtualNumber = 1;
        this.hashHandle = hashHandle;
    }

    public SortedMapConsistentHash(int virtualNumber) {
        this.virtualNumber = virtualNumber;
        this.hashHandle = this::hash;
    }

    @Override
    public void add(ServerNode serverNode) {
        // 实现虚拟节点
        for (int i = 0; i < virtualNumber; i++) {
            serverNode.setVirtualNodeId(i);
            hashCircle.put(this.hashHandle.hash(serverNode), serverNode);
        }
    }

    @Override
    public Integer hash(Node node) {
        if (node instanceof ServerNode) {
            return defaultStringHash(node.getIdentifier() + "#" + ((ServerNode) node).getVirtualNodeId());
        } else {
            return defaultStringHash(node.getIdentifier());
        }
    }

    @Override
    public void delete(Node node) {
        this.hashCircle.remove(hashHandle.hash(node));
    }

    @Override
    public ServerNode getFirstNode(ClientNode clientNode) {
        int hash = hash(clientNode);
        // 获取大于客户端哈希值的子序列
        SortedMap<Integer, ServerNode> subMap = hashCircle.tailMap(hash);

        if (subMap.isEmpty()) {
            // 子序列为空,获取哈希环的第一个节点
            Integer key = hashCircle.firstKey();
            return hashCircle.get(key);
        } else {
            // 子序列不为空,获取子序列的第一个节点
            Integer key = subMap.firstKey();
            return subMap.get(key);
        }
    }

    @Override
    public List<ServerNode> getAllServerNodes() {
        return new ArrayList<>(this.hashCircle.values());
    }

    @Override
    public ServerNode process(ClientNode clientNode) {
        return getFirstNode(clientNode);
    }
}

最后,我们测试一下,一致性哈希算法的结果:

public class ConsistentHashDemo {

    public static void main(String[] args) {
        testSortListConsistentHash();
        testSortedMapConsistentHash();
        testTraverseArrayConsistentHash();
    }

    /**
     * 测试,使用遍历的方式来实现一致性hash算法
     */

    private static void testTraverseArrayConsistentHash() {
        ConsistentHash sortedMapConsistentHash = new TraverseArrayConsistentHash();
        testConsistentHashCommonPart(sortedMapConsistentHash);
    }

    /**
     * 测试,使用SortMap实现的一致性哈希算法
     */

    private static void testSortedMapConsistentHash() {
        ConsistentHash sortedMapConsistentHash = new SortedMapConsistentHash(3);
        testConsistentHashCommonPart(sortedMapConsistentHash);

    }

    /**
     * 测试,使用数组和排序方式实现的 一致性哈希算法
     */

    private static void testSortListConsistentHash() {
        SortListConsistentHash sortListConsistentHashMap = new SortListConsistentHash();
        testConsistentHashCommonPart(sortListConsistentHashMap);
    }


    /**
     * 测试 consistentHash 算法,公共代码部分
     *
     * @param consistentHash 一致性哈希实例
     */

    private static void testConsistentHashCommonPart(ConsistentHash consistentHash) {
        Stream.of(
                new ServerNode("192.168.0.1", UUID.randomUUID().toString(), "domain1.com"),
                new ServerNode("192.168.0.2", UUID.randomUUID().toString(), "domain2.com"),
                new ServerNode("192.168.0.3", UUID.randomUUID().toString(), "domain3.com"),
                new ServerNode("192.168.0.4", UUID.randomUUID().toString(), "domain4.com"),
                new ServerNode("192.168.0.5", UUID.randomUUID().toString(), "domain5.com"),
                new ServerNode("192.168.0.6", UUID.randomUUID().toString(), "domain6.com")
        ).forEach(consistentHash::add);
        String s1 = UUID.randomUUID().toString();
        ServerNode serverNode1 = consistentHash.process(new ClientNode(s1));
        out.println(serverNode1);
        ServerNode serverNode1_1 = consistentHash.process(new ClientNode(s1));
        out.println(serverNode1_1);
        ServerNode serverNode1_2 = consistentHash.process(new ClientNode(UUID.randomUUID().toString()));
        out.println(serverNode1_2);
    }
}

在测试完成之后,我还对各种方案就进行了性能相关的简单测试,硬件: windows 8核16G,i7. 测试代码就不贴出来了,查看原文去看吧。

总结如下:

在节点数 小于10000的场景下:数组遍历 > SortedMap > 排序。即: 方案2 > 方案3 > 方案1。在服务端节点数 大于 10000的场景下:SortedMap > 数组变量 > 排序. 即:方案3 > 方案2 > 方案1.

最后

代码仓库

欢迎star!!欢迎你提交代码~,我们一起维护哇。郑重撒花。

最后的最后

我在【方家小白】等你~

二维码


以上是关于摆个地摊儿:一致性哈希算法的三种实现的主要内容,如果未能解决你的问题,请参考以下文章

一致性哈希算法PHP测试片段

深入一致性哈希(Consistent Hashing)算法原理,并附100行代码实现

一致性哈希算法学习及JAVA代码实现分析

负载均衡-基础-一致性哈希算法及java实现

集群应用Session一致性实现的三种方案

微服务架构实现最终一致性的三种模式