深度解析dubbo负载均衡之ConsistentHashLoadBalance

本文基于dubbo v2.6.x

1. 一致性hash

在分布式系统中解决负载均衡问题的时候可以使用hash算法来将固定的一部分请求落在同一台机器上,这样每台服务器会固定的处理同一部分请求。来起到负载均衡的作用。
但是普通的余数的hash(hash(key)%机器数)算法伸缩性很差,每当新增或者下线机器的时候,某个key与机器的映射会大量的失效,一致性hash则利用hash环对其进行了改进。

我们举个例子,比如我现在有4台服务器,他们对应的ip地址分别是ip1,ip2,ip3,ip4,通过计算这4个ip的hash值(这里假设hash(ip4)> hash(ip3)>hash(ip2)>hash(ip1)),然后按照hash值的大小顺时针分布到hash环上:
在这里插入图片描述
我们可以看到,从0开始然后按照4个ip的hash值大小顺时针方向(这个趋向正无穷大)散落在环上(这里假设hash(ip4)> hash(ip3)>hash(ip2)>hash(ip1)),当用户调用请求打过来的时候,计算用户某个参数的hash(key)值,比如说我们 用户u1的key的hash值正好在hash(ip3)值 与hash(2) 值之间,如下图:
在这里插入图片描述
这时候u1的请求就要交给hash(ip3)也就是ip3的这台机器处理。当我们ip3的机器挂了的时候,我们的hash环是这样分布的:
在这里插入图片描述

这时候u1这个请求就会被重新分配到ip4的机器上,之前不分配到ip3机器上的用户请求不受影响。我们回到ip3没有挂之前,我们新添加了ip5的机器,hash(ip5)值正好在hash(ip2)与hash(ip3)之间。在环上的图如下:
在这里插入图片描述
这时候ip5的机器会分担一部分ip3机器的请求, 如果hash(u1.key)值 在ip5 与ip2 之前,这时候u1的请求就会被重新分配到ip5机器上处理。
当我们机器比较的少时候会造成数据倾斜的问题,就是可能会出现大量的请求被分配到一台机器上情况。这时候可以使用虚拟节点来解决数据倾斜问题,我们给每台机器添加多个虚拟节点,我们来看下dubbo对于一致性hash的落地。(关于更多一致性hash原理问题可以参考《深入浅出一致性Hash原理》这篇文章)

2. ConsistentHashLoadBalance源码解析

我们先来看下ConsistentHashLoadBalance 的class定义

public class ConsistentHashLoadBalance extends AbstractLoadBalance {

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

可以看到也是继承AbstractLoadBalance 抽象类,重写doSelect方法,我们看到他还有一个selectors成员,该成员主要是缓存一致性hashSelector的,key是接口.方法 , value就是ConsistentHashSelector 对象。
我们来看下这个doSelect方法实现:

 @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {

        // 获取方法名
        String methodName = RpcUtils.getMethodName(invocation);
        // 拼接key, 接口全类名.方法名
        String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;

        //根据invokers 计算个hashcode
        int identityHashCode = System.identityHashCode(invokers);

        // 根据key 从缓存中获取ConsistentHashSelector
        ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);


        // selector==null 或者是 selector的hashcode != 现在算出来的,说明这个invokers 变了
        if (selector == null || selector.identityHashCode != identityHashCode) {

            // 创建ConsistentHashSelector 放入缓存中
            selectors.put(key, new ConsistentHashSelector<T>(invokers, methodName, identityHashCode));
            // 获取新的selector
            selector = (ConsistentHashSelector<T>) selectors.get(key);
        }

        // 是用selector 进行选择
        return selector.select(invocation);
    }

首先是拼装key= 接口全类名.方法名,计算invokers的hash值,通过key 从selectors缓存中获取对应的值ConsistentHashSelector
如果这个selector是null 或者这个hash 与selector存的这个不一致的话(这个就说明invokers 集合中的invoker有变动,有下线或者上线情况,导致算出来的hash值与之前存的hash值不一致)就新new ConsistentHashSelector 然后塞到 selectors 缓存中。
然后调用当前selector 的select(invocation)方法。
我们先来看下ConsistentHashSelector 的构造方法

  ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {

            // 虚拟的invoker
            this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
            // hashcode
            this.identityHashCode = identityHashCode;
            // 获取url
            URL url = invokers.get(0).getUrl();


            //获取hash.nodes ,缺省是160  每个实例节点的个数
            this.replicaNumber = url.getMethodParameter(methodName, "hash.nodes", 160);


            // 获取hash.arguments 缺省是0 然后进行切割
            String[] index = Constants.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]);
            }
            for (Invoker<T> invoker : invokers) {
                // 获取地址
                String address = invoker.getUrl().getAddress();

                for (int i = 0; i < replicaNumber / 4; i++) {
                    byte[] digest = md5(address + i);
                    for (int h = 0; h < 4; h++) {
                        long m = hash(digest, h);//计算位置
                        virtualInvokers.put(m, invoker);
                    }
                }
            }
        }

我们看到先创建virtualInvokers这个存储虚拟节点的treemap,获取属性hash.nodes的值,缺省是160,这个是每个invoker的节点个数,默认的话是160个,获取hash.arguments 属性值,缺省是0,这个是要使用哪个位置的参数,可以是多个用,逗号隔开,默认是使用第一个参数。
接着就是为每个invoker 根据 其ip+port 生成replicaNumber 个的节点(生成虚拟节点),然后塞到virtualInvokers 这个treemap中,key就是算出来的hash值,value就是invoker,我们都知道,TreeMap是按照key的值从小到大排序的。
我们再来看下ConsistentHashSelector 的select(inv)方法:

		public Invoker<T> select(Invocation invocation) {
            // 将参数转成key
            String key = toKey(invocation.getArguments());
            byte[] digest = md5(key);
            return selectForKey(hash(digest, 0));
        }

        private String toKey(Object[] args) {
            StringBuilder buf = new StringBuilder();
            for (int i : argumentIndex) {
                if (i >= 0 && i < args.length) {
                    buf.append(args[i]);// 参数
                }
            }
            return buf.toString();
        }

        private Invoker<T> selectForKey(long hash) {//tailMap 是返回键值大于或等于key的那部分 ,然后再取第一个
            Map.Entry<Long, Invoker<T>> entry = virtualInvokers.tailMap(hash, true).firstEntry();
            if (entry == null) {//如果没有取到的话就说明hash就是最大的了,下面那个就是 treemap 第一个了
                entry = virtualInvokers.firstEntry();
            }// 返回对应的那个invoker
            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;
            try {
                bytes = value.getBytes("UTF-8");
            } catch (UnsupportedEncodingException e) {
                throw new IllegalStateException(e.getMessage(), e);
            }
            md5.update(bytes);
            return md5.digest();
        }

在select 方法中首先是利用调用参数生成key,我们可以看下toKey方法中,就是根据我们hash.arguments 参数取出对应位的参数,拼接成key。在使用md5对key计算,使用hash算法算出key对应的hash值。然后调用selectForKey 方法根据这个key的hash值找出对应的invoker。
我们看下这个selectForKey方法。首先是virtualInvokers.tailMap(hash, true).firstEntry() 找出对应的节点,这个tailMap 方法其实就是是返回键值大于或等于key的那部分,在使用firstEntry方法获取这部分的第一个。如果获取的entry是null的话,说明这个hash值就是最大的了,要想找对应的invoker ,就要找TreeMap的第一个元素。然后返回这个invoker。
好了,到这dubbo的一致性hash算法结束了,dubbo是根据调用参数的hash值来寻找对应invoker节点的,至于dubbo的hash算法怎么实现的,感兴趣的同学可以自己研究一下。

©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页