DUBBO负载均衡算法及源码分析

DUBBO负载均衡算法及源码分析

前言

​ 下面是dubbo官方集群容错的布局图,集群容错有以下组件,Cluster、Cluster Invoker、Directory、Router 和 LoadBalance 等。这边分析下LoadBalance (负载均衡模块) ,LoadBalance 仍然是在消费者端实现的功能。

​ 后文源码 dubbo版本2.6.1

img

类结构

​ 由下图可知,Dubbo共提供了四种负载均衡策略,分别是基于权重随机算法的 RandomLoadBalance(默认实现)、基于最少活跃调用数算法的 LeastActiveLoadBalance、基于 hash 一致性的 ConsistentHashLoadBalance,以及基于加权轮询算法的 RoundRobinLoadBalance。
在这里插入图片描述
其中所有的实现类继承与AbstractLoadBalance,该类提供模板方法

 
 /**
     * select one invoker in list.
     *
     * 从 Invoker 集合中,选择一个
     *
     * @param invokers   invokers.  Directory模块查到的列表
     * @param url        refer url
     * @param invocation invocation.
     * @return selected invoker.
     */
@Override
    public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        if (invokers == null || invokers.isEmpty()) {
            return null;
        }
        //如果长度为1 直接返回
        if (invokers.size() == 1) {
            return invokers.get(0);
        }
        //留给具体子类实现
        return doSelect(invokers, url, invocation);
    }

随机权重-RandomLoadBalance

算法思想

​ 下面讲解的时权重不相等的情况,如果权重都相等,采用随机算法从0到n-1中随机获取一个整数,取对应下标的invoker。
在这里插入图片描述

第一步:对所有的invoker列表权重求和,和为totalWeight。

第二步:在0-totalWeight中取一个随机整数offset。

第三步:offset依次减去每一个的invoker的权重,offset小于0时终止条件。

offset每次减去一个权重,其实主要为了看offset落在上图的对应哪一个区间,每个区间的大小是该机器的权重,所以达到了随机权重的作用。

源码分析

主要代码-doSelect
 @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        int length = invokers.size(); // Number of invokers
        int totalWeight = 0; // The sum of weights
        boolean sameWeight = true; // Every invoker has the same weight?
        // 计算总权限
        for (int i = 0; i < length; i++) {
            int weight = getWeight(invokers.get(i), invocation); // 获得权重
            totalWeight += weight; // Sum
            if (sameWeight && i > 0 && weight != getWeight(invokers.get(i - 1), invocation)) {
                sameWeight = false;
            }
        }
        // 权重不相等,随机后,判断在哪个 Invoker 的权重区间中
        if (totalWeight > 0 && !sameWeight) {
            // 随机
            // If (not every invoker has the same weight & at least one invoker's weight>0), select randomly based on totalWeight.
            int offset = random.nextInt(totalWeight);
            // Return a invoker based on the random value.
            // 区间判断
            for (Invoker<T> invoker : invokers) {
                offset -= getWeight(invoker, invocation);
                if (offset < 0) {
                    return invoker;
                }
            }
        }
        // 权重相等,平均随机
        // If all invokers have the same weight value or totalWeight=0, return evenly.
        return invokers.get(random.nextInt(length));
    }
getweight分析

​ 如果远程URL配置了remote.timestamp参数 ,权重采用动态获取。为了防止在做服务扩容的时候,扩容的机器,如果是固定的权重,可能一下子接收了大量的请求,导致服务波动严重。这边通过预热时长,和启动时长动态的调整了扩容服务的权重,规避的大量请求打入刚加入集群的新机器。

  protected int getWeight(Invoker<?> invoker, Invocation invocation) {
        // 获得 weight 配置,即服务权重。默认为 100
        int weight = invoker.getUrl().getMethodParameter(invocation.getMethodName(), Constants.WEIGHT_KEY, Constants.DEFAULT_WEIGHT);
        if (weight > 0) {
            long timestamp = invoker.getUrl().getParameter(Constants.REMOTE_TIMESTAMP_KEY, 0L);
            if (timestamp > 0L) {
                // 获得启动总时长
                int uptime = (int) (System.currentTimeMillis() - timestamp);
                // 获得预热需要总时长。默认为 10 * 60 * 1000 = 10 分钟
                int warmup = invoker.getUrl().getParameter(Constants.WARMUP_KEY, Constants.DEFAULT_WARMUP);
                // 处于预热中,计算当前的权重
                if (uptime > 0 && uptime < warmup) {
                    weight = calculateWarmupWeight(uptime, warmup, weight);
                }
            }
        }
        return weight;
    }

   static int calculateWarmupWeight(int uptime, int warmup, int weight) {
        // 计算权重
        int ww = (int) ((float) uptime / ((float) warmup / (float) weight));
        // 权重范围为 [0, weight] 之间
        return ww < 1 ? 1 : (ww > weight ? weight : ww);
    }

加权轮询-RoundRobinLoadBalance

算法思想

​ 在讲加权轮询时,我们先看一下轮询如何实现,现有服务提供者A,B,C。我们将第一个请求分配给服务器 A,第二个请求分配给服务器 B,第三个请求分配给服务器 C,第四个请求再次分配给服务器 A。这个过程就叫做轮询。

加权轮询:由于每一台机器的性能是有差异的,或者我们生产有多机房,机房之间请求也不是平均的,所以对不同机器配置不同权重是很常见的。现在A,B,C的权重为5,2,1 我们要达到在8次请求中,服务器 A 将收到其中的5次请求,服务器 B 会收到其中的2次请求,服务器 C 则收到其中的1次请求。

步骤分析

第一步:采用一个全局map保存每个服务和服务的请求次数

第二步:计算总的列表权重weightSum,并提取每个invoker和他的权重放进局部invokerToWeightMap中

第三步:用当前轮询序号与服务提供者总权重取模,余数为mod-从0取到weightSum

第四步:外循环,从0-maxweight,由于maxweight*length >= weightSum 保证了对应mod可以递减到0

第五步:内循环,对invokerToWeightMap进行遍历,当取到mod为0,且该invoker的权重还大于0的时候,返回,否则mod–,invoker.weight–;

下面是官方文档的一个案例

假设我们有三台服务器 servers = [A, B, C],对应的权重为 weights = [2, 5, 1]。接下来对上面的逻辑进行简单的模拟。

mod = 0:满足条件,此时直接返回服务器 A

mod = 1:需要进行一次递减操作才能满足条件,此时返回服务器 B

mod = 2:需要进行两次递减操作才能满足条件,此时返回服务器 C

mod = 3:需要进行三次递减操作才能满足条件,经过递减后,服务器权重为 [1, 4, 0],此时返回服务器 A

mod = 4:需要进行四次递减操作才能满足条件,经过递减后,服务器权重为 [0, 4, 0],此时返回服务器 B

mod = 5:需要进行五次递减操作才能满足条件,经过递减后,服务器权重为 [0, 3, 0],此时返回服务器 B

mod = 6:需要进行六次递减操作才能满足条件,经过递减后,服务器权重为 [0, 2, 0],此时返回服务器 B

mod = 7:需要进行七次递减操作才能满足条件,经过递减后,服务器权重为 [0, 1, 0],此时返回服务器 B

源码分析

 /**
     * 服务方法与计数器的映射
     *
     * KEY:serviceKey + "." + methodName
     */
    private final ConcurrentMap<String, AtomicPositiveInteger> sequences = new ConcurrentHashMap<String, AtomicPositiveInteger>();


    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
        int length = invokers.size(); // Number of invokers
        int maxWeight = 0; // The maximum weight
        int minWeight = Integer.MAX_VALUE; // The minimum weight
        final LinkedHashMap<Invoker<T>, IntegerWrapper> invokerToWeightMap = new LinkedHashMap<Invoker<T>, IntegerWrapper>();
        int weightSum = 0;
        // 计算最小、最大权重,总的权重和。
        for (int i = 0; i < length; i++) {
            int weight = getWeight(invokers.get(i), invocation);
            maxWeight = Math.max(maxWeight, weight); // Choose the maximum weight
            minWeight = Math.min(minWeight, weight); // Choose the minimum weight
            if (weight > 0) {
                invokerToWeightMap.put(invokers.get(i), new IntegerWrapper(weight));
                weightSum += weight;
            }
        }
        // 获得 AtomicPositiveInteger 对象
        AtomicPositiveInteger sequence = sequences.get(key);
        if (sequence == null) {
            // 不存在的话 初始当前服务请求计数器
            sequences.putIfAbsent(key, new AtomicPositiveInteger());
            sequence = sequences.get(key);
        }
        // 返回当前顺序号,并递增 + 1
        int currentSequence = sequence.getAndIncrement();
        // 权重不相等,顺序根据权重分配
        if (maxWeight > 0 && minWeight < maxWeight) {
            int mod = currentSequence % weightSum; // 剩余权重
            for (int i = 0; i < maxWeight; i++) { // 循环最大权重
                for (Map.Entry<Invoker<T>, IntegerWrapper> each : invokerToWeightMap.entrySet()) { // 循环 Invoker 集合
                    final Invoker<T> k = each.getKey();
                    final IntegerWrapper v = each.getValue();
                    // 剩余权重归 0 ,当前 Invoker 还有剩余权重,返回该 Invoker 对象
                    if (mod == 0 && v.getValue() > 0) {
                        return k;
                    }
                    // 若 Invoker 还有权重值,扣除它( value )和剩余权重( mod )。
                    if (v.getValue() > 0) {
                        v.decrement();
                        mod--;
                    }
                }
            }
        }
        // 权重相等,平均顺序获得
        // Round robin
        return invokers.get(currentSequence % length);
    }

​ 通过源码分析该算法采用了两重循环,如果机器权重配置差异过大,实际上性能会有点慢,所以高版本做了后续的几次优化。

最少活跃连接数-LeastActiveLoadBalance

算法思想

​ 在分析源码前,我们先来了解一个概念—活跃连接数(active),active表示该服务提供者的当前服务活跃程度,初始情况下,活跃度为0,每收到(发出)一个请求,活跃数加1,完成请求时减1。在服务运行中,服务提供者处理的请求越快,则其活跃度下降的越快,所以最少活跃连接数的服务提供者,相对应的其性能最好,所以此负载均衡算法是基于性能为参考依据,进行负载。

活跃连接数也作用于并发限流,ActiveLimitFilter(服务消费者端),ExecuteLimitFilter(服务提供者端)

​ 补充:如果存在多个最小活跃连接数的服务提供者,则对该集合采用随机权重的获取方法得到对应的invoker。

源码分析

/**
* 最少活跃调用数,相同活跃数的随机,活跃数指调用前后计数差。
* 使慢的提供者收到更少请求,因为越慢的提供者的调用前后计数差会越大。
*
**/
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        int length = invokers.size(); // invokers列表总个数
        int leastActive = -1; // 最小的活跃数
        int leastCount = 0; // 相同最小活跃数的个数
        int[] leastIndexes = new int[length]; // 相同最小活跃数的下标
        int totalWeight = 0; // 总权重
        int firstWeight = 0; // 第一个权重,用于于计算是否相同
        boolean sameWeight = true; // 是否所有权重相同
        // 计算获得相同最小活跃数的数组和个数
        for (int i = 0; i < length; i++) {
            Invoker<T> invoker = invokers.get(i);
            //通过RpcStatus获取该invokers的活跃数
            int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive(); 
            int weight = invoker.getUrl().getMethodParameter(invocation.getMethodName(), Constants.WEIGHT_KEY, Constants.DEFAULT_WEIGHT); // 权重
            // 发现更小的活跃数,重新开始
            if (leastActive == -1 || active < leastActive) { 
                leastActive = active; // 记录最小活跃数
                leastCount = 1; // 重新统计相同最小活跃数的个数
                leastIndexes[0] = i; // 重新记录最小活跃数下标
                totalWeight = weight; // 重新累计总权重
                firstWeight = weight; // 记录第一个权重
                sameWeight = true; // 还原权重相同标识
            } else if (active == leastActive) { // 累计相同最小的活跃数
                leastIndexes[leastCount++] = i; // 累计相同最小活跃数下标
                totalWeight += weight; // 累计总权重
                // 判断所有权重是否一样
                if (sameWeight && weight != firstWeight) {
                    sameWeight = false;
                }
            }
        }
        // 如果只有一个最小则直接返回
        if (leastCount == 1) {
            return invokers.get(leastIndexes[0]);
        }
     // 如果权重不相同且权重大于0则按总权重数随机
        if (!sameWeight && totalWeight > 0) {
            int offsetWeight = random.nextInt(totalWeight);
            // 并确定随机值落在哪个片断上
            for (int i = 0; i < leastCount; i++) {
                int leastIndex = leastIndexes[i];
                offsetWeight -= getWeight(invokers.get(leastIndex), invocation);
                if (offsetWeight <= 0) {
                    return invokers.get(leastIndex);
                }
            }
        }
        // 如果权重相同或权重为0则均等随机
        return invokers.get(leastIndexes[random.nextInt(leastCount)]);
    }

一致性hash-ConsistentHashLoadBalance

一致性哈希算法保证相同的请求参数,每次请求到相同的服务器上面,

算法思想

​ 首先存在一个hash环,原算法包含(232次方 - 1)个节点,dubbo采用了一个treeMap在虚拟构造该环。

​ 然后,所有节点分布在环上面,如果一次采用一个节点 可能有多种情况的分布,如下图。
在这里插入图片描述

​ 可以看到,如果一个服务提供者值在环上只有一个节点的话,分布是各种各样的,极有可能存在数据倾斜,为了避免这个问题,提出了虚拟节点的概念。dubbo默认为每一个提供者设置160个虚拟节点,每四个节点为一组,然后所有虚拟节点均匀分布在环上面。

​ 在做服务选择时,先从请求参数中构造keyString,并采用同样MD5摘要算法,然后做hash求具体key,在哈希换上取距离key最近的服务提供者如果不存在,选择第一个。

img

源码分析

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

    /**
     * Hash nodes name 哈希node键 
     */
    public static final String HASH_NODES = "hash.nodes";

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

    /**
    * 服务方法和一致性哈希选择器的映射 key为serviceKey + "."methodName
    */
    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);
        // 构造key
        String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;
        // 基于invokers 根据对象内存地址获取系统的hashCode 
        int identityHashCode = System.identityHashCode(invokers);
        // 获取当前服务的选择器
        ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);
        // 如果选择器为空 或者hash值改变了 重新创建选择器
        if (selector == null || selector.identityHashCode != identityHashCode) {
            selectors.put(key, new ConsistentHashSelector<T>(invokers, methodName, identityHashCode));
            selector = (ConsistentHashSelector<T>) selectors.get(key);
        }
        return selector.select(invocation);
    }

    private static final class ConsistentHashSelector<T> {

        // 虚拟节点和invoker的对应映射
        private final TreeMap<Long, Invoker<T>> virtualInvokers;
		// 虚拟节点的个数
        private final int replicaNumber;
		// 定义的哈希值
        private final int identityHashCode;
		// 
        private final int[] argumentIndex;

        ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {
            this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
            this.identityHashCode = identityHashCode;
            URL url = invokers.get(0).getUrl();
            // 初始化虚拟节点个数 默认160个
            this.replicaNumber = url.getMethodParameter(methodName, HASH_NODES, 160);
           // 初始化argumentIndex
            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]);
            }
            // 构造virtualInvokers
            for (Invoker<T> invoker : invokers) {
                String address = invoker.getUrl().getAddress();
                // 每四个虚拟节点一组 保证digest的充分利用  提高效率
                for (int i = 0; i < replicaNumber / 4; i++) {
                    // 获取一个唯一的摘要
                    byte[] digest = md5(address + i);
                    for (int h = 0; h < 4; h++) {
                        // 对四个中每一个虚拟节点取hash,作为key 并均匀分布在hash环上面
                        long m = hash(digest, h);
                        virtualInvokers.put(m, invoker);
                    }
                }
            }
        }

        public Invoker<T> select(Invocation invocation) {
            //基于方法参数 作为key
            String key = toKey(invocation.getArguments());
            // 对key计算md5 同样采用MD5 便于计算long型的key
            byte[] digest = md5(key);
            // 先计算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) {
            //返回与大于或等于给定键的最小键关联的键值映射,如果没有这样的键,则返回null
            Map.Entry<Long, Invoker<T>> entry = virtualInvokers.ceilingEntry(hash);
            if (entry == null) {
                 // 不存在,则取 virtualInvokers 第一个
                entry = virtualInvokers.firstEntry();
            }
            return entry.getValue();
        }

        /**
        * hash算法 digest每四位进行拼接位一个32的long数据
        */
        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;
        }

        /**
        * md5摘要算法  返回一个128位的byte数组,数组长度位16
        */
        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();
        }

    }

}

补充

  • 所谓数据倾斜是指,由于节点不够分散,导致大量请求落到了同一个节点上,而其他节点只会接收到了少量请求的情况。
  • 如果服务提供者有变动(新增或者下线),则需要重新构造一致性选择器,此时会有额外的性能问题。
  • 一致性哈希算法中每个节点都是均匀分配的,如果性能不同的机器获取到了相同的请求,则其请求时间会有不同。
  • 大多数请求都是不带状态的,只请求一次就结束了,所以一般不采用一致性哈希路由算法,该算法常用于数据库,缓存集群的方案配置

总结

​ 这次分析了dubbo的负载均衡源码,我们常用的是随机权重,加权轮询,最少活跃连接数这三种算法,其中加权轮询在不同版本分别做了优化,后续有多余时间再进行研究。我们应该根据机器性能合理的配置权重,一开始如果是相同的机器的话,可以采用加权轮询,但我们后续难免会对机器扩容,如果存在新老机器都存在的情况,就需要配置不同的权重了。

​ 另外我们学习了一致性hash算法,一致性hash算法主要解决了常规hash算法在分布式环境下存在的 问题,如果服务节点增加或者减少,其对hash值进行取模时,由于余数变化,所有的路由关系或者数据映射都会混乱,从而请求不能请求到原先的机器。

即使坠落谷底,也要绝地反击。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值