Dubbo源码阅读——负载均衡

文章目录

    • 1. Dubbo负载均衡前的逻辑
      • 1.1 背景
      • 1.2 公共逻辑
    • 2. 四种负载均衡算法
      • 2.1 RandomLoadBalance
      • 2.2 LeastActiveLoadBalance
      • 2.3 RoundRobinLoadBalance
      • 2.4 ConsistentHashLoadBalance

在分布式系统中,负载均衡是非常常用的技术手段。它的职责是将大量的网络请求或者其他形式的负载均匀分布到不同的机器上。避免集群中部分服务器压力过大,而另一些服务器比较空闲的情况。在 Dubbo 中,也有负载均衡的概念和相应的实现。Dubbo 需要对服务消费者的调用请求进行分配,避免少数服务提供者负载过大。
Dubbo 提供了4种负载均衡实现,分别是

  • 基于权重随机算法的 RandomLoadBalance
  • 基于最少活跃调用数算法的 LeastActiveLoadBalance
  • 基于加权轮询算法的 RoundRobinLoadBalance
  • 基于 hash 一致性的 ConsistentHashLoadBalance

注:源码版本:dubbo 2.6.5

1. Dubbo负载均衡前的逻辑

1.1 背景

Dubbo负载均衡属于集群内容的一部分,负载均衡前还包括获取不同容错策略的ClusterInvoker、获取服务列表、服务路由等步骤。详见《Dubbo源码阅读——集群容错》中集群容错整体流程。
下面从代码上梳理一下集群部分的总体流程:
Dubbo有关集群的内容主要发生在两个阶段:
第一阶段,①,发生在服务消费端启动时,服务引用过程的创建Invoker阶段。此时RegistryProtocol的doRefer方法调用Cluster接口的join方法,得到一个ClusterInvoker。
第二阶段,②③④⑤,发生在服务调用阶段,ClusterInvoker的invoke方法被调用,触发这几个动作:

  1. 调用Directory的list方法,得到服务列表List,也叫服务字典。如图,服务列表有两种,静态列表和实时从注册中心获取变化的动态列表。默认动态列表。
  2. 在list方法内部,获取到服务列表后,再调用Router的route方法,进一步筛选,得到路由后的服务列表。如图,路由也有两种,脚本路由和条件路由。默认为条件路由。
  3. 对于大部分集群策略,还会有负载均衡的过程,即调用LoadBalance的select方法,获取到真实的Invoker,比如DubboInvoker。负载均衡策略有加权随机、加权轮询、最小活跃数、一致性哈希四种,默认为加权随机算法。
  4. 得到真实的Invoker后,Cluster层的内容也算完了,之后就是调用真实Invoker的invoke方法,它会调用服务目标方法,完成RPC调用。

1.2 公共逻辑

接口方法LoadBalance#select() 被调用,首先执行的是抽象类AbstractLoadBalance的select方法,该方法封装了四种负载均衡算法的共用逻辑:
select()

	@Override
    public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) {
    	//判断invoker列表,若为空列表或只有一个元素,则不用做负载均衡
        if (invokers == null || invokers.isEmpty())
            return null;
        if (invokers.size() == 1)
            return invokers.get(0);
        return doSelect(invokers, url, invocation);
    }
	//子类实现
    protected abstract <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation);

getWeight() 方法获取invoker计算服务预热后的权重。

	protected int getWeight(Invoker<?> invoker, Invocation invocation) {
        //获取配置的权重
        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分钟
                int warmup = invoker.getUrl().getParameter(Constants.WARMUP_KEY, Constants.DEFAULT_WARMUP);
                // 如果服务运行时间没有达到预热时间,重新计算权重,即降权
                if (uptime > 0 && uptime < warmup) {
                	// ( uptime / warmup) * weight
                    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));
		return ww < 1 ? 1 : (ww > weight ? weight : ww);
	}

除了一致性hash算法,其他三种算法都会使用到这个getWeight()方法来计算主机权重,运行时间没有达到预热时间的服务会被降权,减少被调用的概率。服务预热配置对一致性哈希负载均衡策略无效。

2. 四种负载均衡算法

2.1 RandomLoadBalance

RandomLoadBalance,加权随机算法的实现,是Dubbo默认的负载均衡策略。
算法思想
加权随机算法的实现比较简单。按各个主机的权重在数轴上分配对应的区域,然后生成总权重以内随机数,落到哪个区间就选出代表该区间的主机。
源码实现

@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
        // 只要出现不同的权重,sameWeight就会变为false
        if (sameWeight && i > 0
                && weight != getWeight(invokers.get(i - 1), invocation)) {
            sameWeight = false;
        }
    }
    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 (int i = 0; i < length; i++) {
            offset -= getWeight(invokers.get(i), invocation);
            if (offset < 0) {
                return invokers.get(i);
            }
        }
    }
    // If all invokers have the same weight value or totalWeight=0, return evenly.
    return invokers.get(random.nextInt(length));
}

RandomLoadBalance 是一个简单,高效的负载均衡实现,因此 Dubbo 选择它作为缺省实现。有一个小小的缺点,当调用次数比较少时,随机数可能比较集中。

2.2 LeastActiveLoadBalance

LeastActiveLoadBalance是加权最小活跃度算法的实现。活跃度这个概念指服务当前被调用的数量,有点像计数器的意思。活跃调用数越小,表明该服务提供者效率越高,单位时间内可处理更多的请求。此时应优先将请求分配给该服务提供者。
算法思想
每个服务提供者对应维护了一个活跃数 active。初始情况下,所有服务提供者活跃数均为0。每收到一个请求,活跃数加1,完成请求后则将活跃数减1。在服务运行一段时间后,性能好的服务提供者处理请求的速度更快,因此活跃数下降的也越快,此时这样的服务提供者能够优先获取到新的服务请求。
如果有多个服务活跃度等于得到的最小活跃度,也就是并列最小,此时 Dubbo 会根据它们的权重去分配请求,权重越大,获取到新请求的概率就越大。
源码实现

@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
    int length = invokers.size(); 
    //最小活跃度,初始值-1
    int leastActive = -1; 
    //具有最有活跃度主机的数量
    int leastCount = 0; 
    //记录最小活跃度主机的下标
    int[] leastIndexs = new int[length]; 
    int totalWeight = 0; 
    //记录最小活跃度主机的权重值,用以检测是否“所有具有相同最小活跃数的 Invoker 的权重”均相等
    int firstWeight = 0; 
    boolean sameWeight = true;
    // 遍历 invokers 列表 
    for (int i = 0; i < length; i++) {
        Invoker<T> invoker = invokers.get(i);
        // 获取服务的活跃度
        int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive(); // Active number
        // 计算权重 ①
        int afterWarmup = getWeight(invoker, invocation); 
        // 发现更小的活跃度,则刷新最小活跃度信息(更新数值,数量置为1,重新记录下标,重新计算权重)
        if (leastActive == -1 || active < leastActive) { 
            leastActive = active; 
            leastCount = 1; 
            leastIndexs[0] = i; 
            totalWeight = afterWarmup; 
            firstWeight = afterWarmup; 
            sameWeight = true; 
        // 当前 Invoker 的活跃数 active 与最小活跃数 leastActive 相同
        } else if (active == leastActive) { 
        	//记录下标
            leastIndexs[leastCount++] = i; 
            //计算总权重,后面加权随机的时候使用
            totalWeight += afterWarmup;
            if (sameWeight && i > 0
                    && afterWarmup != firstWeight) {
                sameWeight = false;
            }
        }
    }
    // assert(leastCount > 0)
    if (leastCount == 1) {
        // 具有最小活跃度的服务只有一个,直接选出返回
        return invokers.get(leastIndexs[0]);
    }
    // 下面逻辑与加权随机相似 
    if (!sameWeight && totalWeight > 0) {
    	// +1 是因为下面的 if (offsetWeight <= 0) 判断。②
        int offsetWeight = random.nextInt(totalWeight) + 1;
        for (int i = 0; i < leastCount; i++) {
            int leastIndex = leastIndexs[i];
            offsetWeight -= getWeight(invokers.get(leastIndex), invocation);
            if (offsetWeight <= 0)
                return invokers.get(leastIndex);
        }
    }
    return invokers.get(leastIndexs[random.nextInt(leastCount)]);
}

LeastActiveLoadBalance 负载均衡策略依然比较简单,重点是需要在每个invoker维护一个活跃度字段active,负载均衡的时候获取活跃度并找出最小活跃度的invoker。
这里官网提到在2.6.5版本修复了两个bug,
一个是①处,2.6.5之前的版本这一行是获取invoker上的配置的权重,

int weight = invoker.getUrl().getMethodParameter(invocation.getMethodName(), Constants.WEIGHT_KEY, Constants.DEFAULT_WEIGHT);

这样的话,算出来的totalWeight是未降权的,而后面随机数减去的权重是计算预热的,可能会比配置的权重小,导致有可能offsetWeight -= getWeight(invokers.get(leastIndex), invocation); 经过leastCount次计算后仍然大于0,最终无法选中invoker。
另一个bug是②处,2.6.5之前的版本生成的随机数没有+1。

int offsetWeight = random.nextInt(totalWeight)

问题出在了offsetWeight <= 0的判断上,举例说明,假设有一组 Invoker 的权重为 5、2、1,offsetWeight 最大值为 7。假设 offsetWeight = 7,你会发现,当 for 循环进行第二次遍历后 offsetWeight = 7 - 5 - 2 = 0,提前返回了。此时,此时权重为1的 Invoker 就没有机会被选中了。

2.3 RoundRobinLoadBalance

RoundRobinLoadBalance,加权轮询算法的实现。加权轮询算法的实现相对比较复杂一些。首先所谓轮询,就是把请求依次分发给每台服务器,例如有A,B,C三台服务器,第一个请求发给A,第二个请求发给B,第三个请求发给C,第四个请求发给A…… 轮询是一种无状态负载均衡算法,实现简单,适用于每台服务器性能相近的场景下。但现实情况下,我们并不能保证每台服务器性能均相近。如果我们将等量的请求分配给性能较差的服务器,这显然是不合理的。因此,需要给服务器加权。那么dubbo的加权轮询算法是如何实现的呢,
算法思想
举例说明:
假设有5个主机A,B,C,D,E,配置的主机权重分别是3,5,7,4,9,

Dubbo源码阅读——负载均衡_第1张图片
以下是上图的过程解释:
每一次轮询,选择轮询权重最大的主机(第一次选择主机权重最大的主机),同时更新所有主机的轮询权重(初始轮询权重是0)。更新的规则是:

未被选中的主机:新轮询权重 = 轮询权重 + 主机权重;
选中主机:新轮询权重 = 轮询权重 + 主机权重 - 总权重;

加权轮询的重点就是以上更新权重的规则。它使得大量请求可以平滑地按权重比例分发到服务器上。
源码实现

public class RoundRobinLoadBalance extends AbstractLoadBalance {
    public static final String NAME = "roundrobin";
    //缓存中超过这个时间没有更新的invoker将被移除
    private static int RECYCLE_PERIOD = 60000;
    
    protected static class WeightedRoundRobin {
        //主机权重
        private int weight;
        //轮询权重
        private AtomicLong current = new AtomicLong(0);
        //最后更新时间
        private long lastUpdate;
        public int getWeight() {
            return weight;
        }
        public void setWeight(int weight) {
            this.weight = weight;
            current.set(0);
        }
        public long increaseCurrent() {
            return current.addAndGet(weight);
        }
        public void sel(int total) {
            current.addAndGet(-1 * total);
        }
        public long getLastUpdate() {
            return lastUpdate;
        }
        public void setLastUpdate(long lastUpdate) {
            this.lastUpdate = lastUpdate;
        }
    }
    //缓存,key是方法全限定名,代表invoker
    private ConcurrentMap<String, ConcurrentMap<String, WeightedRoundRobin>> methodWeightMap = new ConcurrentHashMap<String, ConcurrentMap<String, WeightedRoundRobin>>();
    //乐观锁
    private AtomicBoolean updateLock = new AtomicBoolean();
    
    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        //1.通过目标方法全限定名从缓存中获取轮询权重信息,缓存没有则创建并加入缓存
        String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
        ConcurrentMap<String, WeightedRoundRobin> map = methodWeightMap.get(key);
        if (map == null) {
            methodWeightMap.putIfAbsent(key, new ConcurrentHashMap<String, WeightedRoundRobin>());
            map = methodWeightMap.get(key);
        }
        int totalWeight = 0;
        //记录最大轮询权重
        long maxCurrent = Long.MIN_VALUE;
        long now = System.currentTimeMillis();
        //记录负载均衡选出来的invoker结果
        Invoker<T> selectedInvoker = null;
        //选出来的invoker的轮询权重信息
        WeightedRoundRobin selectedWRR = null;
        //2.遍历服务列表,开始轮询
        for (Invoker<T> invoker : invokers) {
            String identifyString = invoker.getUrl().toIdentityString();
            WeightedRoundRobin weightedRoundRobin = map.get(identifyString);
            //计算权重,考虑预热
            int weight = getWeight(invoker, invocation);
            if (weight < 0) {
                weight = 0;
            }
            //2.1 缓存中没有该invoker的轮询权重信息则创建并加入缓存
            if (weightedRoundRobin == null) {
                weightedRoundRobin = new WeightedRoundRobin();
                weightedRoundRobin.setWeight(weight);
                map.putIfAbsent(identifyString, weightedRoundRobin);
                weightedRoundRobin = map.get(identifyString);
            }
            //2.2 由于服务运行时间改变,计算出来的主机权重可能有变化,则刷新缓存的主机权重,轮询权重重置为0
            if (weight != weightedRoundRobin.getWeight()) {
                //weight changed
                weightedRoundRobin.setWeight(weight);
            }
            //2.3 更新轮询权重,新轮询权重=轮询权重+主机权重
            long cur = weightedRoundRobin.increaseCurrent();
            //2.4 更新最后更新时间
            weightedRoundRobin.setLastUpdate(now);
            //2.5 每次更新轮询权重都要更新轮询权重最大值,及对应的invoker
            if (cur > maxCurrent) {
                maxCurrent = cur;
                selectedInvoker = invoker;
                selectedWRR = weightedRoundRobin;
            }
            //2.6 计算总权重
            totalWeight += weight;
        }
        //3.缓存中有些invoker挂了,invokers.size() < map.size(),需要移出缓存
        if (!updateLock.get() && invokers.size() != map.size()) {
            //乐观锁,保证线程安全
            if (updateLock.compareAndSet(false, true)) {
                try {
                    // copy -> modify -> update reference
                    ConcurrentMap<String, WeightedRoundRobin> newMap = new ConcurrentHashMap<String, WeightedRoundRobin>();
                    newMap.putAll(map);
                    Iterator<Entry<String, WeightedRoundRobin>> it = newMap.entrySet().iterator();
                    while (it.hasNext()) {
                        Entry<String, WeightedRoundRobin> item = it.next();
                        //超过RECYCLE_PERIOD时间没有被轮询的invoker,从缓存中移除
                        if (now - item.getValue().getLastUpdate() > RECYCLE_PERIOD) {
                            it.remove();
                        }
                    }
                    methodWeightMap.put(key, newMap);
                } finally {
                    updateLock.set(false);
                }
            }
        }
        //4.选出轮询权重最大的invoker后,更新其轮询权重为 轮询权重-总权重,供后面继续轮询
        if (selectedInvoker != null) {
            selectedWRR.sel(totalWeight);
            return selectedInvoker;
        }
        // should not happen here
        return invokers.get(0);
    }
}

通过前面的算法思想解释,源码及注释,加权轮询算法的实现原理就比较清晰了。

2.4 ConsistentHashLoadBalance

ConsistentHashLoadBalance,一致性hash负载均衡算法的实现。一致性hash算法被广泛运用于大规模缓存系统的负载均衡。
算法思想
它的工作过程是这样的,首先根据 ip 或者其他的信息为缓存节点生成一个 hash,并将这个 hash 投射到 [0, 232 - 1] 的圆环上,一般会为一个缓存节点生成多个虚拟节点,时期在圆环上更加分散,避免数据倾斜。当有查询或写入请求时,则为缓存项的 key (dubbo是根据方法参数)生成一个 hash 值。然后查找第一个大于或等于该 hash 值的缓存节点,并到这个节点中查询或写入缓存项。如果当前节点挂了,则在下一次查询或写入缓存时,为缓存项查找另一个大于其 hash 值的缓存节点即可。
源码实现

// 缓存
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;
    int identityHashCode = System.identityHashCode(invokers);
    ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);
    // 如果服务提供者数量发生了变化,selector.identityHashCode != identityHashCode 条件成立,
    // 需重新将缓存节点分配在圆环上,rehash
    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);
}

上面是简单处理缓存的逻辑,下面看selector负载均衡逻辑。先来看一下一致性 hash 选择器 ConsistentHashSelector 的初始化过程,

// hash与invoker虚拟节点的映射关系。virtualInvokers 需要提供高效的查询操作,因此选用 TreeMap 作为存储结构
private final TreeMap<Long, Invoker<T>> virtualInvokers;
// 虚拟节点数
private final int replicaNumber;
// 服务列表的hashcode
private final int identityHashCode;
// 参与 hash 计算的参数下标值,默认对第一个参数进行 hash 运算
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);
    // 参与 hash 计算的参数下标值,默认对第一个参数进行 hash 运算
    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++) {
            // 对 address + i 进行 md5 运算,得到一个长度为16的字节数组(摘要)
            byte[] digest = md5(address + i);
            // 对 digest 部分字节进行4次 hash 运算,得到四个不同的 long 型正整数
            for (int h = 0; h < 4; h++) {
                // h = 0 时,取 digest 中下标为 0 ~ 3 的4个字节进行位运算
                // h = 1 时,取 digest 中下标为 4 ~ 7 的4个字节进行位运算
                // h = 2, h = 3 时过程同上
                long m = hash(digest, h);
                // 将 hash 到 invoker 的映射关系存储到 virtualInvokers 中
                virtualInvokers.put(m, invoker);
            }
        }
    }
}

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;
}

以上代码通过invoker列表,虚拟节点数配置,影响hash的方法参数配置,确定虚拟节点的hash。下面再看select方法:

public Invoker<T> select(Invocation invocation) {
    //把参数转化为key,根据前面的配置的方法参数下标的参数,拼接成字符串
    String key = toKey(invocation.getArguments());
    //获取key的md5值
    byte[] digest = md5(key);
    // 取 digest 数组的前四个字节进行 hash 运算,再将 hash 值传给 selectForKey 方法,
    // 寻找合适的 Invoker
    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) {
    // 到virtualInvokers TreeMap 中查找第一个节点值大于或等于当前 hash 的 Invoker
    Map.Entry<Long, Invoker<T>> entry = virtualInvokers.tailMap(hash, true).firstEntry();
    // 如果 hash 大于 Invoker 在圆环上最大的位置,则选择头节点
    if (entry == null) {
        entry = virtualInvokers.firstEntry();
    }
    return entry.getValue();
}

选择的过程相对比较简单了。首先是对参数进行 md5 以及 hash 运算,得到一个 hash 值。然后再拿这个值到初始化好的virtualInvokers 中查找目标 Invoker 即可。
一致性hash负载均衡方式没有用到父类getWeight()方法,它与权重配置、预热时间配置无关,是方法级别的负载均衡,相关配置是:

<dubbo:reference id="demoService" check="false" interface="com.alibaba.dubbo.demo.DemoSservice">
	<dubbo:method name="sayHello" >
		<dubbo:parameter key="hash.nodes" value="160"/>
		<dubbo:parameter key="hash.arguments" value="1,2"/>
	dubbo:method>
dubbo:reference>

你可能感兴趣的:(Dubbo)