负载均衡

我们都知道在微服务架构中,微服务之间总是需要互相调用,以此来实现一些组合业务的需求。例如组装订单详情数据,由于订单详情里有用户信息,所以订单服务就得调用用户服务来获取用户信息。要实现远程调用就需要发送网络请求,而每个微服务都可能会存在有多个实例分布在不同的机器上,那么当一个微服务调用另一个微服务的时候就需要将请求均匀的分发到各个实例上,以此避免某些实例负载过高,某些实例又太空闲,所以在这种场景必须要有负载均衡器。

目前实现负载均衡主要的两种方式:

**1、**服务端负载均衡;例如最经典的使用Nginx做负载均衡器。用户的请求先发送到Nginx,然后再由Nginx通过配置好的负载均衡算法将请求分发到各个实例上,由于需要作为一个服务部署在服务端,所以该种方式称为服务端负载均衡。如图:

**2、**客户端侧负载均衡;之所以称为客户端侧负载均衡,是因为这种负载均衡方式是由发送请求的客户端来实现的,也是目前微服务架构中用于均衡服务之间调用请求的常用负载均衡方式。因为采用这种方式的话服务之间可以直接进行调用,无需再通过一个专门的负载均衡器,这样能够提高一定的性能以及高可用性。以微服务A调用微服务B举例,简单来说就是微服务A先通过服务发现组件获取微服务B所有实例的调用地址,然后通过本地实现的负载均衡算法选取出其中一个调用地址进行请求。如图:

我们来通过Spring Cloud提供的DiscoveryClient写一个非常简单的客户端侧负载均衡器,借此直观的了解一下该种负载均衡器的工作流程,该示例中采用的负载均衡策略为随机,代码如下:

package com.zj.node.contentcenter.discovery;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.discovery.DiscoveryClient;

import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;

/**
 * 客户端侧负载均衡器
 *
 * @author 01
 * @date 2019-07-26
 **/
public class LoadBalance {

    @Autowired
    private DiscoveryClient discoveryClient;

    /**
     * 随机获取目标微服务的请求地址
     *
     * @return 请求地址
     */
    public String randomTakeUri(String serviceId) {
        // 获取目标微服务的所有实例的请求地址
        List<String> targetUris = discoveryClient.getInstances(serviceId).stream()
                .map(i -> i.getUri().toString())
                .collect(Collectors.toList());
        // 随机获取列表中的uri
        int i = ThreadLocalRandom.current().nextInt(targetUris.size());

        return targetUris.get(i);
    }
}

使用Ribbon实现负载均衡

什么是Ribbon:

  • Ribbon是Netflix开源的客户端侧负载均衡器
  • Ribbon内置了非常丰富的负载均衡策略算法

Ribbon虽然是个主要用于负载均衡的小组件,但是麻雀虽小五脏俱全,Ribbon还是有许多的接口组件的。如下表:

Ribbon默认内置了八种负载均衡策略,若想自定义负载均衡策略则实现上表中提到的IRule接口或AbstractLoadBalancerRule抽象类即可。内置的负载均衡策略如下:

  • 默认的策略规则为ZoneAvoidanceRule

Ribbon主要有两种使用方式,一是使用Feign,Feign内部已经整合了Ribbon,因此如果只是普通使用的话都感知不到Ribbon的存在;二是配合RestTemplate使用,这种方式则需要添加Ribbon依赖和@LoadBalanced注解。

这里主要演示一下第二种使用方式,由于项目中添加的Nacos依赖已包含了Ribbon所以不需要另外添加依赖,首先定义一个RestTemplate,代码如下:

package com.zj.node.contentcenter.configuration;

import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

/**
 * bean 配置类
 *
 * @author 01
 * @date 2019-07-25
 **/
@Configuration
public class BeanConfig {

    @Bean
    @LoadBalanced  // 加上这个注解表示使用Ribbon
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

然后使用RestTemplate调用其他服务的时候,只需要写服务名即可,不需要再写ip地址和端口号。如下示例:

public ShareDTO findById(Integer id) {
    // 获取分享详情
    Share share = shareMapper.selectByPrimaryKey(id);
    // 发布人id
    Integer userId = share.getUserId();
    // 调用用户中心获取用户信息
    UserDTO userDTO = restTemplate.getForObject(
            "http://user-center/users/{id}",  // 只需要写服务名
            UserDTO.class, userId
    );

    ShareDTO shareDTO = objectConvert.toShareDTO(share);
    shareDTO.setWxNickname(userDTO.getWxNickname());

    return shareDTO;
}

如果不太清楚RestTemplate的使用,可以参考如下文章:


自定义Ribbon负载均衡配置

在实际开发中,我们可能会遇到默认的负载均衡策略无法满足需求,从而需要更换其他的负载均衡策略。关于Ribbon负载均衡的配置方式主要有两种,在代码中配置或在配置文件中配置。

Ribbon支持细粒度的配置,例如我希望微服务A在调用微服务B的时候采用随机的负载均衡策略,而在调用微服务C的时候采用默认策略,下面我们就来实现一下这种细粒度的配置。

**1、**首先是通过代码进行配置,编写一个配置类用于实例化指定的负载均衡策略对象:

@Configuration
public class RibbonConfig {

    @Bean
    public IRule ribbonRule(){
	    // 随机的负载均衡策略对象
        return new RandomRule();
    }
}

然后再编写一个用于配置Ribbon客户端的配置类,该配置类的目的是指定在调用user-center时采用RibbonConfig里配置的负载均衡策略,这样就可以达到细粒度配置的效果:

@Configuration
// 该注解用于自定义Ribbon客户端配置,这里声明为属于user-center的配置
@RibbonClient(name = "user-center", configuration = RibbonConfig.class)
public class UserCenterRibbonConfig {
}

需要注意的是RibbonConfig应该定义在主启动类之外,避免被Spring扫描到,不然会产生父子上下文扫描重叠的问题,从而导致各种奇葩的问题。而在Ribbon这里就会导致该配置类被所有的Ribbon客户端共享,即不管调用user-center还是其他微服务都会采用该配置类里定义的负载均衡策略,这样就会变成了一个全局配置了,违背了我们需要细粒度配置的目的。所以需要将其定义在主启动类之外:

关于这个问题可以参考官方文档的描述:

https://cloud.spring.io/spring-cloud-static/Greenwich.SR2/single/spring-cloud.html#_customizing_the_ribbon_client

**2、**使用配置文件进行配置就更简单了,不需要写代码还不会有父子上下文扫描重叠的坑,只需在配置文件中增加如下一段配置就可以实现以上使用代码配置等价的效果:

user-center:
  ribbon:
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule

两种配置方式对比:

  • 关于优先级:细粒度配置文件配置 > 细粒度代码配置 > 全局配置文件配置 > 全局代码配置

最佳实践总结:

  • 尽量使用配置文件配置,配置文件满足不了需求的情况下再考虑使用代码配置
  • 在同一个微服务内尽量保持单一性,例如统一使用配置文件配置,尽量不要两种方式混用,以免增加定位问题的复杂度

以上介绍的是细粒度地针对某个特定Ribbon客户端的配置,下面我们再演示一下如何实现全局配置。很简单,只需要把注解改为@RibbonClients即可,代码如下:

@Configuration
// 该注解用于全局配置
@RibbonClients(defaultConfiguration = RibbonConfig.class)
public class GlobalRibbonConfig {
}

Ribbon默认是懒加载的,所以在第一次发生请求的时候会显得比较慢,我们可以通过在配置文件中添加如下配置开启饥饿加载:

ribbon:
  eager-load:
    enabled: true
    # 为哪些客户端开启饥饿加载,多个客户端使用逗号分隔(非必须)
    clients: user-center

支持Nacos权重

以上小节基本介绍完了负载均衡及Ribbon的基础使用,接下来的内容需要配合Nacos,若没有了解过Nacos的话可以参考以下文章:

在Nacos Server的控制台页面可以编辑每个微服务实例的权重,服务列表 -> 详情 -> 编辑;默认权重都为1,权重值越大就越优先被调用:

权重在很多场景下非常有用,例如一个微服务有很多的实例,它们被部署在不同配置的机器上,这时候就可以将配置较差的机器上所部署的实例权重设置得比较低,而部署在配置较好的机器上的实例权重设置得高一些,这样就可以将较大一部分的请求都分发到性能较高的机器上。

但是Ribbon内置的负载均衡策略都不支持Nacos的权重,所以我们就需要自定义实现一个支持Nacos权重配置的负载均衡策略。好在Nacos Client已经内置了负载均衡的能力,所以实现起来也比较简单,代码如下:

package com.zj.node.contentcenter.configuration;

import com.alibaba.nacos.api.exception.NacosException;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.pojo.Instance;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.AbstractLoadBalancerRule;
import com.netflix.loadbalancer.BaseLoadBalancer;
import com.netflix.loadbalancer.Server;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.alibaba.nacos.NacosDiscoveryProperties;
import org.springframework.cloud.alibaba.nacos.ribbon.NacosServer;

/**
 * 支持Nacos权重配置的负载均衡策略
 *
 * @author 01
 * @date 2019-07-27
 **/
@Slf4j
public class NacosWeightedRule extends AbstractLoadBalancerRule {

    @Autowired
    private  NacosDiscoveryProperties discoveryProperties;

    /**
     * 读取配置文件,并初始化NacosWeightedRule
     *
     * @param iClientConfig iClientConfig
     */
    @Override
    public void initWithNiwsConfig(IClientConfig iClientConfig) {
        // do nothing
    }

    @Override
    public Server choose(Object key) {
        BaseLoadBalancer loadBalancer = (BaseLoadBalancer) this.getLoadBalancer();
        log.debug("lb = {}", loadBalancer);

        // 需要请求的微服务名称
        String name = loadBalancer.getName();
        // 获取服务发现的相关API
        NamingService namingService = discoveryProperties.namingServiceInstance();

        try {
            // 调用该方法时nacos client会自动通过基于权重的负载均衡算法选取一个实例
            Instance instance = namingService.selectOneHealthyInstance(name);
            log.info("选择的实例是:instance = {}", instance);

            return new NacosServer(instance);
        } catch (NacosException e) {
            return null;
        }
    }
}

然后在配置文件中配置一下就可以使用该负载均衡策略了:

user-center:
  ribbon:
    NFLoadBalancerRuleClassName: com.zj.node.contentcenter.configuration.NacosWeightedRule

**思考:**既然Nacos Client已经有负载均衡的能力,Spring Cloud Alibaba为什么还要去整合Ribbon呢?

个人认为,这主要是为了符合Spring Cloud标准。Spring Cloud Commons有个子项目 spring-cloud-loadbalancer ,该项目制定了标准,用来适配各种客户端负载均衡器(虽然目前实现只有Ribbon,但Hoxton就会有替代的实现了)。

Spring Cloud Alibaba遵循了这一标准,所以整合了Ribbon,而没有去使用Nacos Client提供的负载均衡能力。


同一集群优先调用

Spring Cloud Alibaba之服务发现组件 - Nacos一文中已经介绍过集群的概念以及作用,这里就不再赘述,加上上一小节中已经介绍过如何自定义负载均衡策略了,所以这里不再啰嗦而是直接上代码,实现代码如下:

package com.zj.node.contentcenter.configuration;

import com.alibaba.nacos.api.exception.NacosException;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.pojo.Instance;
import com.alibaba.nacos.client.naming.core.Balancer;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.AbstractLoadBalancerRule;
import com.netflix.loadbalancer.BaseLoadBalancer;
import com.netflix.loadbalancer.Server;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.alibaba.nacos.NacosDiscoveryProperties;
import org.springframework.cloud.alibaba.nacos.ribbon.NacosServer;
import org.springframework.util.CollectionUtils;

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

/**
 * 实现同一集群优先调用并基于随机权重的负载均衡策略
 *
 * @author 01
 * @date 2019-07-27
 **/
@Slf4j
public class NacosSameClusterWeightedRule extends AbstractLoadBalancerRule {

    @Autowired
    private NacosDiscoveryProperties discoveryProperties;

    @Override
    public void initWithNiwsConfig(IClientConfig iClientConfig) {
        // do nothing
    }

    @Override
    public Server choose(Object key) {
        // 获取配置文件中所配置的集群名称
        String clusterName = discoveryProperties.getClusterName();
        BaseLoadBalancer loadBalancer = (BaseLoadBalancer) this.getLoadBalancer();
        // 获取需要请求的微服务名称
        String serviceId = loadBalancer.getName();
        // 获取服务发现的相关API
        NamingService namingService = discoveryProperties.namingServiceInstance();

        try {
            // 获取该微服务的所有健康实例
            List<Instance> instances = namingService.selectInstances(serviceId, true);
            // 过滤出相同集群下的所有实例
            List<Instance> sameClusterInstances = instances.stream()
                    .filter(i -> Objects.equals(i.getClusterName(), clusterName))
                    .collect(Collectors.toList());

            // 相同集群下没有实例则需要使用其他集群下的实例
            List<Instance> instancesToBeChosen;
            if (CollectionUtils.isEmpty(sameClusterInstances)) {
                instancesToBeChosen = instances;
                log.warn("发生跨集群调用,name = {}, clusterName = {}, instances = {}",
                        serviceId, clusterName, instances);
            } else {
                instancesToBeChosen = sameClusterInstances;
            }

            // 基于随机权重的负载均衡算法,从实例列表中选取一个实例
            Instance instance = ExtendBalancer.getHost(instancesToBeChosen);
            log.info("选择的实例是:port = {}, instance = {}", instance.getPort(), instance);

            return new NacosServer(instance);
        } catch (NacosException e) {
            log.error("获取实例发生异常", e);
            return null;
        }
    }
}

class ExtendBalancer extends Balancer {

    /**
     * 由于Balancer类里的getHostByRandomWeight方法是protected的,
     * 所以通过这种继承的方式来实现调用,该方法基于随机权重的负载均衡算法,选取一个实例
     */
    static Instance getHost(List<Instance> hosts) {
        return getHostByRandomWeight(hosts);
    }
}

同样的,想要使用该负载均衡策略的话,在配置文件中配置一下即可:

user-center:
  ribbon:
    NFLoadBalancerRuleClassName: com.zj.node.contentcenter.configuration.NacosSameClusterWeightedRule

基于元数据的版本控制

在以上两个小节我们实现了基于Nacos权重的负载均衡策略及同一集群下优先调用的负载均衡策略,但在实际项目中,可能会面临多版本共存的问题,即一个微服务拥有不同版本的实例,并且这些不同版本的实例之间可能是互不兼容的。例如微服务A的v1版本实例无法调用微服务B的v2版本实例,只能够调用微服务B的v1版本实例。

而Nacos中的元数据就比较适合解决这种版本控制的问题,至于元数据的概念及配置方式已经在Spring Cloud Alibaba之服务发现组件 - Nacos一文中介绍过,这里主要介绍一下如何通过Ribbon去实现基于元数据的版本控制。

举个例子,线上有两个微服务,一个作为服务提供者一个作为服务消费者,它们都有不同版本的实例,如下:

  • 服务提供者有两个版本:v1、v2
  • 服务消费者也有两个版本:v1、v2

v1和v2是不兼容的。服务消费者v1只能调用服务提供者v1;消费者v2只能调用提供者v2。如何实现呢?下面我们来围绕该场景,实现微服务之间的版本控制。

综上,我们需要实现的主要有两点:

  • 优先选择同集群下,符合metadata的实例
  • 如果同集群下没有符合metadata的实例,就选择其他集群下符合metadata的实例

首先我们得在配置文件中配置元数据,元数据就是一堆的描述信息,以k - v形式进行配置,如下:

spring:
  cloud:
    nacos:
      discovery:
        # 指定nacos server的地址
        server-addr: 127.0.0.1:8848
        # 配置元数据
        metadata: 
          # 当前实例版本
          version: v1
          # 允许调用的提供者实例的版本
          target-version: v1

然后就可以写代码了,和之前一样,也是通过负载均衡策略实现,具体代码如下:

package com.zj.node.contentcenter.configuration;

import com.alibaba.nacos.api.exception.NacosException;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.pojo.Instance;
import com.alibaba.nacos.client.naming.utils.CollectionUtils;
import com.alibaba.nacos.client.utils.StringUtils;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.AbstractLoadBalancerRule;
import com.netflix.loadbalancer.DynamicServerListLoadBalancer;
import com.netflix.loadbalancer.Server;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.alibaba.nacos.NacosDiscoveryProperties;
import org.springframework.cloud.alibaba.nacos.ribbon.NacosServer;

import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.stream.Collectors;

/**
 * 基于元数据的版本控制负载均衡策略
 *
 * @author 01
 * @date 2019-07-27
 **/
@Slf4j
public class NacosFinalRule extends AbstractLoadBalancerRule {

    @Autowired
    private NacosDiscoveryProperties discoveryProperties;

    private static final String TARGET_VERSION = "target-version";
    private static final String VERSION = "version";

    @Override
    public void initWithNiwsConfig(IClientConfig iClientConfig) {
        // do nothing
    }

    @Override
    public Server choose(Object key) {
        // 获取配置文件中所配置的集群名称
        String clusterName = discoveryProperties.getClusterName();
        // 获取配置文件中所配置的元数据
        String targetVersion = discoveryProperties.getMetadata().get(TARGET_VERSION);

        DynamicServerListLoadBalancer loadBalancer = (DynamicServerListLoadBalancer) getLoadBalancer();
        // 需要请求的微服务名称
        String serviceId = loadBalancer.getName();
        // 获取该微服务的所有健康实例
        List<Instance> instances = getInstances(serviceId);

        List<Instance> metadataMatchInstances = instances;
        // 如果配置了版本映射,那么代表只调用元数据匹配的实例
        if (StringUtils.isNotBlank(targetVersion)) {
            // 过滤与版本元数据相匹配的实例,以实现版本控制
            metadataMatchInstances = filter(instances,
                    i -> Objects.equals(targetVersion, i.getMetadata().get(VERSION)));

            if (CollectionUtils.isEmpty(metadataMatchInstances)) {
                log.warn("未找到元数据匹配的目标实例!请检查配置。targetVersion = {}, instance = {}",
                        targetVersion, instances);
                return null;
            }
        }

        List<Instance> clusterMetadataMatchInstances = metadataMatchInstances;
        // 如果配置了集群名称,需筛选同集群下元数据匹配的实例
        if (StringUtils.isNotBlank(clusterName)) {
            // 过滤出相同集群下的所有实例
            clusterMetadataMatchInstances = filter(metadataMatchInstances,
                    i -> Objects.equals(clusterName, i.getClusterName()));

            if (CollectionUtils.isEmpty(clusterMetadataMatchInstances)) {
                clusterMetadataMatchInstances = metadataMatchInstances;
                log.warn("发生跨集群调用。clusterName = {}, targetVersion = {}, clusterMetadataMatchInstances = {}", clusterName, targetVersion, clusterMetadataMatchInstances);
            }
        }

        // 基于随机权重的负载均衡算法,选取其中一个实例
        Instance instance = ExtendBalancer.getHost(clusterMetadataMatchInstances);

        return new NacosServer(instance);
    }

    /**
     * 通过过滤规则过滤实例列表
     */
    private List<Instance> filter(List<Instance> instances, Predicate<Instance> predicate) {
        return instances.stream()
                .filter(predicate)
                .collect(Collectors.toList());
    }

    private List<Instance> getInstances(String serviceId) {
        // 获取服务发现的相关API
        NamingService namingService = discoveryProperties.namingServiceInstance();
        try {
            // 获取该微服务的所有健康实例
            return namingService.selectInstances(serviceId, true);
        } catch (NacosException e) {
            log.error("发生异常", e);
            return Collections.emptyList();
        }
    }
}

class ExtendBalancer extends Balancer {
    /**
     * 由于Balancer类里的getHostByRandomWeight方法是protected的,
     * 所以通过这种继承的方式来实现调用,该方法基于随机权重的负载均衡算法,选取一个实例
     */
    static Instance getHost(List<Instance> hosts) {
        return getHostByRandomWeight(hosts);
    }
}