上一篇:《SpringCloud03—服务治理:SpringCloud Eureka》
Spring Cloud Ribbon是一个基于HTTP和TCP的客户端负载均衡工具,它基于NetflixRibbon实现。通过Spring Cloud 的封装,可以让我们轻松地将面向服务的REST模板请求自动转换成客户端负载均衡的服务调用。Spring Cloud Ribbon虽然只是一个工具类框架,它不像服务注册中心、配置中心、API 网关那样需要独立部署,但是它几乎存在于每一个Spring Cloud构建的微服务和基础设施中。因为微服务间的调用,API网关的请求转发等内容,实际上都是通过Ribbon来实现的,包括后续我们将要介绍的Feign,它也是基于Ribbon实现的工具。所以,对Spring Cloud Ribbon的理解和使用,对于我们使用Spring Cloud来构建微服务非常重要。
在这一章中,我们将具体介绍如何使用Ribbon来实现客户端的负载均衡,并且通过源码分析来了解 Ribbon 实现客户端负载均衡的基本原理。
负载均衡在系统架构中是一个非常重要,并且是不得不去实施的内容。因为负载均衡是对系统的高可用、网络压力的缓解和处理能力扩容的重要手段之一。我们通常所说的负载均衡都指的是服务端负载均衡,其中分为硬件负载均衡和软件负载均衡。硬件负载均衡主要通过在服务器节点之间安装专门用于负载均衡的设备,比如F5等;而软件负载均衡则是通过在服务器上安装一些具有均衡负载功能或模块的软件来完成请求分发工作,比如Nginx等。不论采用硬件负载均衡还是软件负载均衡,只要是服务端负载均衡都能以类似下图的架构方式构建起来:
硬件负载均衡的设备或是软件负载均衡的软件模块都会维护一个下挂可用的服务端清单,通过心跳检测来剔除故障的服务端节点以保证清单中都是可以正常访问的服务端节点。
当客户端发送请求到负载均衡设备的时候,该设备按某种算法(比如线性轮询、按权重负载、按流量负载等)从维护的可用服务端清单中取出一台服务端的地址,然后进行转发。
而客户端负载均衡和服务端负载均衡最大的不同点在于上面所提到的服务清单所存储的位置。
客户端负载均衡中,所有客户端节点都维护着自己要访问的服务端清单,而这些服务端的清单来自于服务注册中心,比如上一章我们介绍的Eureka服务端。同服务端负载均衡的架构类似,在客户端负载均衡中也需要心跳去维护服务端清单的健康性,只是这个步骤需要与服务注册中心配合完成。
在Spring Cloud实现的服务治理框架中,默认会创建针对各个服务治理框架的 Ribbon自动化整合配置,比如Eureka中的org.springframework.cloud.netflix.ribbon.eureka.RibbonEurekaAutoConfiguration ,Consul中的org.springframework.cloud.consul.discovery.RibbonConsulAuto-Configuration。在实际使用的时候,我们可以通过查看这两个类的实现,以找到它们的配置详情来帮助我们更好地使用它。
通过Spring Cloud Ribbon的封装,我们在微服务架构中使用客户端负载均衡调用非常简单,只需要如下两步:
这样,我们就可以将服务提供者的高可用以及服务消费者的负载均衡调用一起实现了。
在 RestTemplate 中,对GET请求可以通过如下两个方法进行调用实现。
ResponseEntity<String> responseEntity = restTemplate.getForEntity("http://HELLO-SERVICE/hello?userName={1}",
String.class, "didi");
String body = responseEntity.getBody();
若我们希望返回的body是一个User对象类型ResponseEntity<User> responseEntity = restTemplate.getForEntity("http://HELLO-SERVICE/hello?userName={1}",
User.class, "didi");
User body = responseEntity.getBody();
String result = restTemplate.getForObject("http://HELLO-SERVICE/hello?userName={1}",
String.class, "dodo");
当body是一个User对象时User user=restTemplate.getForObject("http://HELLO-SERVICE/hello?userName={1}",
User.class, "dodo");
有一个最简单有效的办法:postFprObject
User param = new User();
param.setUserName("ninesun");
param.setAge(12);
User user1 = (User) restTemplate.postForObject("http://HELLO-SERVICE/hello",
param, Object.class);
下面解释一下上面的代码:
param是我们在发送post请求时所携带的参数,Object是我们返回的结果类型
负载均衡策略的抽象类,在该抽象类中定义了负载均衡器工LoadBalancer对象,该对象能够在具体实现选择服务策略时,获取到一些负载均衡器中维护的信息来作为分配依据,并以此设计一些算法来实现针对特定场景的高效策略。
首先引入ribbon的两个核心依赖
<dependency>
<groupId>com.netflix.ribbongroupId>
<artifactId>ribbon-loadbalancerartifactId>
<version>2.3.0version>
dependency>
<dependency>
<groupId>com.netflix.ribbongroupId>
<artifactId>ribbon-coreartifactId>
<version>2.3.0version>
dependency>
import com.netflix.client.IClientConfigAware;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.IRule;
import com.netflix.loadbalancer.Server;
public class AbstractLoadBalancerRule implements IRule, IClientConfigAware {
private ILoadBalancer lb;
@Override
public void initWithNiwsConfig(IClientConfig clientConfig) {
}
@Override
public Server choose(Object key) {
return null;
}
@Override
public void setLoadBalancer(ILoadBalancer lb) {
this.lb = lb;
}
@Override
public ILoadBalancer getLoadBalancer() {
return lb;
}
}
该策略实现了从服务实例清单中随机选择个服务实例的功能。它的具体实现如下,可以看到IRule接口的choose 0bject key)函数实现,委托给了该类中的choose(ILoadBalancer lb,object key),该方法增加了一个负载均衡器对象的参数。
从具体的实现上看,它会使用传入的负载均衡器来获得可用实例列表upList和所有实例列表 allList,并通过rand.nextInt (serverCount)函数来获取一个随机数,并将该随机数作为upList的索引值来返回具体实例。
同时,具体的选择逻辑在一个while(server == null)循环之内,而根据选择逻辑的实现,正常情况下每次选择都应该选出一个服务实例,如果出现死循环获取不到服务实例时,则很有可能存在并发的Bug。
import com.netflix.client.IClientConfigAware;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.IRule;
import com.netflix.loadbalancer.Server;
import java.util.List;
import java.util.Random;
public class RandomRule implements IRule, IClientConfigAware {
private ILoadBalancer lb;
@Override
public void initWithNiwsConfig(IClientConfig clientConfig) {
}
@Override
public Server choose(Object key) {
return choose(getLoadBalancer(), key);
}
public Server choose(ILoadBalancer lb, Object key) {
Server server = null;
while (server == null) {
if (Thread.interrupted()) {
return null;
}
List<Server> upList = lb.getReachableServers();
List<Server> allList = lb.getAllServers();
int serverCount = allList.size();
if (serverCount == 0) {
return null;
}
Random random = new Random();
int index = random.nextInt(serverCount);
server = upList.get(index);
if (server == null) {
Thread.yield();
continue;
}
if (server.isAlive()) {
return server;
}
server = null;
Thread.yield();
}
return server;
}
@Override
public void setLoadBalancer(ILoadBalancer lb) {
this.lb = lb;
}
@Override
public ILoadBalancer getLoadBalancer() {
return lb;
}
}
该策略实现了按照线性轮询的方式依次选择每个服务实例的功能。它的具体实现如下,其详细结构与RandomRule非常类似。除了循环条件不同外,就是从可用列表中获取所谓的逻辑不同。从循环条件中,我们可以看到增加了一个count计数变量,该变量会在每次循环之后累加,也就是说,如果一直选择不到server超过10次,那么就会结束尝试,并打印一个警告信息No available alive servers after 10 tries from load balancer: …。
import com.netflix.client.IClientConfigAware;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.IRule;
import com.netflix.loadbalancer.Server;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class RoundRobinRule implements IRule, IClientConfigAware {
private ILoadBalancer lb;
private AtomicInteger nextServerCyclicCounter;
public RoundRobinRule() {
nextServerCyclicCounter = new AtomicInteger(0);
}
public RoundRobinRule(ILoadBalancer lb) {
this();
setLoadBalancer(lb);
}
@Override
public void initWithNiwsConfig(IClientConfig clientConfig) {
}
@Override
public Server choose(Object key) {
return choose(getLoadBalancer(), key);
}
public Server choose(ILoadBalancer lb, Object key) {
Server server = null;
int count = 0;
while (server == null && count++ < 10) {
List<Server> reachableServers = lb.getReachableServers();
List<Server> allServers = lb.getAllServers();
int upCount = reachableServers.size();
int serverCount = allServers.size();
if (upCount == 0 || serverCount == 0) {
log.warn("No available alive servers after 10 tries from load balancer:" + lb);
return null;
}
int nextServerIndex = incrementAndGetModulo(serverCount);
server = allServers.get(nextServerIndex);
if (server == null) {
Thread.yield();
continue;
}
if (server.isAlive() && server.isReadyToServe()) {
return server;
}
server = null;
}
if (count >= 10) {
log.warn("No available alive servers after 10 tries from load balancer:" + lb);
}
return server;
}
@Override
public void setLoadBalancer(ILoadBalancer lb) {
this.lb = lb;
}
@Override
public ILoadBalancer getLoadBalancer() {
return lb;
}
private int incrementAndGetModulo(int modulo) {
int current;
int next;
do {
current = this.nextServerCyclicCounter.get(); //nextServerCyclicCounter是AtomicInteger对象,默认值0,可保证线程安全性
next = (current + 1) % modulo; //每次往后移一位,取集合中的下一个server。这里要注意的是从1开始,即数组中的第二个server会被第一个调用。
} while (!this.nextServerCyclicCounter.compareAndSet(current, next)); //操作完成后用CAS操作将next赋值给nextServerCyclicCounter
return next;
}
}
该策略实现了一个具备重试机制的实例选择功能。从下面的实现中我们可以看到,在其内部还定义了一个IRule对象,默认使用了RoundRobinRule实例。而在choose方法中则实现了对内部定义的策略进行反复尝试的策略,若期间能够选择到具体的服务实例就返回,若选择不到就根据设置的尝试结束时间为阈值(maxRetryMillis参数定义的值+ choose方法开始执行的时间戳),当超过该阈值后就返回null。
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.IRule;
import com.netflix.loadbalancer.InterruptTask;
import com.netflix.loadbalancer.Server;
public class RetryRule extends AbstractLoadBalancerRule {
IRule iRule = new RoundRobinRule();
long maxRetryMillis = 500;
@Override
public Server choose(Object key) {
return choose(getLoadBalancer(), key);
}
public Server choose(ILoadBalancer lb, Object key) {
long requestTime = System.currentTimeMillis();
long deadline = requestTime + maxRetryMillis;
Server answer = null;
answer = iRule.choose(key);
if ((answer == null || !answer.isAlive()) && (System.currentTimeMillis() < deadline)) {
InterruptTask task = new InterruptTask(deadline - System.currentTimeMillis());
while (!Thread.interrupted()) {
answer = iRule.choose(key);
if ((answer == null || !answer.isAlive()) && (System.currentTimeMillis() < deadline)) {
Thread.yield();
} else {
break;
}
}
task.cancel();
}
if (answer == null || !answer.isAlive()) {
return null;
} else {
return answer;
}
}
}
该策略是对RoundRobinRule 的扩展,增加了根据实例的运行情况来计算权重,并根据权重来挑选实例,以达到更优的分配效果,它的实现主要有三个核心内容。
weightedResponseTimeRule策略在初始化的时候会通过serverweightTimer.schedule (new DynamicServerWeightTask(),0,serverWeightTaskTimerInterval)启动一个定时任务,用来为每个服务实例计算权重,该任务默认30秒执行一次。
在源码中我们可以轻松找到用于存储权重的对象 List accumulated-Weights = new arrayList( ),该List中每个权重值所处的位置对应了负载均衡器维护的服务实例清单中所有实例在清单中的位置。
维护实例权重的计算过程通过maintainweights函数实现,具体如下面的代码所示:
import com.netflix.client.IClientConfigAware;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClient;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.atomic.AtomicBoolean;
@Slf4j
public class WeightedResponseTimeRule implements IRule, IClientConfigAware {
private ILoadBalancer lb;
private volatile List<Double> accumulatedWeights = new ArrayList();
protected AtomicBoolean serverWeightAssignmentInProgress = new AtomicBoolean(false);
@Override
public void initWithNiwsConfig(IClientConfig clientConfig) {
}
@Override
public Server choose(Object key) {
return null;
}
@Override
public void setLoadBalancer(ILoadBalancer lb) {
this.lb = lb;
}
@Override
public ILoadBalancer getLoadBalancer() {
return lb;
}
public void maintainWeights() {
ILoadBalancer lb = getLoadBalancer();
try {
log.info("Weight adjusting job started");
AbstractLoadBalancer nlb = (AbstractLoadBalancer) lb;
LoadBalancerStats stats = nlb.getLoadBalancerStats();
//计算所有实例的平均相应时间总和:totalResponseTime
double totalResponseTime = 0;
for (Server server : nlb.getAllServers()) {
//如果服务实例的状态快照不在缓存中,那么这里会进行自动加载
ServerStats ss = stats.getSingleServerStat(server);
totalResponseTime += ss.getResponseTimeAvg();
}
//逐个计算每个实例的权重:weightSoFar+totalResponseTime-实例的平均响应
Double weightSoFar = 0.0;
List<Double> finalWeights = new ArrayList<>();
for (Server server : nlb.getAllServers()) {
ServerStats ss = stats.getSingleServerStat(server);
double weight = totalResponseTime - ss.getResponseTimeAvg();
weightSoFar += weight;
finalWeights.add(weightSoFar);
}
setWeights(finalWeights);
} catch (Throwable t) {
log.error("Exception while dynamically calculating server weights", t);
} finally {
serverWeightAssignmentInProgress.set(false);
}
}
void setWeights(List<Double> weights) {
this.accumulatedWeights = weights;
}
该函数的实现主要分为两个步骤:
举个简单的例子来理解这个计算过程,假设有4个实例A、B、C、D,它们的平均响应时间为10、40、80、100,所以总响应时间是10+40+80+100=230,每个实例的权重为总响应时间与实例自身的平均响应时间的差的累积所得,所以实例A、B、C、D的权重分别如下所示。
weightedResponseTimeRule选择实例的实现与之前介绍的算法结构类似,选择实例的核心过程就两步:
若继续以上面的数据为例进行服务实例的选择,则该方法会从[0,690)区间中选出一个随机数,比如选出的随机数为230,由于该值位于第二个区间,所以此时就会选择实例B来进行请求。
该策略较为特殊,我们一般不直接使用它。因为它本身并没有实现什么特殊的处理逻辑,正如下面的源码所示,在它的内部定义了一个RolundRobinRule策略,而choose函数的实现也正是使用了RoundRobinRule的线性轮询机制,所以它实现的功能实际上与RoundRobinRule相同
那么定义它有什么特殊的用处呢?
虽然我们不会直接使用该策略,但是通过继承该策略,默认的choose就实现了线性轮询机制,在子类中做一些高级策略时通常有可能会存在一些无法实施的情况,那么就可以用父类的实现作为备选。在后文中我们将继续介绍的高级策略均是基于ClientConfigEnabledRoundRobinRule的扩展。
该策略继承自clientconfigEnabledRoundRobinRule,在实现中它注入了负载均衡器的统计对象LoadBalancerStats,同时在具体的 choose 算法中利用LoadBalancerStats保存的实例统计信息来选择满足要求的实例。
通过遍历负载均衡器中维护的所有服务实例会过滤掉故障的实例,并找出并发请求数最小的一个,所以该策略的特性是可选出最空闲的实例。
这是一个抽象策略,它也继承了clientconfigEnabledRoundRobinRule,从其命名中可以猜出这是一个基于 Predicate 实现的策略,Predicate 是Google GuavaCollection工具对集合进行过滤的条件接口。
该策略我们在介绍负载均衡器ZoneAwareLoadBalancer 时已经提到过,它也是PredicateBasedRule的具体实现类。在之前的介绍中主要针对ZoneAvoidanceRule中用于选择Zone区域策略的一些静态函数,比如createSnapshot、getAvailableZones。
在这里我们将详细看看ZoneAvoidanceRule作为服务实例过滤条件的实现原理。
它使用了CompositePredicate来进行服务实例清单的过滤。这是一个组合过滤条件,在其构造函数中,它以ZoneAvoidancePredicate为主过滤条件,AvailabilityPredicate为次过滤条件初始化了组合过滤条件的实例。
由于Ribbon中定义的每一个接口都有多种不同的策略实现,同时这些接口之间又有一定的依赖关系,这便使得第一次 使用Ribbon的开发者很难上手,不知道如何选择具体的实现策略以及如何组织他们之间的关系。spring Cloud Ribbon中的自动化配置恰恰能够解决这样的痛点,在引入Spring Cloud Ribbon的依赖之后,就能够自动化构建下面这些接口的实现。
注意 :上面这些自动化配置内容仅在没有引入Spring Cloud Eureka等服务时才会如此配置,在同时引入 Eureka和 Ribbon依赖时,自动化配置会有一些不同,后续我会做详细的介绍。
比如下面的配置内容,由于创建了PingUrl实例,所以默认的NoOpPing就不会被创建。
@Configuration
public class MyRibbonConfiguration {
@Bean
public IPing ribbonPing(IClientConfig clientConfig){
return new PingUrl();
}
}
另外,也可以通过使用@Ribbonclient注解来实现更细粒度的客户端配置,比如下面的代码实现了为hello-service服务使用MyRibbonConfiguration中的配置
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-netflix-ribbonartifactId>
<version>2.2.6.RELEASEversion>
dependency>
@Configuration
@RibbonClient(name = "hello-service", configuration = MyRibbonConfiguration.class)
public class RibbonConfiguration {
}
上面我们介绍了在 Brixton版本中对RibbonClient的IPing、IRule等接口实现进行个性化定制的方法,主要通过独立创建一个configuration类来定义IPing、IRule等接口的具体实现 Bean,然后在创建RibbonClient时指定要使用的具体configuration类来覆盖自动化配置的默认实现。虽然这种方式已经能够实现个性化的定义,但是当有大量这类配置的时候,对于各个RibbonClient 的指定配置信息都将分散在这些配置类的注解定义中,这使得管理和修改都变得非常不方便。所以,在Camden版本中,Spring Cloud Ribbon对RibbonClient定义个性化配置的方法做了进一步优化。可以直接通过< clientName>.ribbon.< key>=< value>的形式进行配置。
比如我们要实现与上面例子一样的配置(将hello-service服务客户端的IPing 接口实现替换为PingUrl),只需在application.properties 配置中增加下面的内容即可:
hello-service.ribbon.NELoadBalancerPingClassName=com.netflix.1oadbalancer.PingUrl
其中hello-service为服务名
对于Ribbon的参数配置通常有两种方式:全局配置以及指定客户端配置。
全局配置的方式很简单,只需使用ribbon.< key>=< value>格式进行配置即可。其中,< key>代表了Ribbon客户端配置的参数名,< value>则代表了对应参数的值。比如,我们可以像下面这样全局配置Ribbon创建连接的超时时间:
ribbon.ConnectTimeOut=200
全局配置可以作为默认值进行设置,当指定客户端配置了相应key的值时,将覆盖全局配置的内容
指定客户端的配置方式采用< client>.ribbon.< key>=< value>的格式进行配置。其中,< key>和< value>的含义同全局配置相同,而< client>代表了客户端的名称,如上文中我们在@Ribbonclient中指定的名称,也可以将它理解为是一个服务名。
为了方便理解这种配置方式,我们举一个具体的例子:假设,有一个服务消费者通过RestTemplate来访问hello-service 服务的/hello接口,这时我们会这样调用restTemplate.getForEntity (“http: //hello-service/hello”,string.class).getBody();。
如果没有服务治理框架的帮助,我们需要为该客户端指定具体的实例清单,可以指定服务名来做详细的配置,具体如下:
hello-service.ribbon.listOfServers=localhost:8001,localhost:8002, 1ocalhost:8003
对于Ribbon参数的key以及 value类型的定义,可以通过查看com.netflix.client.config.CommonclientConfigKey类获得更为详细的配置内容
当在Spring Cloud的应用中同时引入Spring Cloud Ribbon和 Spring Cloud Eureka依赖时,会触发Eureka 中实现的对Ribbon的自动化配置。
在与Spring Cloud Eureka结合使用的时候,我们的配置将会变得更加简单。不再需要通过类似hello-service.ribbon.listofServers 的参数来指定具体的服务实例清单,因为 Eureka将会为我们维护所有服务的实例清单。
而对于Ribbon的参数配置,我们依然可以采用之前的两种配置方式来实现,而指定客户端的配置方式可以直接使用 Eureka中的服务名作为< client>来完成针对各个微服务的个性化配置。
此外,由于Spring Cloud Ribbon 默认实现了区域亲和策略,所以,我们可以通过Eureka实例的元数据配置来实现区域化的实例配置方案。比如,可以将处于不同机房的实例配置成不同的区域值,以作为跨区域的容错机制实现。而实现的方式非常简单,只需在服务实例的元数据中增加zone参数来指定自己所在的区域,比如:
eureka.instance.metadata-map.zon=shanghai
在Spring Cloud Ribbon 与Spring Cloud Eureka结合的工程中,我们也可以通过参数配置的方式来禁用Eureka对Ribbon服务实例的维护实现。只需在配置文件中加入如下参数,这时我们对于服务实例的维护就又将回归到使用< client>.ribbon.listOfServers参数配置的方式来实现了。
ribbon.eureka.enabled=false
由于Spring Cloud Eureka实现的服务治理机制强调了CAP原理中的AP,即可用性与可靠性,它与ZooKeeper这类强调CP(一致性、可靠性)的服务治理框架最大的区别就是,Eureka为了实现更高的服务可用性,牺牲了一定的一致性,在极端情况下它宁愿接受故障实例也不要丢掉“健康”实例,比如,当服务注册中心的网络发生故障断开时,由于所有的服务实例无法维持续约心跳,在强调AP的服务治理中将会把所有服务实例都剔除掉,而 Eureka则会因为超过85%的实例丢失心跳而会触发保护机制,注册中心将会保留此时的所有节点,以实现服务间依然可以进行互相调用的场景,即使其中有部分故障节点,但这样做可以继续保障大多数的服务正常消费。
由于Spring Cloud Eureka在可用性与一致性上的取舍,不论是由于触发了保护机制还是服务剔除的延迟,引起服务调用到故障实例的时候,我们还是希望能够增强对这类问题的容错。所以,我们在实现服务调用的时候通常会加入一些重试机制。
以我们之前对hello-service服务的调用为例,可以在配置文件中增加如下内容:
# 该参数用来开启重试机制,它默认是关闭的。
spring.cloud.loadbalancer.retry.enabled=true
# 请求连接的超时时间
hello-service.ribbon.ConnectTimeout=250
# 请求处理的超时时间
hello-service.ribbon.Readtimeout=1000
# 对所有操作请求都进行重试
hello-service.ribbon.OkToRetryOnA11Operations=true
# 切换实例的重试次数
hello-service.ribbon.MaxAutORetriesNextServer=2
# 对当前实例的重试次数
hello-service.ribbon.MaxAutoRetries=1
下一篇:《SpringCloud05—服务容错保护:Spring Cloud Hystrix》