分布式系统(八)

那么,具体某一个请求到底应该是由哪个服务实例来响应最为合适呢?这个话题看上去很简单,实际上却有点复杂,涉及到服务请求的路由机制

而在分布式系统中,上一讲中引入的负载均衡就是最常见也是最具代表性的一种路由机制。为了对请求进行合理的分发,我们需要提供一组负载均衡算法,那么常见的负载均衡算法有哪些?它们又应该如何实现呢?

问题背景

这些扩展性话题才是面试官想要真正考查的知识点,而对于这些知识点的掌握程度也体现了你和其他候选人之间的水平差异

我们可以沿着面试官的考查思路,梳理一组与负载均衡算法相关的一组常见面试题,包括:

  • 从你自己所理解的角度出发,你认为负载均衡算法可以分成哪些类型?
  • 如果想要在常见的静态负载均衡算法中嵌入动态特性,你有什么思路?
  • 你能列举常见的负载均衡算法以及它们的特性吗?
  • Dubbo 包含了哪些负载均衡算法?
  • Spring Cloud 内置了哪些负载均衡算法?
  • Dubbo 框架在实现负载均衡机制时提供了哪些优化特性?
  • 一致性哈希算法的实现过程是怎么样的?

可以看到,针对负载均衡算法的提问形式灵活多样。

问题分析

这部分属于理论知识,考查方式比较固定,很难有创新和变化,应对策略上主要以记忆为主

  • 可以基于自己擅长的框架来分析具体的算法实现过程
  • 把负载均衡算法与远程调用过程结合起来一起讨论

技术体系

负载均衡的类型

负载均衡主要包括服务器端负载均衡客户器端负载均衡两大类。我们先来看服务器端负载均衡,它的结构如下图所示:

可以看到,所谓的服务器端负载均衡指的就是在客户端和各个服务实例之间存在一个独立的负载均衡器,所有的请求都将通过这个负载均衡器进行转发并嵌入负载均衡算法。

业界关于这种负载均衡器的实现工具也很多,最常见的就是 Nginx

我们换一种思路,把上图中负载均衡器所具备的功能放到客户端中,那么就诞生了另一种负载均衡机制,即客户端负载均衡。这时候,负载均衡算法的执行流程发生在客户端本地,如下图所示:

客户端负载均衡应用广泛,例如目前主流的微服务架构实现框架 Spring CloudDubbo 等都内置了完整的客户端负载均衡模块。而像老牌的分布式缓存 Memcache 同样也是这一负载均衡策略的典型应用。

负载均衡算法和策略

无论使用哪种负载均衡机制,负载均衡算法决定了最终的请求分发效果。常见的负载均衡算法也可以分成两大类,即静态负载均衡算法动态负载均衡算法

对于静态负载均衡而言,经典的算法包括各种随机(Random)轮询(Round Robin)算法。

随机算法

随机算法是最简单也是最常用的负载均衡算法之一,该算法就是使用一个随机数来决定具体的目标服务实例。

假设我们持有一个保存所有服务的 serverList 列表,那么只用 JDK 中自带的 Random 工具类就可以实现一个基本的随机算法,如下所示:

1
2
3
java.util.Random random = new java.util.Random();
int randomPosition = random.nextInt(serverList.size());
return serverList.get(randomPosition);

随机算法足够简单,但有时候并不能满足我们的需求。例如,如果在集群中存在一些性能有差异的服务器,为了充分利用那些高性能的服务器,可以提升这些服务器的访问权重,这时候就可以引入用加权随机(Weight Random)算法

假设存在一个 serverWeightMap 保存着服务器地址与权重之间的对应关系,类似 (“192.168.10.100”, 1)、(“192.168.10.105”, 3) 这样的结构,那么实现加权随机的一种简单策略就是构建一个新的 serverList 列表,并根据服务权重的数量来添加重复数量的服务提供者地址(这样权重越高的服务被选中的概率就会越大),然后再使用随机算法进行选择,示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Set<String> keySet = serverWeightMap.keySet();
Iterator<String> iterator = keySet.iterator();
List<String> serverList = new ArrayList<String>();
while (iterator.hasNext()){
String server = iterator.next();
int weight = serverWeightMap.get(server);
for (int i = 0; i < weight; i++) {
serverList.add(server);
}
}

java.util.Random random = new java.util.Random();
int randomPosition = random.nextInt(serverList.size());
return serverList.get(randomPosition);

轮询算法

所谓轮询,就是一个循环访问所有服务器列表的过程。在循环过程中,如果发现某台服务器可用就把请求分发给它。如果一个循环下来还是没有找到合适的服务器,那么就继续进行新的一轮循环,直到找到目标服务器。

轮询算法的一种简单的实现方法如下所示:

1
2
3
4
5
6
7
8
9
10
String server = null;
synchronized(position) {
if (position > serverList.size()) {
position = 0;
}

server = serverList.get(position);
position++;
}
return server;

类似加权随机算法,我们也可以实现加权轮循(Weighted Round Robin)算法

对于负载均衡算法而言,权重本质上也是一个可以动态变化的参数,所以也可以基于权重构建动态负载均衡算法。

当然,典型的动态负载均衡算法实现过程都没有那么简单,常见的包括最少连接数算法源 IP 哈希算法服务调用时延算法等。

最少连接数算法

所谓最少连接数(Least Connection)算法,就是根据当前服务器的连接数量来决定目标服务器。

在系统运行过程中,连接数显然是一个不断在变化的参数,我们可以选择那些连接数较少的服务来接收新的请求。

因此,当执行分发策略时,我们会根据在某一个特定的时间点下服务实例的最新连接数来判断是否执行客户端请求。而在下一个时间点时,服务实例的连接数一般都会发生相应的变化,对应的请求处理也会做相应的调整。

源 IP 哈希算法

在日常开发过程中,有时候我们希望实现这样一种分发效果:

来自同一个客户端的请求总是发送到某一个固定的服务器,这时候就可以引入源 IP 哈希(Source IP Hash)算法,该算法会根据请求的 IP 地址来决定目标服务器。只要源 IP 地址不变,那么负载均衡的结果也是固定的。

源 IP 哈希算法一种实现方案如下所示:

1
2
3
4
5
String remoteIp = getRemoteIp();
int hashCode = remoteIp.hashCode();
int serverListSize = serverList.size();
int serverPos = hashCode % serverListSize;
return serverList.get(serverPos);

服务调用时延算法

服务调用时延(Service Invoke Delay)算法的动态性来自于服务的调用延迟。

针对每一台服务器,我们都可以计算一段时间内所有请求的服务调用时延。有了这个参数之后,就可以执行各种计算策略进一步决定选择那一台服务器来对请求做出响应。

针对前面介绍的各个负载均衡算法,我们可以通过如下所示的一张思维导图来进行总结:

源码解析

Dubbo 负载均衡整体结构

在 Dubbo 中,专门提供了一个 LoadBalance 接口来提供负载均衡能力,如下所示:

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

可以看到 LoadBalance 接口只有一个方法,即在一批 Invoker 列表中选择其中一个 Invoker 进行返回。

这里,我们可以从该接口上的 @SPI(RandomLoadBalance.NAME) 注解中看到 Dubbo 默认加载的是 RandomLoadBalance 类,即随机负载均衡。除了 RandomLoadBalance 类之外,Dubbo 还提供了其他多种负载均衡策略,整体的类层结构如下图所示:

从上图中,我们看到存在一个 AbstractLoadBalance 抽象类,它实现了 LoadBalanceselect 方法,如下所示:

1
2
3
4
5
6
7
public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) {
if (invokers == null || invokers.size() == 0)
return null;
if (invokers.size() == 1)
return invokers.get(0);
return doSelect(invokers, url, invocation);
}

显然,从设计模式上讲,这里采用的是经典的模板方法。通过模板方法,具体负载均衡算法由 AbstractLoadBalance 子类中的 doSelect 方法进行实现。

同时,我们在 AbstractLoadBalance 中还看到了如下所示的 getWeight 方法。从方法命名上看,该方法用来计算权重,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected int getWeight(Invoker<?> invoker, Invocation invocation) {
//从URL中获取权重
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);
//从URL中获取预热时间
int warmup = invoker.getUrl().getParameter(Constants.WARMUP_KEY, Constants.DEFAULT_WARMUP);
if (uptime > 0 && uptime < warmup) {
//计算预热权重
weight = calculateWarmupWeight(uptime, warmup, weight);
}
}
}
return weight;
}

可以看到代表权重的 weight 参数是从 URL 中传入的。

而基于上述代码,我们发现这里的处理逻辑显然并没有那么简单,而是用到了所谓的预热(Warmup)机制。

我们看到 Dubbo 首先会获取服务启动时间,然后再与预热时间进行比较。如果启动时间小于预热时间,则会调用 calculateWarmupWeight 方法来重新计算预热权重。

从代码逻辑上看,预热权重最小为 1,并在预热时间内随启动时间逐渐增加。

这样设计的原因在于:JVM 从启动成功到处于最佳状态需要一段时间,在这段时间内虽然服务可以接收请求,但显然不应该接收过多请求。所以,Dubbo 通过预热机制确保在预热时间内该服务受到一定的保护,直到其处于最佳运行状态

预热机制在 Dubbo 的多个负载均衡算法中都得到了应用,是一种实现上的技巧,为我们设计类似的应用场景提供了一定的参考价值。

Dubbo 负载均衡算法实现示例

接下来,就让我们看看 Dubbo 中使用预热机制的场景和方式。我们重点介绍 LeastActiveLoadBalance 类,这是一种典型的动态负载均衡算法。

LeastActiveLoadBalance 继承自 AbstractLoadBalance 类,并实现了如下所示的 doSelect 方法。该方法比较长,我们对代码进行了部分裁剪。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
//获取所有的invoker并执行计算
for (int i = 0; i < length; i++) {
Invoker<T> invoker = invokers.get(i);
// 通过RpcStatus获取当前这个invoker并发数
int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive();
// 通过预热机制计算权重值
int afterWarmup = getWeight(invoker, invocation);

// 发现最小的活跃数,重新开始计算
if (leastActive == -1 || active < leastActive) {
// 记录leastActive 为当前的活跃数,并重置最小计数,基于当前最小计数重新计数

} else if (active == leastActive) {
// 当前invoker的活跃数与最小活跃数相等,则记录权重

}
}

//如果我们恰好有一个调用程序具有最少的活动值,那么直接返回这个调用程序。
if (leastCount == 1) {
return invokers.get(leastIndexs[0]);
}

// 如果每个invoker有不同的权重
if (!sameWeight && totalWeight > 0) {
// 在总权重范围内随机一个值
int offsetWeight = random.nextInt(totalWeight) + 1;
for (int i = 0; i < leastCount; i++) {
// 获取i位置的那个最小活跃 在invokers 里面的位置信息
int leastIndex = leastIndexs[i];
offsetWeight -= getWeight(invokers.get(leastIndex), invocation);
if (offsetWeight <= 0)
// 返回这个位置的这个
return invokers.get(leastIndex);
}
}
// 具有相同权重或者是 总权重=0 的话就均匀返回
return invokers.get(leastIndexs[random.nextInt(leastCount)]);
}

在上述代码中,我们对关键流程添加了注释。

该算法首先会对所有的 Invoker 进行轮询,找出所有活跃数最小的集合。

如果这个集合的数量只有 1,那么就可以直接返回当前的 Invoker。

如果集合中所有 Invoker 的权重相同,那么随机返回一个。

而如果这些条件都不满足,那么就获取一个具有最小活跃数的 Invoker。

解题要点

首先,我们要介绍负载均衡算法的分类,即静态负载均衡动态负载均衡

其次,我们需要列举常见负载均衡算法并基于自己的理解分析这些算法的功能特性

针对随机、轮询等静态负载均衡算法,我们的回答思路是给出基本的设计策略和所能达到的效果,以及如何将这些静态负载均衡算法转换为动态负载均衡算法的实现方法。

因为静态负载均衡算法相对都比较简单,所以这部分内容不是回答的重点。我们需要详细介绍的是一致性哈希、最少连接数等动态负载均衡的实现原理。

在回答上,就需要提到 Dubbo 中的“预热”机制,该机制的目的是确保服务在刚启动的一段时间内得到保护,避免因为负载均衡导致出现不可用的情况