Soul 网关源码分析(十三)divide 插件(三)

378 阅读5分钟

今天我们继续看 divide 下的随机负载均衡策略,手先看下 这个 Random 类在 MomoSec 插件中被规则检测出了:

其中的描述提到:java.util.Random依赖一个可被预测的伪随机数生成器,因为

  1. java.util.Random类中实现的随机算法是伪随机,也就是有规则的随机,所谓有规则的就是在给定种(seed)的区间内随机生成数字;
  2. 相同种子数的Random对象,相同次数生成的随机数字是完全相同的;
  3. Random类中各方法生成的随机数字都是均匀分布的,也就是说区间内部的数字生成的几率均等;

其实这个地方随机数只是为了负载均衡策略,不会有这样的问题。

@Join
public class RandomLoadBalance extends AbstractLoadBalance {

	//无参构造函数创建的 Random 实例的 seed 是当年的 纳秒时间戳
    private static final Random RANDOM = new Random();

    @Override
    public DivideUpstream doSelect(final List<DivideUpstream> upstreamList, final String ip) {
    	//计算出总权重数
        int totalWeight = calculateTotalWeight(upstreamList);
        //检查所有的 upstreams 分配到的权重是否完全相等
        boolean sameWeight = isAllUpStreamSameWeight(upstreamList);
        //如果总权重数 > 0 且 upstream 分配到的权重都不相等
        if (totalWeight > 0 && !sameWeight) {
        	// 返回选择的 upstream
            return random(totalWeight, upstreamList);
        }
        // If the weights are the same or the weights are 0 then random
        // 如果 upstreams 分配到的权重完全相等,或者 总权重数 = 0
        return random(upstreamList);
    }

    private boolean isAllUpStreamSameWeight(final List<DivideUpstream> upstreamList) {
        boolean sameWeight = true;
        int length = upstreamList.size();
        for (int i = 0; i < length; i++) {
            int weight = getWeight(upstreamList.get(i));
            if (i > 0 && weight != getWeight(upstreamList.get(i - 1))) {
                // 依次和前一个比较权重是否相等,如果不等就跳出当前循环
                sameWeight = false;
                break;
            }
        }
        return sameWeight;
    }

    private int calculateTotalWeight(final List<DivideUpstream> upstreamList) {
        // total weight
        int totalWeight = 0;
        for (DivideUpstream divideUpstream : upstreamList) {
            int weight = getWeight(divideUpstream);
            // 计算总权重数
            totalWeight += weight;
        }
        return totalWeight;
    }

    private DivideUpstream random(final int totalWeight, final List<DivideUpstream> upstreamList) {
        // If the weights are not the same and the weights are greater than 0, then random by the total number of weights
        int offset = RANDOM.nextInt(totalWeight);
        // Determine which segment the random value falls on
        for (DivideUpstream divideUpstream : upstreamList) {
            offset -= getWeight(divideUpstream);
            if (offset < 0) {
                return divideUpstream;
            }
        }
        return upstreamList.get(0);
    }

    private DivideUpstream random(final List<DivideUpstream> upstreamList) {
        return upstreamList.get(RANDOM.nextInt(upstreamList.size()));
    }
}

接下来是 一个巧妙的负载均衡策略:RoundRobinLoadBalance,首先我们来了解一下相关的概念:

Nginx 的负载均衡调度算法默认就是 round robin,也就是轮询调度算法。根据维基百科的介绍:术语循环/轮转/轮替(英语:Round-robin)用于多种情况中,通常指将多个某物轮流用于某事,例如“逐户派对”(round-robin-party)中所有参与者要挨家挨户地拜访每位参与者的住处并参加那里的小型聚会。联名信(round-robin letter)往往是指一大群下属为批评其领导而写的一封信,这种信一般只在签名人数多到难于逐个报复后才会寄出。

在负载均衡算法上的应用,也就是由 upstreams 轮流来处理请求,但在 Soul 中 是加权轮询调度的,查阅Nginx 官方文档关于轮询调度算法的文章,发现对加权轮询调度有以下的解释:

Weighted round robin – A weight is assigned to each server based on criteria chosen by the site administrator; the most commonly used criterion is the server’s traffic‑handling capacity. The higher the weight, the larger the proportion of client requests the server receives. If, for example, server A is assigned a weight of 3 and server B a weight of 1, the load balancer forwards 3 requests to server A for each 1 it sends to server B.

加权轮询是根据站点管理员选择的标准为每个服务器分配一个权重的策略。最常用的标准是服务器的流量处理能力。权重越高,服务器接收的客户端请求的比例越大。例如,如果为服务器A分配了3的权重,为服务器B分配了1的权重,那么负载均衡器每向服务器B转发 1 个请求,就会向服务器A转发 3 个请求,因为服务器A权重更高,代表他处理流量的能力更强。

加权轮询算法的原理就是:根据服务器的不同处理能力,给每个服务器分配不同的权值,使其能够接受相应权值数的服务请求。

下面我们深入源码来看是如何实现的:

@Join
public class RoundRobinLoadBalance extends AbstractLoadBalance {

	//回收周期为 60000 毫秒,即 60 秒
    private final int recyclePeriod = 60000;
	//函数权重 map
    private final ConcurrentMap<String, ConcurrentMap<String, WeightedRoundRobin>> methodWeightMap = new ConcurrentHashMap<>(16);
	//更新锁,用 AtomicBoolean 实现
    private final AtomicBoolean updateLock = new AtomicBoolean();

    @Override
    public DivideUpstream doSelect(final List<DivideUpstream> upstreamList, final String ip) {
        String key = upstreamList.get(0).getUpstreamUrl();
        ConcurrentMap<String, WeightedRoundRobin> map = methodWeightMap.get(key);
        //将 第一个 upstream url 放入 函数权重 map
        if (map == null) {
            methodWeightMap.putIfAbsent(key, new ConcurrentHashMap<>(16));
            map = methodWeightMap.get(key);
        }
        int totalWeight = 0;
        long maxCurrent = Long.MIN_VALUE;
        long now = System.currentTimeMillis();
        DivideUpstream selectedInvoker = null;
        WeightedRoundRobin selectedWRR = null;
        
        for (DivideUpstream upstream : upstreamList) {
            String rKey = upstream.getUpstreamUrl();
            WeightedRoundRobin weightedRoundRobin = map.get(rKey);
            int weight = getWeight(upstream);
            //如果 weightedRoundRobin 为 null,则实例化一个新的 WeightedRoundRobin,放入 map 中
            if (weightedRoundRobin == null) {
                weightedRoundRobin = new WeightedRoundRobin();
                weightedRoundRobin.setWeight(weight);
                map.putIfAbsent(rKey, weightedRoundRobin);
            }
            if (weight != weightedRoundRobin.getWeight()) {
                // 如果权重不一致,则更新
                weightedRoundRobin.setWeight(weight);
            }
            //将当前权重加到 current 中
            long cur = weightedRoundRobin.increaseCurrent();
            //设置最后更新时间
            weightedRoundRobin.setLastUpdate(now);
            //如果两个权重相等,则第一个被遍历的会被选中
            if (cur > maxCurrent) {
                // 如果当前值大于最大值,则更新最大值
                maxCurrent = cur;
                // 确定当前 upstream 为被选中的调用者
                selectedInvoker = upstream;
                //被选择的加权轮询调度
                selectedWRR = weightedRoundRobin;
            }
            //总权重数汇总
            totalWeight += weight;
        }
        //如果更新锁未被持有且 upstreamList 大小与 map 不一致且 加锁成功
        if (!updateLock.get() && upstreamList.size() != map.size() && updateLock.compareAndSet(false, true)) {
            try {
                // 复制一份 map,然后遍历 newMap 并移除 当前时间减去最后更新时间大于 60秒的元素
                ConcurrentMap<String, WeightedRoundRobin> newMap = new ConcurrentHashMap<>(map);
                newMap.entrySet().removeIf(item -> now - item.getValue().getLastUpdate() > recyclePeriod);
                // 再将 key 指向新的 newMap
                methodWeightMap.put(key, newMap);
            } finally {
            	//最后施放锁
                updateLock.set(false);
            }
        }
        if (selectedInvoker != null) {
        	//被选中的 weighted round robin 的 current 减去总节点权重分
            selectedWRR.sel(totalWeight);
            return selectedInvoker;
        }
        // should not happen here
        return upstreamList.get(0);
    }

}

光看代码,逻辑还是有点不清晰,假设我们有三个节点ABC,权重分别是 5,3,2,在7次请求的情况下:

请求次数请求前weightcurrent选中节点请求后weight
1A=5,B=3,C=25AA=0,B=6,C=4
2A=0,B=6,C=46BA=5,B=2,C=6
3A=5,B=2,C=66CA=10,B=5,C=-1
4A=10,B=5,C=-110AA=6,B=8,C=1
5A=6,B=8,C=18BA=11,B=1,C=3
6A=11,B=1,C=311AA=7,B=4,C=5
7A=7,B=4,C=57AA=-2,B=7,C=7

我们可以看到第 7 次 请求后的 权重 B 和 C 都是7,如何分配呢?答案就在源码中:

if (cur > maxCurrent) {
    maxCurrent = cur;
    selectedInvoker = upstream;
    selectedWRR = weightedRoundRobin;
}

第一个被遍历的 upstream 会被选中,所以下一次请求将会转发到 B 节点上。

总结

RoundRobinLoadBalance 在设计上跟 Nginx 是类似的,我们可以理解为工作效率越高的程序员接的活越多,责任也越大,因为他在领导心中的权重最高。🤣 加锁使用 AtomicBoolean 实现,有效降低了锁的开销。