Dubbo源码解析-负载均衡

384 阅读9分钟

1.相关概念

        当服务集群中所有的节点都处于活动状态,每个节点都会分摊整个系统的工作负载。负载均衡集群一般用于相应网络请求的网页服务器,数据库服务器。这种集群可以在接到请求时,检查接受请求较少,不繁忙的服务器,并把请求转到这些服务器上。负载均衡有DNS负载均衡、IP负载均衡、反向代理负载均衡等模式,在模式中也有多种负载均衡的实现算法,如下图所示,在集群中有服务器A、B、C,它们都是互不影响,互不相干的,任何一台的机器宕了,都不会影响其他机器的运行,当用户来一个请求,有负载均衡器的算法决定由哪台机器来处理,假如你的算法是采用round算法,用户a、b、c分别发送请求,那么分别由服务器A、B、C来处理响应。

2.负载均衡的实现

2.1 负载均衡的总体结构

       Dubbo框架内置了4种负载均衡算法,用户也可以自行扩展,默认实现为RandomLoadBalance(随机负载均衡),由于select方法上有@Adaptive注解,会根据URL带的loadbalance参数在运行时动态指定负载均衡算法。

@SPI(RandomLoadBalance.NAME)
public interface LoadBalance {
    @Adaptive("loadbalance")
    <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException;
}

        主要类结构如下所示,AbstractLoadBalance作为抽象类实现了LoadBalance接口方法,并有多种具体负载均衡策略实现类。

Dubbo各种负载均衡策略的相关说明:

  • RandomLoadBalance: 随机,按照权重设置随机概率。在一个节点上碰撞的概率高,但调用量越大分布越均匀,而且按概率使用权重后也比较均匀,有利于动态调整提供者的权重;
  • RoundRobinLoadBalance: 轮询,按公约后的权重设置轮询比例。存在慢的提供者累计请求的问题。比如:第二胎机器很慢,但仍可响应请求,当请求到第二台时就卡在这里,久而久之,都会卡在第二台上;
  • LeastActiveLoadBalance: 最少活跃调用数,如果活跃数相同则随机调用,活跃数指调用前后计数差。使慢的提供者收到更少请求,因为越慢的提供者的调用前后计数差会越大;
  • ConsistentHashLoadBalance: 一致性Hash,相同的参数的请求总是发到同一提供者。当某一台提供者“挂”时,原本发往该提供者的请求,基于虚拟节点,会平摊到其他提供者,不会引起剧烈的波动。默认只对第一个参数"Hash",如果要修改,则配置hash.arguments,默认使用160份虚拟节点,如果需要修改,则配置hash.node,默认为320。

      AbstractLoadBalance实现了LoadBalance接口,并封装了一些常用的方法,RoundRobinLoadBalance/RandomLoadBalance等继承了AbstractLoadBalance类,各自实现了doSelect方法,其中还有一个抽象方法就是获取服务节点的权重值,这里有个预热的概念,就是JVM都是有一个预热的过程,在运行一段时间后才会达到最佳状态,Dubbo框架针对JIT编译器做了优化,主动去触发编译优化,如果服务提供者上线时间少于预热时间则需要重新计算节点权重值。

public abstract class AbstractLoadBalance implements LoadBalance {

    ...
    int getWeight(Invoker<?> invoker, Invocation invocation) {

        int weight;
        URL url = invoker.getUrl();
        if (REGISTRY_SERVICE_REFERENCE_PATH.equals(url.getServiceInterface())) {
            weight = url.getParameter(REGISTRY_KEY + "." + WEIGHT_KEY, 
                DEFAULT_WEIGHT);
        } else {
            //通过url获取权重参数,如无则默认100
            weight = url.getMethodParameter(invocation.getMethodName(),
                 WEIGHT_KEY, DEFAULT_WEIGHT);
            if (weight > 0) {
                //通过url拿到服务的上线时间戳
                long timestamp = invoker.getUrl().getParameter(TIMESTAMP_KEY, 0L);
                if (timestamp > 0L) {
                    //服务提供者的已上线时间
                    long uptime = System.currentTimeMillis() - timestamp;
                    if (uptime < 0) {
                        return 1;
                    }
                    //获取服务提供者的预热时间,默认为10分钟
                    int warmup = invoker.getUrl().getParameter(WARMUP_KEY, 
                                DEFAULT_WARMUP);
                    //如果提供者在线时间比预热时间要小则需要重新计算权重值
                    if (uptime > 0 && uptime < warmup) {
                        weight = calculateWarmupWeight((int)uptime, warmup, weight);
                    }
                }
            }
        }
        return Math.max(weight, 0);
    }
    ...

    //子类实现具体的select方法 
    protected abstract <T> Invoker<T doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation);
}

2.2 Random

         随机调用负载均衡实现,该类实现了抽象的AbstractLoadBalance接口,重写了doSelect方法,首先遍历每个提供服务的机器,获取每个服务的权重,然后累加权重值,其中判断每个服务的提供者权重是否相同,最后如果每个调用者的权重不相同,并且每个权重大于0,那么就会根据权重的总值生成一个随机数,再用这个随机数,根据调用者的数量每次减去调用者的权重,直到计算出当前的服务提供者随机数小于0,就选择那个提供者。另外,如果每个机器的权重的都相同,那么权重就不会参与计算,直接选择随机算法生成的某一个选择,完全随机。

public class RandomLoadBalance extends AbstractLoadBalance {

    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        //拿到服务列表的个数
        int length = invokers.size();
        //是否所有服务节点权重都一样
        boolean sameWeight = true;
        //初始化服务节点的权重列表
        int[] weights = new int[length];
        //调用抽象类AbstractLoadBalance#getWeight方法,获取第一个权重
        int firstWeight = getWeight(invokers.get(0), invocation);
        weights[0] = firstWeight;
        int totalWeight = firstWeight;

        //计算总的权重,并填充权重列表,判断是否为相同权重
        for (int i = 1; i < length; i++) {
            int weight = getWeight(invokers.get(i), invocation);
            weights[i] = weight;
            totalWeight += weight;
            if (sameWeight && weight != firstWeight) {
                sameWeight = false;
            }
        }

        //总的权重和大于0与各个服务节点权重不完全相同
        if (totalWeight > 0 && !sameWeight) {
            //随机获取递减值
            int offset = ThreadLocalRandom.current().nextInt(totalWeight);
            //遍历所有的Invoker,累减,得到被选中的Invoker
            for (int i = 0; i < length; i++) {
                offset -= weights[i];
                if (offset < 0) {
                    return invokers.get(i);
                }
            }
        }

        //如果权重和不大于0或者服务节点权重都一样则随机获取
        return invokers.get(ThreadLocalRandom.current().nextInt(length));
    }
}

        假设有4个Invoker,它们的权重分别是1、2、3、4,则权重是10。说明每个Invoker分别有1/10、2/10、3/10、4/10的概率会被选中。然后nextInt(10)会返回0-10之间的一个整数,假设为5,进行累减,减到3后会小于0,此时会落入3的区间,就会选择3号Invoker。

2.3 LeastActive

         最少活跃调用数负载均衡实现,框架会记录下每个Invoker的活跃数,每次只从活跃数最少的Invoker里面选一个节点,将调用活跃数相同的Invoker放入集合中,使用的Random算法从中挑选。这个负载均衡算法需要配合RpcStatus请求状态记录容器和ActiveLimitFilter过滤器来计算每个接口方法的活跃数。

public class LeastActiveLoadBalance extends AbstractLoadBalance {

    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        ....
        for (int i = 0; i < length; i++) {
            ...
            //获得Invoker活跃数和预热权重             
            int active = RpcStatus.getStatus(invoker.getUrl(), 
                        invocation.getMethodName()).getActive();
            ...
            //如果是第一次或发现更小的活跃数
            if (leastActive == -1 || active < leastActive) {
                //以前的计数要重新开始,置空
                leastActive = active;
                leastCount = 1;
                leastIndexes[0] = i;
                totalWeight = afterWarmup;
                firstWeight = afterWarmup;
                sameWeight = true;
            } else if (active == leastActive) {
                 //当前Invoker活跃数与计数相同,
                //则有x个Invoker都是最小计数,全部保存在集合中,后续在这个集合里面随机挑选
                leastIndexes[leastCount++] = i;
                totalWeight += afterWarmup;
                if (sameWeight && afterWarmup != firstWeight) {
                    sameWeight = false;
                }
            }
        }
        ...
    }
}

         最少活跃调用数需要一个记录每个节点调用的容器RpcStatus,里面主要存储了url与RpcStatus对象的对应关系,url与调用方法的对应关系,还有各类的计数器。在beginCount方法中主要是做方法调用次数的自增。

public class RpcStatus {

    //url->RpcStatus的关系
    private static final ConcurrentMap<String, RpcStatus> SERVICE_STATISTICS
             = new ConcurrentHashMap<String, RpcStatus>();

    //url->(methodName->RpcStatuc)的关系
    private static final ConcurrentMap<String, ConcurrentMap<String, RpcStatus>> 
        METHOD_STATISTICS = 
            new ConcurrentHashMap<String, ConcurrentMap<String, RpcStatus>>();
    
     ...

    public static boolean beginCount(URL url, String methodName, int max) {
        ...
        //方法级调用次数自增,使用的CAS自旋方式保证线程安全的
        for (int i; ; ) {
            i = methodStatus.active.get();
            if (i + 1 > max) {
                return false;
            }
            if (methodStatus.active.compareAndSet(i, i + 1)) {
                break;
            }
        }
        appStatus.active.incrementAndGet();
        ...
        return true;
    }
}

         在ActiveLimitFilter类上有@Activate注解,由于进行统计在高并发情况下有较大的性能损耗,所以在遇到对应的条件才会加入到Filter链路上执行,invoke中使用RpcStatus的方法进行调用次数/时间的统计。

@Activate(group = CONSUMER, value = ACTIVES_KEY)
public class ActiveLimitFilter implements Filter, Filter.Listener {    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) 
            throws RpcException {
        ...         
        if (!RpcStatus.beginCount(url, methodName, max)) {
            long timeout = invoker.getUrl().getMethodParameter(invocation.getMethodName(), TIMEOUT_KEY, 0);
            long start = System.currentTimeMillis();
            long remain = timeout;
            synchronized (rpcStatus) {
                while (!RpcStatus.beginCount(url, methodName, max)) {
                    try {
                        rpcStatus.wait(remain);
                    } catch (InterruptedException e) {
                       ... 
                    }
        ...
}

2.4 RoundRobin

        权重轮询负载均衡会根据设置的权重来判断轮询的比例,轮询调用的过程主要是维护了局部变量的一个map去存储调用者和权重值的对应关系。在doSelect方法中会遍历所有可用的节点(Invoker列表),对于每个Invoker的current=current+weight,同时累加每个Invoker的weight到totalWeight中,即totalWeight=totalWeight+weight,遍历完后就将current值最大的节点作为选择的节点,并把它的current值减去totalWeight,即current=current-totalWeight,这样每次轮询,weight值最大的节点总能较快减去马上又到可选择的条件,而weight值小的节点就要经过多几次循环减去totalWeigth才能到可选择条件。

public class RoundRobinLoadBalance extends AbstractLoadBalance {
    
    ...
    protected static class WeightedRoundRobin {
        //权重
        private int weight;
        //调用次数
        private AtomicLong current = new AtomicLong(0);
        //最后更新时间
        private long lastUpdate;
        //减去某个值
        public void sel(int total) {    
            current.addAndGet(-1 * total);
        }    
    }

   //定义权重缓存
    private ConcurrentMap<String, ConcurrentMap<String, WeightedRoundRobin>> 
        methodWeightMap = new ConcurrentHashMap<String, 
                ConcurrentMap<String, WeightedRoundRobin>>();

    ...

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

        //初始化 (服务接口名.方法名, 节点权重轮询问)缓存
        ...

        for (Invoker<T> invoker : invokers) {
            //获取url的唯一标识字符串
            String identifyString = invoker.getUrl().toIdentityString();
            //获取invoker的权重
            int weight = getWeight(invoker, invocation);
            //初始化权重记录
            WeightedRoundRobin weightedRoundRobin = 
                map.computeIfAbsent(identifyString, k -> {
                WeightedRoundRobin wrr = new WeightedRoundRobin();
                wrr.setWeight(weight);
                return wrr;
                });
            
            //更新权重记录信息
            long cur = weightedRoundRobin.increaseCurrent();
            weightedRoundRobin.setLastUpdate(now);
            //调用次数超过最大数则选出该Invoker
            if (cur > maxCurrent) {
                //更新
                maxCurrent = cur;
                selectedInvoker = invoker;
                selectedWRR = weightedRoundRobin;
            }

            //增加到总权重
            totalWeight += weight;
        }

        //有可能会选择多个超出最大数了,去除掉最后更新时间大于60s的Invoker
        if (invokers.size() != map.size()) {
            map.entrySet().removeIf(item -> now - 
                item.getValue().getLastUpdate() > RECYCLE_PERIOD);
        }

        //将选出的Invoker的调用次数减去总的权重
        if (selectedInvoker != null) {
            selectedWRR.sel(totalWeight);
            return selectedInvoker;
        }

        return invokers.get(0);
    }

}

2.5 一致性Hash

         一致性Hash负载均衡可以让参数相同的请求每次都路由到相同的机器上。这种负载均衡的方式让请求相对平均, 相比直接使用Hash而言,当某些节点下线时,请求会平分的到其他服务节点,这样才不会引起剧烈变动。一致性Hash例子如下所示,普通一致性Hash会把每个服务节点散列在环形上,然后把请求的客户端散列到环上,顺时针找到的第一个节点就是要调用的节点。假设节点c 宕机下线,则落在区域2部分的客户端会自动迁移到节点d上,这样避免了全部重新散列的问题。

        普通一致性Hash也有一定的局限性,它的散列不一定均匀,容易造成某些节点压力大,因此Dubbo框架使用了Ketama一致性Hash算法,这种算法为每个真实节点再创建多个虚拟节点,让节点在环上分布更加均匀,后续的调用也更加均匀。

public class ConsistentHashLoadBalance extends AbstractLoadBalance {

    @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;
        //获取Invoker的hashCode
        int invokersHashCode = invokers.hashCode();
        
        //根据一致性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);
    }
}

          ConsistenHashSelector初始化会对节点进行散列,散列的环形是使用一个TreeMap实现的,所有的真实、虚拟节点都会放入TreeMap中,其中key的值是Hash(MD5(节点的 IP+递增数字)),value的值是调用的节点。在客户端使用,要对请求的参数做MD5计算,其中MD5的值不一定能准确匹配到TreeMap中的key,所以在selectForKey方法使用了TreeMap的cellingEntry方法用来返回一个至少大于或等于key的entry,其中entry的value就是Invoker,这样就实现了顺时针往前查找的效果。

private static final class ConsistentHashSelector<T> {

        //存储节点标识与可使用的Invoker的对应关系
        private final TreeMap<Long, Invoker<T>> virtualInvokers;
        ...
        ConsistentHashSelector(List<Invoker<T>> invokers, 
                    String methodName, int identityHashCode) {
            //初始化TreeMap
            this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
            //从url中获取需要复制的节点个数,默认是160
            this.replicaNumber = url.getMethodParameter(methodName, HASH_NODES, 160);
            ...
            for (Invoker<T> invoker : invokers) {
                String address = invoker.getUrl().getAddress();
                for (int i = 0; i < replicaNumber / 4; i++) {
                    //以IP+递增数字做MD5,以此作为节点标识
                    byte[] digest = md5(address + i);
                    for (int h = 0; h < 4; h++) {
                        //对标识做"hash"得到TreeMap的key, Invoker为value
                        long m = hash(digest, h);
                        virtualInvokers.put(m, invoker);
                    }
                }
            }
        }

        private Invoker<T> selectForKey(long hash) {
            //获取一个至少大于或等于当前给定key的entry
            Map.Entry<Long, Invoker<T>> entry = virtualInvokers.ceilingEntry(hash);
            if (entry == null) {
                //如果为空,则返回第一个entry
                entry = virtualInvokers.firstEntry();
            }
            return entry.getValue();
        }

        public Invoker<T> select(Invocation invocation) {
            //将rpc请求参数做md5
            String key = toKey(invocation.getArguments());
            byte[] digest = md5(key);
            //根据md5的值选择Invoker
            return selectForKey(hash(digest, 0));
        }

    }
}

3.总结

         本篇主要讲述了服务集群中负载均衡的相关原理,以及Dubbo框架中Random/RoundRobin/LeastActive/一致性hash等方式的负载均衡的主要实现思路。

参考文献

blog.csdn.net/wfq78496769…

blog.csdn.net/feelwing131… dubbo预热过程

blog.csdn.net/zhoufanyang… jvm预热