不为有趣之事,何遣有涯之生
不失其所者久,死而不亡者寿

SpringCloud微服务系列(3) 客户端负载Ribbon

客户端负载Ribbon

Ribbon的基本介绍

什么是客户端负载

我们在做服务集群的时候,经常会听到负载均衡这个词,比如下图的一个架构:

客户端在访问服务器的时候,中间一般会用一些硬件(F5)或软件(nginx)来作负载均衡,从而实现后端各服务器分摊请求压力,达到均衡的目的。图中的nginx就是负载均衡器,我们通常称之为服务端的负载均衡,客户端不用关心自己调用的是哪个服务,只要统一访问某一个地址,负载均衡器会根据某个负载策略(权重,可用性,线性轮询等)路由到某个具体的服务器上。

那么客户端负载均衡和服务端负载均衡有什么区别呢?如果我们把上图架构改造成下图所示:

我们在客户端后面加了一个服务器清单(里面维护着后端可以用的服务),客户端访问后端服务的时候,自己去选择一个服务去访问。所以它们二者最大的不用点就是谁来维护服务端清单,就称之为谁的负载均衡。

SpringCloud中的Ribbon

Spring Cloud Ribbon是一个基于HTTP和TCP的客户端负载工具,它基于Netflix实现。它不用像注册中心、配置中心、API网关那样独立部署,而且几乎存在与于每一个SpringCloud构建的微服务和基础设施中。我们要使用它也很简单,只要添加spring-cloud-starter-ribbon的依赖即可。

简单示例

在上一节注册中心的讲解中我们没有使用客户端的负载均衡,服务消费者调用服务生产者的架构如下所示:

消费者调用的时候,输入的URL是指向具体的生产者的,接下来我们新建一个项目service-ribbon,然后把Ribbon加进去。
新建的项目添加依赖:spring-cloud-starter-ribbon。

通过Spring Cloud Ribbon来使用客户端负载均衡调用,需要二个步骤:
- 第一步,服务提供者的多个实例注册到注册中心
- 第二步,服务消费者通过调用被@LoadBalanced注解修饰过的RestTemplate来实现

第一步的话,只要仿造上节的启动注册中心(单中心和多中心都可以)和生产者多个实例即可。
第二步的话,需要编写一些代码,我们会有3个类:
ServiceRibbonApplication,启动类并负责生成RestTemplate实例

@EnableDiscoveryClient
@SpringBootApplication
public class ServiceRibbonApplication {

   public static void main(String[] args) {
      SpringApplication.run(ServiceRibbonApplication.class, args);
   }


   @Bean
   @LoadBalanced
   RestTemplate restTemplate() {
      return new RestTemplate();
   }
}

HelloControler,提供一个请求入口,目的是调用本身的服务

@RestController
public class HelloControler {

    @Autowired
    HelloService helloService;
    @RequestMapping(value = "/hi")
    public String hi(@RequestParam String name){
        return helloService.hiService(name);
    }
}

HelloService,提供给HelloControler调动,服务本身不提供具体的服务,而是去调用服务生产者的服务

@Service
public class HelloService {

    @Autowired
    RestTemplate restTemplate;

    /**
     * 负载方法
     * @param name
     * @return
     * SERVICE-HI :虚拟主机名
     */
    public String hiService(String name) {
        return restTemplate.getForObject("http://SERVICE-HI/hi?name="+name,String.class);
    }
}

配置文件如下:

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
server:
  port: 8764
spring:
  application:
    name: service-ribbon

启动service-ribbon,eureka-server,eureka-client(2个实例),在注册中心看到注册的服务如下:

通过url:http://localhost:8764?name=hziquxm 来访问下服务, 多次访问后发现以下返回结果轮询出现:

hi hzqiuxm, i am from port8762
hi hzqiuxm, i am from port8763

证明ribbon已经发挥了作用,默认采用了轮询访问后台服务的方式。
最终我们的架构变成如下所示:

Ribbon深入详解

RestTemplate

可以从简单示例得知RestTemplate的作用非常关键,该对象会使用Ribbon的自动化配置,通过注解@LoadBalanced开启客户端负载。从名字上我们得知RestTemplate和REST请求是很有关系的,它就是针对REST几种不同请求类型调用实现工具类。我们接下来就看看这个工具类的增删改查。

GET请求

RestTemplate中对GET请求,通过如下两个方法进行调用实现:
第一种getForEntity(),它有三种不同的重载实现

<T> ResponseEntity<T> getForEntity(URI url, Class<T> responseType)

url:请求的地址,注意类型是是URL(包含了路径和参数等信息),不是String
responseType:请求响应体包装类型
ResponseEntity:返回结构,是Spring对HTTP请求响应的封装,该对象中body中内容类型会根据第二个参数类型进行转换,比如第二个参数是String.class代表返回对象body的内容会转换为String类型

<T> ResponseEntity<T> getForEntity(String url, Class<T> responseType, Object... uriVariables)
  • url:字符串类型的url,通常这种用的比较多
  • uriVariables:url中的参数绑定,数组方式,顺序和url占位符按顺序对应
<T> ResponseEntity<T> getForEntity(String url, Class<T> responseType, Map<String, ?> uriVariables)
  • uriVariables:url中的参数绑定,Map类型,需要提供一个key,value的map作为参数

第二种getForObject(),功能类似对getForEntity()进行进一步封装,它通过HttpMessageConverterExtractor对请求响应体body中的内容进行对象转换,实现了请求直接返回包装好的对象内容,不用再去body中去取了。它也提供三种不同的重载实现:

<T> T getForObject(URI url, Class<T> responseType)
<T> T getForObject(String url, Class<T> responseType, Object... uriVariables)
<T> T getForObject(String url, Class<T> responseType, Map<String, ?> uriVariables)

参数和 getForEntity类似,就不赘述了。

POST请求

RestTemplate中对POST请求,通过如下三个方法进行调用实现:
第一种,postForEntity(),该方法和GET中的getForEntity类型,返回值也是一个ResponseEntity对象,它有三种不同重载方法:

<T> ResponseEntity<T> postForEntity(URI url, Object request, Class<T> responseType)
<T> ResponseEntity<T> postForEntity(String url, Object request, Class<T> responseType, Map<String, ?> uriVariables)
<T> ResponseEntity<T> postForEntity(String url, Object request, Class<T> responseType, Object... uriVariables)
  • request:可以是普通类型(比如自定义的某个实体Bean)或者是HttpEntity类型,普通类型不包含header,HttpEntity类型包含header。

第二种,postForObject(),也和getForObject()有些类似,包含了三种重载方法:

<T> T postForObject(URI url, Object request, Class<T> responseType)
<T> T postForObject(String url, Object request, Class<T> responseType, Object... uriVariables)
<T> T postForObject(String url, Object request, Class<T> responseType, Map<String, ?> uriVariables)

除了返回类型,参数都和postForEntity()一样,就不赘述了。

第三种,postForLocation(),该方法实现了以POST请求提交资源,并返回新资源URI,该URI就相同于指定了返回类型。
它也有三种不同的重载方法:

URI postForLocation(URI url, Object request)
URI postForLocation(String url, Object request, Object... uriVariables)
URI postForLocation(String url, Object request, Map<String, ?> uriVariables)

参数都和前面二种一样,就不赘述了。

PUT请求

RestTemplate中对PUT请求,就是通过put方法调用实现的,它有三种不同的重载方法:

void put(URI url, Object request)
void put(String url, Object request, Object... uriVariables)
void put(String url, Object request, Map<String, ?> uriVariables)

请求参数之前GET或POST中都有出现,就不赘述了。

PATCH请求

RestTemplate中对PATCH请求,是通过patchForObject方法调用实现的,它有三种不同的重载方法:

<T> T patchForObject(URI url, Object request, Class<T> responseType)
<T> T patchForObject(String url, Object request, Class<T> responseType,Object... uriVariables)
<T> T patchForObject(String url, Object request, Class<T> responseType,Map<String, ?> uriVariables)

请求参数之前GET或POST中都有出现,就不赘述了。

DELETE请求

RestTemplate中对DELETE请求,就是通过delete方法调用实现的,它有三种不同的重载方法:

void delete(URI url)
void delete(String url, Object... uriVariables)
void delete(String url, Map<String, ?> uriVariables)

方法参数非常简单,一般我们会把请求唯一标示拼接在url中。

源码分析

熟悉Spring的同学肯定知道RestTemplate是Spring自己提供的,那么和客户端负载均衡有关的,貌似就剩下之前没有见过的注解@LoadBalancer了,接下来我们就从它开始,看看Ribbon到底是怎么实现客户端负载均衡的。

首先我们会情不自禁的看下@LoadBalancerClinet注解的源码:

/**
 * Annotation to mark a RestTemplate bean to be configured to use a LoadBalancerClient
 * @author Spencer Gibb
 */
@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Qualifier
public @interface LoadBalanced {
}

看完后好像并没有发生什么,就是一个自定义的注解,怎么办?别慌来读一遍上面的官方注释:该注解是用来标记RestTemplate,使其使用LoadBalancerClient(负载均衡的客户端)来配置它。看来LoadBalancerClient有蹊跷,赶紧进去看下。

public interface LoadBalancerClient extends ServiceInstanceChooser {
    <T> T execute(String var1, LoadBalancerRequest<T> var2) throws IOException;

    <T> T execute(String var1, ServiceInstance var2, LoadBalancerRequest<T> var3) throws IOException;

    URI reconstructURI(ServiceInstance var1, URI var2);
}

顺便也看下它继承的接口ServiceInstanceChooser

public interface ServiceInstanceChooser {

    /**
     * Choose a ServiceInstance from the LoadBalancer for the specified service
     * @param serviceId the service id to look up the LoadBalancer
     * @return a ServiceInstance that matches the serviceId
     */
    ServiceInstance choose(String serviceId);
}
  • choose()方法,根据传入的服务名serviceId,从负载均衡器挑选一个对应的服务实例
  • execute()方法,2个重载方法都是使用从负载均衡器中挑选出来服务实例来执行请求的内容
  • reconstructURI(),把服务名称的URI(后一个参数),转换成host+port形式的请求地址(前一个参数)

我们按照习惯,以LoadBalancerClient为引线,整理下和它有关的类图如下:

类图中最关键的类就是LoadBalancerInterceptor,它是由LoadBalancerAutoConfiguration自动化配置类生成的。
它的作用就是对加了@LoadBalanced注解修饰的RestTemplate对象向外发起HTTP请求时拦截客户端的请求时进行拦截,获取需要的真正实例,发起实际请求。
它的源码主要部分如下:

@Override
public ClientHttpResponse intercept(final HttpRequest request, final byte[] body,
      final ClientHttpRequestExecution execution) throws IOException {
   final URI originalUri = request.getURI();
   String serviceName = originalUri.getHost();
   Assert.state(serviceName != null, "Request URI does not contain a valid hostname: " + originalUri);
   return this.loadBalancer.execute(serviceName, requestFactory.createRequest(request, body, execution));
}

intercept函数负责拦截客户端请求,然后通过LoadBalancerClient的execute方法获取具体实例发起实际请求。
由上面类图得知LoadBalancerClient是一个接口,我们要看下它的实现类RibbonLoadBalancerClient的execute方法寻找关键。

@Override
public <T> T execute(String serviceId, LoadBalancerRequest<T> request) throws IOException {
   ILoadBalancer loadBalancer = getLoadBalancer(serviceId);
   Server server = getServer(loadBalancer);
   if (server == null) {
      throw new IllegalStateException("No instances available for " + serviceId);
   }
   RibbonServer ribbonServer = new RibbonServer(serviceId, server, isSecure(server,
         serviceId), serverIntrospector(serviceId).getMetadata(server));

   return execute(serviceId, ribbonServer, request);
}
protected Server getServer(ILoadBalancer loadBalancer) {
   if (loadBalancer == null) {
      return null;
   }
   return loadBalancer.chooseServer("default"); // TODO: better handling of key
}

从RibbonLoadBalancerClient的源码中,我们可以发现execute方法一开就就通过getServer()方法,根据传入的服务名来获取具体的服务实例,而获取实例又是依赖ILoadBalancer对象的chooseServer方法。
在获取了服务实例之后,会将server对象包装成一个RibbonServer对象,该对象额外增加了是否使用HTTPS协议,服务名等其它信息。然后使用该对象回调LoadBalancerRequest的apply方法,向一个实际的服务实例发起请求,从而实现以服务名为host的URI请求到host:port形式的实际地址转换。

@Override
public <T> T execute(String serviceId, ServiceInstance serviceInstance, LoadBalancerRequest<T> request) throws IOException {
   Server server = null;
   if(serviceInstance instanceof RibbonServer) {
      server = ((RibbonServer)serviceInstance).getServer();
   }
   if (server == null) {
      throw new IllegalStateException("No instances available for " + serviceId);
   }

   RibbonLoadBalancerContext context = this.clientFactory
         .getLoadBalancerContext(serviceId);
   RibbonStatsRecorder statsRecorder = new RibbonStatsRecorder(context, server);

   try {
      T returnVal = request.apply(serviceInstance);
      statsRecorder.recordStats(returnVal);
      return returnVal;
   }
... ...

再简单看下ILoadBalancer接口,它其实是我们下面要介绍的一系列各种负载均衡器的接口,我们先简单看下接口中定义的一些抽象操作,具体的在负载均衡器中进行讲解。

public interface ILoadBalancer {

   public void addServers(List<Server> newServers);//向负载均衡器维护的实例清单中增加服务实例
   public Server chooseServer(Object key);//通过某种策略,选择一个具体的服务实例
   public void markServerDown(Server server);//标识出某个服务已经停止服务
   public List<Server> getReachableServers();//获取正常服务的实例清单
   public List<Server> getAllServers();//获取所有服务的实例清单

负载均衡

负载均衡器

我们先看下ILoadBalancer接口的类图:

AbstractLoadBalancer:是接口的抽象实现,主要功能是把实例进行分组并提供获取实例方法。

public enum ServerGroup{
    ALL,//所有实例
    STATUS_UP,//正常服务实例
    STATUS_NOT_UP //停止服务实例       
}

BaseLoadBalancer:是Ribbon负载均衡器的基础实现类,包含的主要是一些基础内容,比如:存储服务实例的列表,检查服务实例是否正常服务的IPing对象,定义负载均衡的处理规则IRule(这也是所有负载均衡策略的接口)对象,选择一个具体服务实例(默认使用线性轮询的方式)等等。

NoOpLoadBalancer:一个什么也不做的负载均衡器。

DynamicServerListLoadBalancer:对BaseLoadBalancer的扩展,在基础功能上增加了动态更新服务实例清单和过滤的功能,仍然使用线性轮询方式进行具体服务实例的选择。

ZoneAwareLoadBalancer:对DynamicServerListLoadBalancer的扩展,增加了按区域的概念。它父类的负载均衡器都是把所有实例视为一个Zone下的节点进行轮询的,所以当我们有多个区域的情况下,势必会造成周期性的跨区域访问问题。使用了该负载均衡器,就可以避免这种问题。我们可以在RibbonClientConfiguration类中查看:

@Bean
@ConditionalOnMissingBean
public ILoadBalancer ribbonLoadBalancer(IClientConfig config,
      ServerList<Server> serverList, ServerListFilter<Server> serverListFilter,
      IRule rule, IPing ping, ServerListUpdater serverListUpdater) {
   if (this.propertiesFactory.isSet(ILoadBalancer.class, name)) {
      return this.propertiesFactory.get(ILoadBalancer.class, config, name);
   }
   return new ZoneAwareLoadBalancer<>(config, rule, ping, serverList,
         serverListFilter, serverListUpdater);
}

ZoneAwareLoadBalancer是默认整合时采用的负载均衡器,它内部使用的是ZoneAvoidanceRule策略来实现的,各种负载均衡策略的具体介绍,参考下节内容。

负载均衡策略

整个负载均衡策略相关类的关系图如下:

IRule:所有负载均衡器的统一接口,提供选择负载均衡器功能

AbstractLoadBalancerRule: 定义了负载均衡器ILoadBalancer

RandomRule:随机选择策略,从服务实例清单中随机取一个。

RoundRobinRule:按照线性轮询的方式依次选择每个服务实例的策略。

RetryRule:内部使用了RoundRobinRule负载策略,增加了重试机制,通过时间来控制。

WeightedResponseTimeRule:增加了权重的线性轮询机制,主要由定时任务(默认30秒计算一次)、权重计算(根据实例的响应时间按规则计算)、实例选择三部分功能组成。

ClientConfigEnabledRoundRobinRule:本身也是通过RoundRobinRule来实现策略的,它的主要作用是做为一个父类,使得它所有子类具备线性轮询的功能同时可以和扩展自己的特性。

BestAvailableRule:最空闲实例负载策略,选择一个目前负载量最小的实例。

PredicateBasedRule:具备过滤机制的线性轮询负载策略。

AvailabilityFilteringRule:可用的最大空闲负载策略,这里的可用指的是:1 断路器处于关闭状态 2 并发数是小于阈值(默认2的32次减1)的

ZoneAvoidanceRule:区域选择负载策略,这个策略的逻辑稍微有点复杂,大致规则如下:

  • 为所有Zone区域创建快照
  • 计算出可用区域:首先剔除符合下面这些规则的Zone区域:实例数为0的区域,平均负载小于0的区域,故障了大于阈值(默认0.99999)的区域 ;然后根据平均负载计算出最差的Zone区域;如果上面过程没有符合剔除条件的区域并且平均负载小于阈值(20%),就直接返回所有可用的区域;否则从最坏的区域集合中随机选择一个剔除
  • 当区域数集合不为空,随机选择一个
  • 确定某个区域后,获取区域的服务实例清单轮询选择

在上面的类图中,我把所有策略中最为重要的两个策略用绿色标记了下,为什么我认为他们最重要呢?RoundRobinRule是其他所有负责策略的基础,也是我们通常情况下使用最多的策略;ZoneAvoidanceRule是我们需要对所有微服务实例进行有效管理和最优化实施的关键策略,特别是有跨区域的实例时。

配置详解

自动化配置

由于Ribbon中定义的每一个接口都有多种不同的策略实现,同时这些接口之间又有一定的依赖关系,这使得第一次使用Ribbon的开发者很难上手,不知道如何选择具体的实现策略以及如何组织它们的关系。SpringCloudRibbon中的自动化配置恰恰能够解决这样的痛点,在引入Spring Cloud Ribbon的依赖之后,就能够自动化构建下面这些接口的实现。

  • IClientConfig:Ribbon 的客户端配置, 默认采用com.netflix.client.config.DefaultClientConfigimpl实现
  • IRule:Ribbon 的负载均衡策略, 默认采用com.netflix.loadbalancer.ZoneAvoidanceRule实现,该策略能够在多区域环境下选出最佳区域的实例进行访问
  • IPing:Ribbon的实例检查策略,默认采用com.netflix.loadbalancer.NoOping实现, 该检查策略是一个特殊的实现,实际上它并不会检查实例是否可用, 而是始终返回true, 默认认为所有服务实例都是可用的。
  • ServerList:服务实例清单的维护机制, 默认采用com.netflix.loadbalancer.ConfigurationBasedServerList实现
  • ServerListFilter:服务实例清单过滤机制, 默认采用org.springframework.cloud.netflix.ribbon.ZonePreferenceServerListFilter实现, 该策略能够优先过滤出与请求调用方处于同区域的服务实例。
  • ILoadBalancer:负载均衡器, 默认采用com.netflix.loadbalancer.ZoneAwareLoadBalancer实现, 它具备了区域感知的能力。

上面这些自动化配置内容仅在没有引入Spring Cloud Eureka等服务治理框架时如此,在同时引入Eureka和Ribbon依赖时,自动化配置会有一些不同。如果要看这些类具体的配置了些什么,只要查看对应的类具体代码就可以详细了解。

我们也可以使用配置类方便地替换上面的这些默认实现,比如:

@Configuration
public class MyRibbonConfiguration {

@Bean //默认的NoOping 就不会被创建
public IPing ribbonPing(IClientConfig config) {
return new PingUrl();
    }


@RibbonClient(name = "hello-service", configura七ion = HelloServiceConfiguration.
class) //为服务指定配置
public class RibbonConfiguration {
    }
}

参数配置

支持二种方式的配置:

  • 全局配置
    ribbon.< key>=< value>格式进行配置即可。代表了Ribbon 客户端配置的参数名, < value>则代表了对应参数的
    值。例如:ribbon.ConnectTimeout=250,配置了全局的Ribbon创建连接时间。
    一般建议全局配置可以作为系统的默认配置,客户端配置可以覆盖全局配置,从而实现自定义的一些配置

  • 指定客户端配置
    配置方式采用< client> .ribbon.< key>=< value>的格式进行配置。其中, < key>和< value>的含义同全局配置相同, 而< client>代表了客户端的名称, 比如使用@RibbonClient指定的名称, 也可以将它理解为是一个服务名。

与Eureka结合

当在Spring Cloud的应用中同时引入Spring Cloudribbon和Spring Cloud Eureka依赖时, 会触发Eureka中实现的对ribbon的自动化配置。ServerList的维护机制,IPing的实现都将被覆盖。

在与Spring Cloud Eureka结合使用的时候, 我们的配置将会变得更加简单。不再需要通过类似hello-service.ribbon.listOfServers的参数来指定具体的服务实例清单, 因为Eureka将会为我们维护所有服务的实例清单。而对于Ribbon 的参数配置, 我们依然可以采用之前的两种配置方式来实现, 而指定客户端的配置方式可以直接使用Eureka中的服务名作为来完成针对各个微服务的个性化配置。

SpringCloudRibbon默认实现了区域亲和策略,所以, 我们可以通过Eureka实例的元数据配置来实现区域化的实例配置方案。
将处于不同机房的实例配置成不同的区域值, 以作为跨区域的容错机制实现。例如:

eureka.instance.metadataMap.zone=hangzhou

最后,如果你喜欢或有需要使用Ribbon来维护服务实例,也可以通过参数配置的方式来禁用Eureka对Ribbon服务实例的维护实现。例如:

ribbon.eureka.enabled=false

重试机制

Spring Cloud Eureka实现的服务智力机制强调了CAP原理中的AP(可用性和分区容错性),不同于类似ZK这类强调CP(一致性和分区容错性) 的服务治理框架。Eureka为了可用性,牺牲了一定的一致性。

在极端情况下,它宁愿接收故障的服务实例,也不要丢掉健康实例。比如在注册中心的网络发生故障时,CP优先的服务治理将会把所有的服务实例全部剔除,而Eureka则会因为超过85%的实例丢掉心跳触发保护机制,注册中心会保留此时的所有节点。

所以当服务调用到故障实例的时候,我们希望能够增强对这类问题的容错,这时可以使用Spring Retry(SpringCloud已经做了整合)来增强RestTemplate的重试能力。

要实现上面所说的重试机制我们只要增加相关配置就行:

spring.cloud.loadbalancer.retry.enabled = true //开启重试机制

然后是对具体服务进行一些超时时间的配置:

hystrix.command.default.execution.isolation.thread.timeoutinMilliseconds=l0000 //断路器超时时间
xxxservice.ribbon.ConnectTimeout=250 //请求连接超时时间
xxxservice.ribbon.ReadTimeout= l000 //请求处理的超时时间
xxxservice.ribbon.OkToRetryOnAllOperations=true //对所有操作请求都进行重试
xxxservice.ribbon.MaxAutoReriesNextServer=2 //切换实例的重试次数
xxxservice.ribbon.MaxAutoRetries=1   //对当前实例的重试次数

注意断路器的超时时间需要大于Ribbon的超时时间,不然不会触发重试,原理很简单,因为先触发断路器的逻辑了。

未经允许不得转载:菡萏如佳人 » SpringCloud微服务系列(3)

欢迎加入极客江湖

进入江湖关于作者