在上一篇文章《Soul网关源码学习(10)- 插件模板 AbstractSoulPlugin》 中,我们分析了插件的模板抽象类 AbstractSoulPlugin,AbstractSoulPlugin #executor 方法主要抽象了 PluginData、SelectorData、Rule 的获取和筛选流程,并将它们作为参数传递给子类方法 doExecutor 进行使用。
从这一章节开始,我们对这些 AbstractSoulPlugin 的具体子类实现逐个进行深入的分析。在上上上篇文章《Soul网关源码学习(9)- 请求解析 GlobalPlugin》中,我们了解了 GlobalHandler 插件主要功能就是对请求进行解析,并且将解析的信息存储到请求上下文中,如果暂时把一些辅助的功能插件忽略掉,那一步就是负载均衡处理了。这里我们先分析 Http 代理的负载均衡策略,它们主要是由两个插件实现:DividePlugin 和 SpringCloudPlugin。
离散的 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 方法,实现逻辑是非常简单的主要步骤就是三个:
第一步,如果暂时不考虑数据同步问题(即是如何把 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);
}
例如:上面第一次随机,随机数为80,依次递减 : 80 - 60 - 10 -30 < 0,因此随机数的右边界落在 30% 的部分,因此选择第三个区间;第二次随机:50 - 60 < 0,右边界落在第一个区间,因此选择第一个区间。简单来说就是箭头在哪里就选择哪里!
一致性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 进行存取?
加权轮询就是一种改进的轮询算法,反过来也可以认为,轮询算法是权值相同的加权轮询,目的是在轮询中让权重高的节点,处理更加多的请求。 Soul 使用的一种叫做平滑的加权轮询(smooth weighted round-robin balancing)的算法,它生成的序列更加均匀,其代码实现在 RoundRobinLoadBalance#doSelector 方法。
我们先来看一下该算法的实现原理:
这里引用一个博客评论,我觉得用割韭菜来比喻真的非常形象,权重大的长的快,被割之后,可能有些矮的还没被割,它又长得比别人高了:
平滑加权轮询那个算法可以这样想: (注意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 和 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行代码实现
负载均衡之加权轮询算法
负载均衡的多种算法总结