[白话解析] 深入浅出一致性Hash原理

802 阅读10分钟

0x00 摘要

一致性哈希算法是分布式系统中常用的算法。但相信很多朋友都是知其然而不知其所以然。本文将尽量使用易懂的方式介绍一致性哈希原理,并且通过具体应用场景来帮助大家深入这个概念。

0x01. 概念&原理

Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。

一致性哈希算法在1997年由麻省理工学院的Karger等人在解决分布式Cache中提出的,主要是为了解决因特网中的热点(Hot spot)问题。目前这一思想已经扩展到其它的领域,并且在实践中得到了很大的发展。

1. 与经典哈希方法的对比

  • 经典哈希方法:总是假设内存位置的数量是已知且固定不变的。因为hash映射依赖节点/内存位置,所以如果需要变化集群,需要重新计算每一个key的哈希值。哈希表(服务器数量)大小的变更实际上干扰了 所有映射
  • 一致性哈希:某种虚拟环结构。位置数量不再固定,环有无限数量的点,服务器节点可以放置在环上的随机位置。哈希表(服务器数量)大小改变会导致 只有 一部分请求(相对于环分配因子)会受到特定的环变更的影响

2. 通俗理解一致性哈希的关键点:

从拗口的技术术语来解释,一致性哈希的技术关键点是:按照常用的hash算法来将对应的key哈希到一个具有2^32次方个桶的空间中,即0 ~(2^32)-1的数字空间。我们可以将这些数字头尾相连,想象成一个闭合的环形。

用通俗白话来理解,这个关键点就是:在部署服务器的时候,服务器的序号空间已经配置成了一个固定的非常大的数字 12^32。服务器可以分配为 12^32 中任一序号。这样服务器集群可以固定大多数算法规则 (因为序号空间是算法的重要参数),这样面对扩容等变化只是对部分算法规则做调整。具体会参见后面实例详细说明。

3. 一致性哈希如何处理请求

如何决定哪个请求将由哪个服务器节点来处理?

从理论上来说,每个服务器节点“拥有”哈希环的一个区间,进入该区间的任何请求将由同一服务器节点来处理。

我们假设环是有序的,以便环的顺时针遍历与位置地址的递增顺序对应,那么每个请求可以由最先出现在该顺时针遍历中的那个服务器节点来处理。也就是说,地址高于请求地址的第一个服务器节点负责处理该请求。如果请求地址高于最高寻址节点,它由最小地址的服务器节点来处理,因为环遍历以圆形方式进行。

4. 异常处理/变化应对

如果其中一个服务器节点出现故障,下一个服务器节点的区间就变宽,进入该区间的任何请求都将进入到新的服务器节点。这时候应该如何处理这些异常的请求?

一致性Hash的优势就在这里体现:需要重新分配的是仅仅这一个区间(与出现故障的服务器节点对应),哈希环的其余部分和请求/节点分配仍然不受影响。

0x02. 具体应用场景(通过名著水浒传为例来阐释)

大家都知道,梁山泊山下有四个酒店。分别是: 东山酒店 / 西山酒店 / 南山酒店 / 北山酒店

那么这四个酒店如何分配客人入住呢? 这里就能用到Hash算法,也能看到一致性哈希的好处。

1. 经典算法:

梁山4个酒店,按照顺序其序号是1,2,3,4。

哈希函数:客人姓名笔画 / 4得到一个余数,客人按照余数分配到这4个酒店中

如果减少一个酒店,哈希函数就变成: 客人按照姓名笔画 / 3,然后客人按照这个新余数来分配酒店。所有客人都得重新分配酒店

如果增加一个酒店,哈希函数就变成: 客人按照姓名笔画 / 5,然后客人按照这个新余数来分配酒店。所有客人都得重新分配酒店

可以看到,如果有容量变化,则哈希函数和分配规则都要改变,这样就对整体机制造成了伤害。

2. 一致性算法:

预先就把服务器的序号空间(现在~未来)想好了,定为100个桶。就是在未来可见的年份内,100个肯定够了(l梁山无论怎么扩大生产规模,哪怕扩招了10000个头领,山下也没有开设100个酒店的可能)。

哈希函数(这个固定不变):

客人姓名笔画/100. 这个是固定不变的! 因为100这个序号空间固定了,所以哈希函数和分配规则都基本固定了。

酒店/客人分配规则如下(这个会根据容量变化做相应微调):

  • 酒店1负责 hash(x)--> 1 ~ 20,即 "客人姓名笔画/100"的结果 位于1-20之间。
  • 酒店2负责 hash(x)--> 21 ~ 40,即 "客人姓名笔画/100"的结果 位于21-40之间。
  • 酒店3负责 hash(x)--> 41 ~ 60,即 客人姓名笔画/100"的结果 位于41-60之间。
  • 酒店4负责 hash(x)--> 61 ~ 100,即 "客人姓名笔画/100位"的结果 于61-100之间。

客人住店规则如下(这个固定不变):

  • 客人来了,姓氏笔画/100,得到余数。去余数对应的酒店住。比如余数3住到酒店1,余数22则住到酒店2......
  • 如果该酒店出问题关门了,就去比所有 "比余数大的酒店" 中最小那个住。以此类推。比如酒店1挂了,就去酒店2,酒店2挂了去3。
  • 如果最大酒店也出问题关门了,就转圈回到最小酒店住。即如果酒店4挂了去酒店1.

异常处理(扩容或者宕机):

  • 减少酒店。如果酒店3挂了,则原来去酒店3的客人去酒店4,原来去酒店4的客人还是酒店4. 这样只有酒店4受到影响,1,2号酒店客人不用搬家。
  • 增加酒店。如果增加了一个酒店5.则需要对 酒店/客人分配规则 做改变。让4号酒店负责6180,5号酒店负责81100。这样4号点原有部分客人要迁移到5号。

关键点:

可以看出来,关键在于服务器的序号空间早就确定了是一个以后也不会修改的大数字100。当然这是梁山。对于其他真实案例可能是2^32。这样hash函数 (因为序号空间是算法一个重要参数) 可以保持不变,只有"分配规则" 需要根据实际系统容量做相应微调。从而对整体系统影响较小。

当然具体分配酒店的规则算法,是可以融入到hash中。即酒店号码可能就是21,41,61....

0x03. 源码剖析

我们用Dubbo的一致性 Hash 负载均衡策略来做剖析。

一致性 Hash 实现如下:doSelect 中主要实现缓存检查和 Invokers 变动检查,当调用该方法时,如果选择器不存在则去创建。随后通过ConsistentHashSelector的select方法选择结点。一致性 hash 负载均衡的实现在这个内部类 ConsistentHashSelector 中实现。

public class ConsistentHashLoadBalance extends AbstractLoadBalance {
    public static final String NAME = "consistenthash";

    /**
     * Hash nodes name
     */
    public static final String HASH_NODES = "hash.nodes";

    /**
     * Hash arguments name
     */
    public static final String HASH_ARGUMENTS = "hash.arguments";

    private final ConcurrentMap<String, ConsistentHashSelector<?>> selectors = new ConcurrentHashMap<String, ConsistentHashSelector<?>>();

    @SuppressWarnings("unchecked")
    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        String methodName = RpcUtils.getMethodName(invocation);
        String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;
        // 通过计算一组提供者invokers的内存地址值,即生成调用列表hashCode
        // using the hashcode of list to compute the hash only pay attention to the elements in the list
        int invokersHashCode = invokers.hashCode();
        // selectors 方法, key -- 服务选择管理器 ,服务选择管理器对应一组节点服务。即以调用方法名为key,获取一致性hash选择器
        ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);
        if (selector == null || selector.identityHashCode != invokersHashCode) {
            //未构建或者发现一组提供者内存值发生变化,重新构建服务选择管理器,即生成所有虚拟结点
            selectors.put(key, new ConsistentHashSelector<T>(invokers, methodName, invokersHashCode));
            // 获取选择器
            selector = (ConsistentHashSelector<T>) selectors.get(key);
        }
        //服务选择管理器选择一个节点
        return selector.select(invocation);
    }
    // 后面是 ConsistentHashSelector 类
}

ConsistentHashSelector在构造函数内部会创建replicaNumber个虚拟结点,并将这些虚拟结点存储于TreeMap。随后根据调用方法的参数来生成key,并在TreeMap中选择一个结点进行调用。

   private static final class ConsistentHashSelector<T> {

        private final TreeMap<Long, Invoker<T>> virtualInvokers;// 虚拟结点
        private final int replicaNumber;// 副本数
        private final int identityHashCode;// hashCode
        private final int[] argumentIndex;// 参数索引数组

        ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {
            // 创建TreeMap 来保存结点
            this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
            // 生成调用结点HashCode
            // 计算哈希值 key,通过这个字段来识别出调用实例是否有变化,有变化则重新创建
            this.identityHashCode = identityHashCode;
            // 获取Url
            URL url = invokers.get(0).getUrl();
            // 获取所配置的结点数,如没有设置则使用默认值160
            this.replicaNumber = url.getMethodParameter(methodName, HASH_NODES, 160);
            // 获取需要进行hash的参数数组索引,默认对第一个参数进行hash
            // 根据哪些参数来生产hash值 // 需要哈希的参数,默认是第一个参数
            String[] index = COMMA_SPLIT_PATTERN.split(url.getMethodParameter(methodName, HASH_ARGUMENTS, "0"));
            argumentIndex = new int[index.length];
            for (int i = 0; i < index.length; i++) {
                argumentIndex[i] = Integer.parseInt(index[i]);
            }
            //节点向 值空间 映射,构虚拟节点值管理器
            // 对每个invoker生成replicaNumber个虚拟结点,并存放于TreeMap中
            for (Invoker<T> invoker : invokers) {
                // 每个invoker 拥有160个虚拟节点,并将虚拟节点这细化为40份,每份4个点 散列到treeMap中
                String address = invoker.getUrl().getAddress();
                // 为每一个Invoker创建replicaNumber 个虚拟节点,每一个节点的Hashcode不同。
                // 同一个Invoker不同hashcode的创建逻辑为:
                // invoker.getUrl().getAddress() + i (0-39)的值,对其md5,然后用该值+h(0-3)的值取hash。
                // 一致性hash实现的一个关键是如果将一个Invoker创建的replicaNumber 个虚拟节点(hashcode)能够均匀分布在Hash环上
                for (int i = 0; i < replicaNumber / 4; i++) {
                    // 根据md5算法为每4个结点生成一个消息摘要,摘要长为16字节128位
                    byte[] digest = md5(address + i);
                    for (int h = 0; h < 4; h++) {
                        // 再做哈希计算,得到 m,作为 Key
                        // 根据md5算法为每4个结点生成一个消息摘要,摘要长为16字节128位。
                        // 随后将128位分为4部分,0-31,32-63,64-95,95-128,并生成4个32位数,存于long中,long的高32位都为0
                        // 并作为虚拟结点的key。 每个节点一部分 ;
                        long m = hash(digest, h);//最大2的32次方
                        virtualInvokers.put(m, invoker);// 存到虚拟节点
                    }
                }
            }
        }

        public Invoker<T> select(Invocation invocation) {
            // 根据调用参数来生成Key
            String key = toKey(invocation.getArguments());
            // 根据这个参数生成消息摘要
            byte[] digest = md5(key);
            //调用hash(digest, 0),将消息摘要转换为hashCode,这里仅取0-31位来生成HashCode
            //调用sekectForKey方法选择结点
            return selectForKey(hash(digest, 0));
        }

        private String toKey(Object[] args) {
            //参数拼接构建key
            StringBuilder buf = new StringBuilder();
            //参数因子遍历
            // 由于hash.arguments没有进行配置,因为只取方法的第1个参数作为key
            for (int i : argumentIndex) {
                if (i >= 0 && i < args.length) {
                    buf.append(args[i]);
                }
            }
            return buf.toString();
        }

        private Invoker<T> selectForKey(long hash) {
            // 方法用来返回与该键至少大于或等于给定键,如果不存在这样的键的键 - 值映射,则返回null相关联。
            Map.Entry<Long, Invoker<T>> entry = virtualInvokers.ceilingEntry(hash);
            if (entry == null) {
                // 方法用于返回在这个映射上最近的键,或者null,如果映射为空关联的键 - 值映射关系
                entry = virtualInvokers.firstEntry();
            }
            return entry.getValue();
        }

        private long hash(byte[] digest, int number) {
            return (((long) (digest[3 + number * 4] & 0xFF) << 24)
                    | ((long) (digest[2 + number * 4] & 0xFF) << 16)
                    | ((long) (digest[1 + number * 4] & 0xFF) << 8)
                    | (digest[number * 4] & 0xFF))
                    & 0xFFFFFFFFL;
        }

        private byte[] md5(String value) {
            MessageDigest md5;
            try {
                md5 = MessageDigest.getInstance("MD5");
            } catch (NoSuchAlgorithmException e) {
                throw new IllegalStateException(e.getMessage(), e);
            }
            md5.reset();
            byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
            md5.update(bytes);
            return md5.digest();
        }
    }

0x04. 参考

blog.csdn.net/gerryke/art…

blog.csdn.net/cb_lcl/arti…

www.iteblog.com/archives/24…

www.zsythink.net/archives/11…

www.sohu.com/a/239283928…

DUBBO ConsistentHashLoadBalance 一致性HASH 算法

Dubbo源码学习–ConsistentHashLoadBalance负载均衡(五)

★★★★★★关于生活和技术的思考★★★★★★

微信公众账号:罗西的思考

如果您想及时得到个人撰写文章的消息推送,或者想看看个人推荐的技术资料,敬请关注。