负载均衡算法有哪些?如何实现?

120 阅读12分钟

在分布式环境下,我们将多个服务实例集中在一起,每一次请求都可以由集群中的某一个服务实例进行响应。那么具体某一个请求到底应该是由哪个服务实例来响应最为合适呢?这个话题看上去很简单,实际上却有点复杂,涉及到服务请求的路由机制。而在分布式系统中,负载均衡就是最常见也是最具代表性的一种路由机制。为了对请求进行合理的分发,我们需要提供一组负载均衡算法,那么常见的负载均衡算法有哪些?它们又应该如何实现呢?我们先来看这些问题背后的示意图,如下图所示。

  WPS图片(1).png

显然,我们需要明确从服务B发出的请求最终会由服务A所提供的具体哪一个实例来进行处理,这是负载均衡算法的作用。上图所示的基本架构虽然简单,但围绕该图,我们可以延伸出一系列的扩展性话题,包括:

l 负载均衡算法应该位于上图中的哪个位置?是在服务B中,还是应该有专门存储这些算法的其他组件?

l 服务A的某个实例如果出现了问题,服务B还能对它进行访问吗?

l 服务A各个实例的当前负载肯定不一样,服务B基于什么原则选择最合适的目标实例呢?

l 如何防止对服务A实例的访问过程是不均衡的,从而导致服务A的某个实例压力太大?

 

对于上述问题而言,笔者认为回答的思路还是比较明确的,主要就是对主流负载均衡算法需要有全面的了解。然后,无论你现在使用的是哪种分布式服务框架,都需要把它与常见的负载均衡算法结合起来进行掌握。

 

负载均衡技术

在进行具体的工具介绍和源码分析之前,我们首先对负载均衡的类型以及相应的基本策略做简要介绍。

负载均衡的类型

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

  WPS图片2.png

可以看到,所谓的服务器端负载均衡指的就是在客户端和各个服务实例之间存在一个独立的负载均衡器,所有的请求都将通过这个负载均衡器进行转发并嵌入负载均衡算法。业界关于这种负载均衡器的实现工具也很多,最常见的就是Nginx。 

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

WPS图片3.png

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

 

我们来对上述这两种负载均衡机制做一个对比,会发现客户端负载均衡不需要架设专门的服务器组件,负载均衡算法的执行过程被分摊到了每个客户端内部,不会造成明显的单点瓶颈。当然,因为每个客户端自己都需要维护一套服务实例信息,所以需要确保服务实例的变更能够及时通知到各个客户端。

 

负载均衡算法和策略

 

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

 

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

l 随机算法

随机算法是最简单也是最常用的负载均衡算法之一,该算法就是使用一个随机数来决定具体的目标服务实例。假设我们持有一个保存所有服务的serverList列表,那么只用JDK中自带的Random工具类就可以实现一个基本的随机算法,如下所示。

 

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列表,并根据服务权重的数量来添加重复数量的服务提供者地址(这样权重越高的服务被选中的概率就会越大),然后再使用随机算法进行选择,示例代码如下所示。

 

Set keySet = serverWeightMap.keySet();

Iterator iterator = keySet.iterator();

List serverList = new ArrayList();

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

 

l 轮询算法

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

 

String server = null;

synchronized(position) {

if (position > serverList.size()) {

position = 0;

}

server = serverList.get(position);

position++;

}

return server;

 

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

 

对于负载均衡算法而言,权重本质上也是一个可以动态变化的参数,所以也可以基于权重构建动态负载均衡算法。当然,典型的动态负载均衡算法实现过程都没有那么简单,常见的包括最少连接数算法、源IP哈希算法、服务调用时延算法等。

 

l 最少连接数算法

所谓最少连接数(Least Connection)算法,就是根据当前服务器的连接数量来决定目标服务器。在系统运行过程中,连接数显然是一个不断在变化的参数,我们可以选择那些连接数较少的服务来接收新的请求。因此,当执行分发策略时,我们会根据在某一个特定的时间点下服务实例的最新连接数来判断是否执行客户端请求。而在下一个时间点时,服务实例的连接数一般都会发生相应的变化,对应的请求处理也会做相应的调整。

 

l 源IP哈希算法

在日常开发过程中,有时候我们希望实现这样一种分发效果:来自同一个客户端的请求总是发送到某一个固定的服务器,这时候就可以引入源IP哈希(Source IP Hash)算法,该算法会根据请求的IP地址来决定目标服务器。只要源IP地址不变,那么负载均衡的结果也是固定的。源IP哈希算法一种实现方案如下所示。

 

String remoteIp = getRemoteIp();

int hashCode = remoteIp.hashCode();

int serverListSize = serverList.size();

int serverPos = hashCode % serverListSize;

return serverList.get(serverPos);

 

l 服务调用时延算法

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

 

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

WPS图片4.png

Dubbo中的负载均衡

 

有了理论知识,我们接下来讨论负载均衡的实际应用。诸如最少连接数算法、服务调用时延算法等动态负载均衡算法在设计和实现上都比较复杂,我们重点来看一下主流开源框架中对它们的实现机制。在接下来的内容,我们以Dubbo框架为例展开讨论。

Dubbo负载均衡整体结构

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

 

@SPI(RandomLoadBalance.NAME)

public interface LoadBalance {

    @Adaptive("loadbalance")

     Invoker select(List<Invoker> invokers, URL url, Invocation invocation) throws RpcException;

}

 

可以看到LoadBalance接口只有一个方法,即在一批Invoker列表中选择其中一个Invoker进行返回。这里,我们可以从该接口上的@SPI(RandomLoadBalance.NAME)注解中看到Dubbo默认加载的是RandomLoadBalance类,即随机负载均衡。除了RandomLoadBalance类之外,Dubbo还提供了其他多种负载均衡策略,整体的类层结构如下图所示。

  WPS图片5.png

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

 

public Invoker select(List<Invoker> 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方法。从方法命名上看,该方法用来计算权重,如下所示。

 

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方法。该方法比较长,我们对代码进行了部分裁剪。

 

@Override

protected Invoker doSelect(List<Invoker> invokers, URL url, Invocation invocation) {

        //获取所有的invoker并执行计算

        for (int i = 0; i < length; i++) {

            Invoker 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提供了SPI机制,允许开发人员自定义负载均衡算法,我们会在第X讲中介绍到微内核架构和SPI机制时给出一个简单的案例。你也可以根据自己的需要尝试实现新的负载均衡算法。