Soul网关源码学习(11)- Http代理的负载均衡:DividePlugin 和 SpringCloudPlugin

前言

在上一篇文章《Soul网关源码学习(10)- 插件模板 AbstractSoulPlugin》 中,我们分析了插件的模板抽象类 AbstractSoulPlugin,AbstractSoulPlugin #executor 方法主要抽象了 PluginData、SelectorData、Rule 的获取和筛选流程,并将它们作为参数传递给子类方法 doExecutor 进行使用。

从这一章节开始,我们对这些 AbstractSoulPlugin 的具体子类实现逐个进行深入的分析。在上上上篇文章《Soul网关源码学习(9)- 请求解析 GlobalPlugin》中,我们了解了 GlobalHandler 插件主要功能就是对请求进行解析,并且将解析的信息存储到请求上下文中,如果暂时把一些辅助的功能插件忽略掉,那一步就是负载均衡处理了。这里我们先分析 Http 代理的负载均衡策略,它们主要是由两个插件实现:DividePlugin 和 SpringCloudPlugin。

DevidePlugin

离散的 Http 服务器接入 Soul 后,不单单会把 Soul 网关作为代理服务器,同时也相当于把 Soul Admin 作为了自己的注册中心,Soul 网关服务通过 Admin 获取这些注册和配置信息,实现请求的负载均衡,而负责实现这一功能的便是 DevidePlugin。

DevidePlugin 负载均衡的实现由两个关键信息:一个是负载均衡主机列表;一个是基于接口的负载均衡策略。我先来看看这两个信息在控制台页面的展现:
负载均衡的主机列表:
在这里插入图片描述

负载均衡策略:

在这里插入图片描述

接下来我们来看一下 DevidePlugin 代码实现的主要流程:

 protected Mono<Void> doExecute(final ServerWebExchange exchange, final SoulPluginChain chain, final SelectorData selector, final RuleData rule) {
		...
		//获取负载均衡的主机列表
        final List<DivideUpstream> upstreamList = UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selector.getId());
 		...
        final String ip = Objects.requireNonNull(exchange.getRequest().getRemoteAddress()).getAddress().getHostAddress();
        //通过负载均衡策略从上面的主机列表中选择出一个目标主机
        DivideUpstream divideUpstream = LoadBalanceUtils.selector(upstreamList, ruleHandle.getLoadBalance(), ip);
		...
        // 通过目标主机信息解析出最终地址
        String domain = buildDomain(divideUpstream);
        String realURL = buildRealURL(domain, soulContext, exchange);
		...
        return chain.execute(exchange);
    }

如果只看 doExecute 方法,实现逻辑是非常简单的主要步骤就是三个:

  • 获取负载均衡的目标主机列表。
  • 通过负载均衡策略(存储在 Rule Data中)选择最终的目标主机。
  • 通过主机信息解析出最终的目标地址,存进请求上下文。

第一步,如果暂时不考虑数据同步问题(即是如何把 admin 的数据同步到本地 JVM,这个后面文章会重点分析),就只是简单从本地存储的 Map 中,根据 selector id 获取相应的数据,同时第三步也很简单,地址解析什么的我相信还是难不倒小伙伴们的,所以,这里主要详细分析第二步,负载均衡的策略。

我们首先先来看一下负载均衡的入口方法:

    public static DivideUpstream selector(final List<DivideUpstream> upstreamList, final String algorithm, final String ip) {
    	//通过 SPI 加载具体的 负载均衡策略实现
        LoadBalance loadBalance = ExtensionLoader.getExtensionLoader(LoadBalance.class).getJoin(algorithm);
        //调用负载均衡处理方法
        return loadBalance.select(upstreamList, ip);
    }

第一步,通过 SPI 加载相应的负载均衡策略实现对象,关于 Soul 是如何运用 SPI 的,后面再通过专门的文章去分析,这里我们先关注负载均衡策略的实现逻辑上。
第二步,我们跟踪进去 select 方法,发现这是抽象类 AbstractLoadBalance 的方法,而具体的实现则委派给了子类的 doSelect 方法。
AbstractLoadBalance 的子类一共有三个:RandomLoadBalance(随机)、HashLoadBalance(一致性 hash)、RoundRobinLoadBalance(加权轮询),接下来我们就分别对它们进行分析。

负载均衡策略 - 加权随机

顾名思义,就是把请求随机分给一个主机,实现简单,不过 soul 的随机负载是添加权重的,会比直接的随机选择稍稍复杂一点。

    @Override
    public DivideUpstream doSelect(final List<DivideUpstream> upstreamList, final String ip) {
    	//计算总的权重
        int totalWeight = calculateTotalWeight(upstreamList);
        // 是否每个权重都相同
        boolean sameWeight = isAllUpStreamSameWeight(upstreamList);
        if (totalWeight > 0 && !sameWeight) {
        	//如果权重不相同,则进行加权的随机选择
            return random(totalWeight, upstreamList);
        }
        //如果每个权重都相同,就直接通过 Random#nextInt 随机选择一个
        return random(upstreamList);
    }

上面代码逻辑很简单:如果权重都相同则直接通过 Random#nextInt 随机生成主机列表下标,否则使用加权随机算法,这里我们主要分析一下后者:

    private DivideUpstream random(final int totalWeight, final List<DivideUpstream> upstreamList) {
        // 上界为总权重的随机数
        int offset = RANDOM.nextInt(totalWeight);
        // 遍历主机列表,递减当前主机的权重,直到 < 0
        for (DivideUpstream divideUpstream : upstreamList) {
            offset -= getWeight(divideUpstream);
            if (offset < 0) {
                return divideUpstream;
            }
        }
        return upstreamList.get(0);
    }

在这里插入图片描述
结合上面的代码和图片简单分析一下加权随机算法:

  • 每次使用总权重作为上界,生成一个随机数。
  • 从左到右遍历节点列表,上面的随机数递减当前节点权重,直到小于0。
  • 当随机数小于0时候,就表示右边界落在当前区间,即选择当前区间。

例如:上面第一次随机,随机数为80,依次递减 : 80 - 60 - 10 -30 < 0,因此随机数的右边界落在 30% 的部分,因此选择第三个区间;第二次随机:50 - 60 < 0,右边界落在第一个区间,因此选择第一个区间。简单来说就是箭头在哪里就选择哪里!

负载均衡策略 - 一致性 Hash

一致性Hashing在分布式系统中经常会被用到, 用于尽可能地降低节点变动带来的数据迁移开销。如果小伙伴们想更加详细地了解一致性 Hash 的原理和实现,这里有一篇不错的博文,本文就不作过多原理性的介绍了,我们主要是了解 Soul 的 一致性 Hash 实现。

Soul 实现一致性 hash 策略的插件是 HashLoadBalance,其同样继承于AbstractLoadBalance ,所以我们直接跳到其被委派的子类方法 doSelector:

    public DivideUpstream doSelect(final List<DivideUpstream> upstreamList, final String ip) {
    	//使用跳表实现、线程安全的、有序的 HashMap
        final ConcurrentSkipListMap<Long, DivideUpstream> treeMap = new ConcurrentSkipListMap<>();
        for (DivideUpstream address : upstreamList) {
        	//虚拟节点填充 这里每个主机(真实 + 虚拟) 都有5个节点
        	//真实和虚拟的节点具有相同的地址
            for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
            	//使用主机地址 + 节点序号 进行hash 计算
                long addressHash = hash("SOUL-" + address.getUpstreamUrl() + "-HASH-" + i);
                treeMap.put(addressHash, address);
            }
        }
        //计算当前请求地址 hash 值
        long hash = hash(String.valueOf(ip));
        // 如果 请求地址的Hash值 右边存在节点,返回右边第一个
        // treeMap.tailMap(hash) 会返回所有(key >= hash)的映射集合,同时是有序的。
        SortedMap<Long, DivideUpstream> lastRing = treeMap.tailMap(hash);
        if (!lastRing.isEmpty()) {
        	// 这里也就是取 url hash 值右边的第一个节点
            return lastRing.get(lastRing.firstKey());
        }
        // 否则返回第一个
        return treeMap.firstEntry().getValue();
    }

除了代码我们也可以通过下面的图来简单了解一下一致性 Hash 策略的原理:
在这里插入图片描述
结合上面代码的注释和图解,我详细小伙伴理解起来也应该没什么问题了。那问题又来了,为什么不直接使用简单的 hash呢,比如直接使用 HashMap 进行存取?

  • 使用简单的 Hash 存取,如果有节点宕机或者增加,url 的 hash 映射会改变,造成大量连接迁移。不过呢 Soul 是 Http 网关,我觉得应该不存在这方面问题。
  • 一致性 hash 通过虚拟节点的方式更加容易实现不同权重的分配,比如权重高的节点可以虚拟更加多的节点,让其均匀的分布在圈上。

负载均衡策略 - 加权轮询

加权轮询就是一种改进的轮询算法,反过来也可以认为,轮询算法是权值相同的加权轮询,目的是在轮询中让权重高的节点,处理更加多的请求。 Soul 使用的一种叫做平滑的加权轮询(smooth weighted round-robin balancing)的算法,它生成的序列更加均匀,其代码实现在 RoundRobinLoadBalance#doSelector 方法。
我们先来看一下该算法的实现原理:

  • 每个服务器都有两个权重变量:
    • weight,配置文件中指定的该服务器的权重,这个值是固定不变的。
    • current_weight,服务器目前的权重。一开始为0,之后会动态调整。
  • 每次当请求到来,选取服务器时,会遍历数组中所有服务器。
    • 对于每个服务器,让它的 current_weight 增加它的 weight(每一轮每颗韭菜都会长一节,权重大的长得快)。
    • 同时累加所有服务器的 weight,并保存为 total。
  • 遍历完所有服务器之后,如果该服务器的 current_weight 是最大的,就选择这个服务器处理本次请求。
  • 最后把该服务器的 current_weight 减去 total(割韭菜)。

这里引用一个博客评论,我觉得用割韭菜来比喻真的非常形象,权重大的长的快,被割之后,可能有些矮的还没被割,它又长得比别人高了:

平滑加权轮询那个算法可以这样想: (注意total是不变的,因为每次减掉一个节点total后,每个节点都会加一次自身权重,所以总共又增加了一个total) 每次选出节点后,都是裁掉这个节点权重一个total; 自身权重越大的节点增长越快,那么比其他节点大的几率就越高,被选中的机会就越多;而自身权重比较低的,自身current_weight增长比较慢,所以比其他大的几率小,被选中的机会就少。(挨的刀子是一样大的,但是哪棵韭菜长得快,哪棵就更容易挨刀子;东北大米年收一次,海南能收3次)

分析了原理我们来看一下代码的实现:

 @Override
    public DivideUpstream doSelect(final List<DivideUpstream> upstreamList, final String ip) {
        String key = upstreamList.get(0).getUpstreamUrl();
        ConcurrentMap<String, WeightedRoundRobin> map = methodWeightMap.get(key);
		...
        for (DivideUpstream upstream : upstreamList) {
            String rKey = upstream.getUpstreamUrl();
            WeightedRoundRobin weightedRoundRobin = map.get(rKey);
            int weight = getWeight(upstream);
            // 下面省略的代码是更新 weightedRoundRobin 对象的最新的权重值,让其和最新的同步数据保持一致,简单来说就是个set 操作
            ...
            // 让当前 weightedRoundRobin对象 的 current_weight 增加自身的 weight
            long cur = weightedRoundRobin.increaseCurrent();
            //更新最新修改时间
            weightedRoundRobin.setLastUpdate(now);
            // 选中 current_weight 最大的服务器
            if (cur > maxCurrent) {
                maxCurrent = cur;
                //选择的服务器
                selectedInvoker = upstream;
                //选择的服务器对应的 weightedRoundRobin 对象
                selectedWRR = weightedRoundRobin;
            }
            //累加所有服务器的weight,并保存为total
            totalWeight += weight;
        }
        //通过 CAS 更新 WeightedRoundRobin 集合
        //主要是去除 长时间没被轮询的 WeightedRoundRobin 对象,也就是下线的服务器
        if (!updateLock.get() && upstreamList.size() != map.size() && updateLock.compareAndSet(false, true)) {
			...
        }
        if (selectedInvoker != null) {
            //服务器的 WeightedRoundRobin 对象的 current_weight 减去 total
            selectedWRR.sel(totalWeight);
            //返回选中的服务器
            return selectedInvoker;
        }
        //否则返回主机列表第一个
        return upstreamList.get(0);
    }

到这里关于 DividePlugin 的功能和实现,相信小伙伴应该是理解清楚了,毕竟其就做了三件事情:获取主机列表,进行负载均衡,解析真实地址。第一和第三步,代码很简单,小伙伴自己读一下即可,而第二步涉及的负载均衡策略,我们也已经分析完毕,因此 DividePlugin 的分析也告一段落了。

SpringCloudPlugin

接下来我们再来看一下 SpringCloudPlugin 的负载均衡处理,这里为什么将 SpringCloudPlugin 和 DividePlugin 放在一起说呢?因为,Spring Cloud 服务代理,本身也就是 Http 服务代理,唯一不一样的就是 Spring Cloud 服务一般都处在一个微服务集群中,有着自己的注册中心和负载均衡策略,所以相对于DividePlugin 的实现,SpringCloudPlugin 的逻辑就显得非常简单了,主要就做了一件事:通过 Spring cloud 的 LoadBalancerClient 获取负载均衡主机地址,并且存储到请求上下文中。因为,负载均衡有 Spring cloud 集群负责,所以这里不需要用到 soul 自己的负载均衡策略,最后我们来看一下其 doExecutor 方法:

 @Override
    protected Mono<Void> doExecute(final ServerWebExchange exchange, final SoulPluginChain chain, final SelectorData selector, final RuleData rule) {
		...
        final SpringCloudRuleHandle ruleHandle = GsonUtils.getInstance().fromJson(rule.getHandle(), SpringCloudRuleHandle.class);
        final SpringCloudSelectorHandle selectorHandle = GsonUtils.getInstance().fromJson(selector.getHandle(), SpringCloudSelectorHandle.class);
        //下面是一些数据有效性检测
        ...
        // 通过server id 获取服务实例
        final ServiceInstance serviceInstance = loadBalancer.choose(selectorHandle.getServiceId());
		// 获取 服务真实地址
        final URI uri = loadBalancer.reconstructURI(serviceInstance, URI.create(soulContext.getRealUrl()));
        String realURL = buildRealURL(uri.toASCIIString(), soulContext.getHttpMethod(), exchange.getRequest().getURI().getQuery());
        exchange.getAttributes().put(Constants.HTTP_URL, realURL);
		...
        return chain.execute(exchange);
    }

总结

到这里关于 Http 负载均衡方面的源码解析就到这里,往下就是 Http 真正发起对目标服务的请求了,然后等待其返回,最后响应客户端,那将是我们下一章节要学习的内容。

参考资料

深入一致性哈希(Consistent Hashing)算法原理,并附100行代码实现
负载均衡之加权轮询算法
负载均衡的多种算法总结

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值