【微服务专题】深入理解与实践微服务架构(九)之手写负载均衡算法与Ribbon优化

1,171 阅读43分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第9篇文章,点击查看活动详情

本文接上篇,下面是对Ribbon组件的相关优化:

8. 自定义负载均衡策略

Ribbon 组件来自 Netflix,它的定位是一款用于提供客户端负载均衡的工具软件。Ribbon 会自动地基于某种内置的负载均衡算法去连接服务实例,我们也可以设计并实现自定义的负载均衡算法并嵌入 Ribbon 中。同时,Ribbon 客户端组件提供了一系列完善的辅助机制用来确保服务调用过程的可靠性和容错性,包括连接超时和重试等。Ribbon 是客户端负载均衡机制的典型实现方案,所以需要嵌入在服务消费者的内部进行使用。

因为 Netflix Ribbon 本质上只是一个工具,而不是一套完整的解决方案,所以 Spring Cloud Netflix Ribbon 对 Netflix Ribbon 做了封装和集成,使其可以融入以 Spring Boot 为构建基础的技术体系中。

几大负载均衡策略

常见的5种负载均衡实现策略,如下所示:

  • 随机 (Random)
  • 轮询 (RoundRobin)
  • 一致性哈希 (ConsistentHash)
  • 哈希 (Hash)
  • 加权(Weighted)

Ribbon支持的7大负载均衡策略

策略名描述
RoundRobinRule轮询策略:按照顺序选择server(默认策略)。
RandomRule随机策略:随机选择server。
RetryRule重试策略:先按照RoundRobinRule的策略获取服务,如果获取服务失败则在指定时间内会进行重试,获取可用的服务。
WeightedResponseTimeRule响应时间加权重策略:对RoundRobinRule 的拓展,响应速度越快的实例选择权重越大,越容易被选择。
BestAvailableRule最低并发策略:会先过滤掉由于多次访问故障而处断路器跳闸状态的服务,然后选择一个并发量最小的服务。
AvailabilityFilteringRule可用过滤策略:先过滤掉故障实例,在选择并发较小的实例。
ZoneAvoidanceRule区域权重策略:综合判断server所在区域的性能和区域中server的可用性;轮询选择server并且判断一个AWS Zone的运行性能是否可用,剔除不可用的Zone中的所有server。

基于Ribbon,通过注解就能简单实现在面向服务的接口调用中,自动集成负载均衡功能,使用方式主要包括以下三种:

  • 使用 @LoadBalanced 注解。

@LoadBalanced 注解用于修饰发起 HTTP 请求的 RestTemplate 工具类,并在该工具类中自动嵌入客户端负载均衡功能。开发人员不需要针对负载均衡做任何特殊的开发或配置。

@LoadBalanced的实现原理(LoadBalancerInterceptor拦截器)

  1. RestTemplate在发送请求的时候会被ClientHttpRequestInterceptor拦截,LoadBalancerInterceptor是ClientHttpRequestInterceptor的实现类,它的作用就是用于RestTemplate的负载均衡,LoadBalancerInterceptor将负载均衡的核心逻辑交给了loadBalancer。
  2. @LoadBalanced注解是属于Spring Cloud Commons,而不是Ribbon的,Spring在初始化容器的时候,如果检测到Bean被@LoadBalanced注解,Spring会为其设置LoadBalancerInterceptor的拦截器。
  • 使用 @RibbonClient 注解。

Ribbon 还允许你使用 @RibbonClient 注解来完全控制客户端负载均衡行为。这在需要定制化负载均衡算法等某些特定场景下非常有用,我们可以使用这个功能实现更细粒度的负载均衡配置。

  • 使用 DiscoveryClient 获取服务实例信息

理解 Ribbon 与 DiscoveryClient

可以通过 Nacos提供的 HTTP 端点获取服务的详细信息。基于这一点,假如现在没有 Ribbon 这样的负载均衡工具,我们也可以通过代码在运行时实时获取注册中心中的服务列表,并通过服务定义并结合各种负载均衡策略动态发起服务调用。这就是使用公共抽象组件DiscoveryClient 最大的好处!

@RibbonClient注解,适用于自定义服务负载均衡策略的细粒度场景

创建Ribbon负载均衡调用类

创建RibbonController

package com.deepinsea.controller;
​
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
​
/**
 * Created by deepinsea on 2022/6/5.
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class RibbonController {
​
    @Autowired
    private RestTemplate restTemplate;
​
    private static final String URL = "http://service-provider-nacos/provider-nacos/hello";
​
    @PostMapping("/testRibbon")
    public String testRibbon(){ //@RibbonClient未指定具体服务的负载算法时,默认轮询策略
        String result = restTemplate.getForObject(URL, String.class);
        return "Ribbon返回结果:" + result;
    }
}

注意:还是需要使用@LoadBalanced开启负载均衡

我们使用curl命令进行测试,没有使用@RibbonClient指定具体服务的负载均衡策略时的场景:

C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-nacos!

可以看到,直接调用采用的是默认的round-robin轮询负载算法

我们优化一下代码,通过负载均衡调用公共抽象客户端LoadBalancerClient来进行调用(不用开启@LoadBalanced):

package com.deepinsea.controller;
​
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
​
/**
 * Created by deepinsea on 2022/6/5.
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class RibbonController {
​
    @Autowired //通过绑定@LoadBalanced开启服务名调用
    private RestTemplate restTemplate;
​
    @Autowired //IP调用
    private RestTemplate byIpRestTemplate;
​
    @Autowired
    LoadBalancerClient loadBalancerClient;
​
    private static final String URL = "http://service-provider-nacos/provider-nacos/hello";
​
    @PostMapping("/testRibbon") //需要开启@LoadBalanced
    public String testRibbon(){ //@RibbonClient未指定具体服务的负载算法时,默认轮询策略
        String result = restTemplate.getForObject(URL, String.class);
        return "Ribbon返回结果:" + result;
    }
​
    @PostMapping("/testRibbonRule") //不依赖于@LoadBalanced注解的负载均衡调用
    public String testRibbonRule(){ //@RibbonClient未指定具体服务的负载算法时,默认轮询策略
        ServiceInstance serviceInstance = loadBalancerClient.choose("service-provider-nacos");
        String result = byIpRestTemplate.getForObject(serviceInstance.getUri()+"/provider-nacos/hello", String.class);
        return "Ribbon返回结果:" + result;
    }
}

同样的,重新启动项目然后使用curl调用接口,依然可以看到我们的方法调用成功。其实这两种方式都可以自动绑定负载均衡策略,两种方式调用都是一样的;只不过第二种LoadBalancerClient的方式不依赖具体的负载均衡组件,并且不用开启@LoadBalanced注解。

调用类准备好了,下面以表格的形式,总结一下Ribbon支持的7种负载均衡算法:

Ribbon支持的的7种负载均衡策略

Ribbon 核心组件IRule : com.netflix.loadbalancer.IRule ,相关的负载均衡算法 , 均在 com.netflix.loadbalancer 包下:

策略名描述
RoundRobinRule轮询策略:按照顺序选择server(默认策略)。
RandomRule随机策略:随机选择server。
RetryRule重试策略:先按照RoundRobinRule的策略获取服务,如果获取服务失败则在指定时间内会进行重试,获取可用的服务。
WeightedResponseTimeRule响应时间加权重策略:对RoundRobinRule 的拓展,响应速度越快的实例选择权重越大,越容易被选择。
BestAvailableRule最低并发策略:会先过滤掉由于多次访问故障而处断路器跳闸状态的服务,然后选择一个并发量最小的服务。
AvailabilityFilteringRule可用过滤策略:先过滤掉故障实例,在选择并发较小的实例。
ZoneAvoidanceRule区域权重策略:综合判断server所在区域的性能和区域中server的可用性;轮询选择server并且判断一个AWS Zone的运行性能是否可用,剔除不可用的Zone中的所有server。

了解了这些算法的功能和适用场景之后,我们下面自定义Ribbon的负载算法(不需要改变RibbonController的调用过程):

更改负载均衡规则

这里只是更改默认规则的两种方式,但是并没有自定义我们自己的负载均衡算法,因此没有打印自定义负载规则的提示日志,下面会自定义自己的负载算法。

① 配置类方式

首先创建RibbonRuleConfig 自定义负载均衡算法配置类:

package com.deepinsea.common.config;
​
import com.netflix.loadbalancer.IRule;
import com.netflix.loadbalancer.RandomRule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
​
/**
 * Created by deepinsea on 2022/5/29.
 * 自定义负载均衡策略配置类
 */
@Configuration
public class RibbonRuleConfig {
​
    /**
     * 自定义负载均衡规则
     * RandomRule 随机选择
     * @return IRule
     */
    @Bean
    public IRule MyRule(){
        // 随机选择rule规则
        return new RandomRule();
    }
}

然后创建RibbonClient配置类,指定需要调用的服务对应的负载均衡配置:

基于 @RibbonClients@RibbonClient 注解的配置

全局和局部负载均衡策略配置

package com.deepinsea.common.config;
​
import org.springframework.cloud.netflix.ribbon.RibbonClient;
​
/**
 * Created by deepinsea on 2022/6/5.
 * RibbonClient配置类(负载均衡算法)
 */
// 配置多个和单个的服务的负载均衡策略
//@RibbonClients(value = {@RibbonClient(name = "service-provider-nacos",configuration = RibbonRuleConfig.class)}, defaultConfiguration = RibbonRuleConfig.class)
@RibbonClient(name = "service-provider-nacos",configuration = RibbonRuleConfig.class)
public class RibbonClientConfig {
}

注意:编写自定义配置类,需要特别注意的是官方文档明确给出了警告:这个自定义配置类不能放在@ComponentScan所扫描的包以及其子包下(即不能放在主启动类所在的包及其子包下,因此我们需要新建一个包来放该配置类),否则我们自定义的这个配置类就会被所有的Ribbon客户端所共享,也就达不到特殊化定制的目的了。

而在启动类中有一个@SpringBootApplication注解,其中组合了@ComponentScan注解,这个注解是用来扫猫@Component的,包括@Configuration注解,扫猫的分为当前启动类所在的包以及启动类所在包下面的所有的@Component。

  而Spring的上下文是树状的上下文,@SpringBootApplication所扫猫的上下文是主上下文;而ribbon也会有一个上下文,是子上下文。而父子上下文扫猫的包一旦重叠,会导致很多问题【会导致配置被共享】,所以ribbon的配置类一定不能被启动类扫猫到,RibbonRuleConfig包一定要在启动类所在的包以外。

几大注解和对应参数的作用:

@RibbonClients:声明多个服务和对应的配置@RibbonClient:声明单个调用服务的配置
value:单个服务的配置name:调用的服务名称
defaultConfiguration:全局服务默认负载均衡策略配置类configuration:自定义负载均衡配置类名称(@Configuration注解声明)

当然,你也可以在启动类使用@RibbonClient进行服务的负载均衡策略进行配置。

package com.deepinsea;
​
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
​
/**
 * Created by deepinsea on 2022/5/27.
 * Ribbon主启动类
 */
@SpringBootApplication
@EnableDiscoveryClient
//@RibbonClient(name = "service-provider-nacos",configuration = RibbonRuleConfig.class)
public class ServiceRibbonApplication {
    public static void main(String[] args) {
        SpringApplication.run(ServiceRibbonApplication.class, args);
    }
}

效果是一样的,两种二选一即可。当然我更推荐配置类的方式,因为启动类不用加这么多注解,将配置文件放在一起方便管理。

下面使用curl命令进行请求测试:

C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!

可以看到,确实是随机负载均衡策略进行请求的,中间两个负载均衡的服务接口请求比例也不一致。

当然,也可以使用 JMeter压测一下接口,然后查看生成的接口请求报表情况来验证。

扩展:@Controller等Spring注解是如何生效的?

结论:这是依赖于Spring的@Component、和SpringBoot的@ComponentScan快速注入Bean工厂的一种方式:是通过注解内部的@Component注解,标注为Spring管理的Bean,然后通过@SpringBootApplication复合注解中的@ComponentScan扫描进Spring IOC容器中。

通过阅读@SpringBootApplication源码可知,@SpringBootApplication启动类注解帮我们做三件事(排除Java元注解后):

  • @SpringBootConfiguration:标识启动类是配置类
  • @EnableAutoConfiguration:自动配置
  • @ComponentScan:包扫描

run方法传入了启动类的字节码文件ServiceRibbonApplication.class,也就是说,run方法初始化bean容器之后会去扫描启动类上的注解。@SpringBootApplication里面的包扫描和我们自定义的包扫描注解不一样,自定义注解是我们可以指定扫描我们用的到的模块的工具包。而启动类注解自带的包扫描的注解会扫描启动类及其所在包的子包里面的所有类的注解 — @Service,@Mapper,@Controller等等。通过@ComponentScan包扫描注解,我们可以将@Controller等注解声明的类加载到Spring容器中。

@RibbonClient注解加在普通类中和启动类中是如何生效的?

结论这是另外一种方式,@Import和实现ImportBeanDefinitionRegistrar接口注册到容器:通过Import注解导入RibbonClientConfigurationRegistrar注册类( (@Import仅仅是引入,不会被Spring容器管理) ),然后实现ImportBeanDefinitionRegistrar接口后将@RibbonClient声明的类自动注册到Spring容器中。这种方式,相当于不依赖于spring提供的注解注入容器,可以通过带有我们特定标识(比如: @MyService)的类加载到Spring容器中。

个人认为主要流行的有以上两种用法,第一种是在注解上去import资源,和@Configuration区别不大。第二种是用import管理所有的@Configuration配置类,保证@Configuration本身是按照功能、业务、职责独立划分的。所以第二种用法Import参数可以填一组@Configuration修饰的Class对象。

通过spring bean容器可视化工具可以看到RibbonClientConfig已经加载到Spring容器中了,并且通过类继承关系图可以看到,RibbonClientConfig配置类通过@RibbonClient注解生成了RibbonClientConfigurationRegistrar对象:

image-20220606022147539

那么我们了解了:应该是RibbonClientConfigurationRegistrar对象将RibbonClientConfig类注册到BeanFactory容器工厂中的。

我们点开@RibbonClient注解,可以看到使用import注解导入了RibbonClientConfigurationRegistrar类:

@Import注解一般可以配合ConfigurationImportSelector以及ImportBeanDefinitionRegistrar来使用

image-20220606023033810

我们点开RibbonClientConfigurationRegistrar类可以看到,类内部实现了ImportBeanDefinitionRegistrar注册器:

image-20220606023122473

我们知道:ImportBeanDefinitionRegistrar接口是也是spring的扩展点之一,它可以支持我们自己写的代码封装成BeanDefinition对象;实现此接口的类会回调postProcessBeanDefinitionRegistry方法,注册到spring容器中。把bean注入到spring容器不止有 @Service @Component等注解方式,还可以实现此接口。

因此, @RibbonClient能自动将声明的类自动注册到BeanFacttory容器工厂中

② 配置文件属性方式
# ribbon调用服务名
service-provider-nacos: 
  ribbon:
    # 局部自定义负载均衡策略
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule

用属性配置clientName.ribbon时,有如下属性:

  • NFLoadBalancerClassName:ILoadBalancer实现类
  • NFLoadBalancerRuleClassName:IRule实现类
  • NFLoadBalancerPingClassName:IPing实现类
  • NIWSServerListClassName:ServerList实现类
  • NIWSServerListFilterClassName:ServerListFilter实现类

启动项目后,注释配置类指定的自定义负载策略,使用curl请求负载接口进行测试:

C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testRibbon
Ribbon返回结果:hi, this is service-provider-api!

可以看到,自定义随机负载均衡策略成功生效!

两种配置方式对比
配置方式优点缺点
代码配置基于代码,更加灵活有小坑(父子上下文) 线下修改得重新打包,发布
属性配置易上手 配置更加直观 线上修改无需重新打包 发布优先级更高极端场景下没有代码配置方式灵活

ribbon配置负载策略父子上下文问题

原因:配置项父子上下文扫描重叠,即Ribbon上下文和启动类主上下文扫描重叠!

解决

  • Spring Cloud中新增负载均衡Ribbon配置项不要在启动项同级包目录,不能在@SpringBootApplication中的@ComponentScan范围内,从其包名上分离;
  • 注意避免包扫描重叠,最好的方法是明确的指定包名;
  • 建议RibbonRuleAutoConfiguration配置项在项目common包下。

扩展

common包推荐结构:

  • annotation
  • configuration
  • filter
  • intercepter
  • predicate
  • rule
  • support
  • vo

总结

  1. 尽量使用属性配置,属性方式实现不了的情况下再考虑用代码配置;
  2. 在同一个微服务内尽量保持单一性,比如统一使用属性配置,不要两种方式混用,增加定位代码的复杂性。

手写负载均衡算法

Ribbon默认负载算法原理

RoundRobinRule源码分析:

public class RoundRobinRule extends AbstractLoadBalancerRule {
​
    private AtomicInteger nextServerCyclicCounter;
    private static final boolean AVAILABLE_ONLY_SERVERS = true;
    private static final boolean ALL_SERVERS = false;
​
    private static Logger log = LoggerFactory.getLogger(RoundRobinRule.class);
​
    public RoundRobinRule() {
        nextServerCyclicCounter = new AtomicInteger(0);
    }
​
    public RoundRobinRule(ILoadBalancer lb) {
        this();
        setLoadBalancer(lb);
    }
​
    //重点关注这方法。
    public Server choose(ILoadBalancer lb, Object key) {
        if (lb == null) {
            log.warn("no load balancer");
            return null;
        }
​
        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 up servers available from load balancer: " + lb);
                return null;
            }
            //获得当前需要调用的服务的索引
            int nextServerIndex = incrementAndGetModulo(serverCount);
            //从服务列表中根据索引取出对应的服务
            server = allServers.get(nextServerIndex);
​
            if (server == null) {
                /* Transient. */
                Thread.yield();
                continue;
            }
​
            if (server.isAlive() && (server.isReadyToServe())) {
                return (server);
            }
​
            // Next.
            server = null;
        }
​
        if (count >= 10) {
            log.warn("No available alive servers after 10 tries from load balancer: "
                    + lb);
        }
        return server;
    }
​
    /**
     * Inspired by the implementation of {@link AtomicInteger#incrementAndGet()}.
     *
     * @param modulo The modulo to bound the value of the counter.
     * @return The next value.
     */
     //具体的轮询算法实现
    private int incrementAndGetModulo(int modulo) {
        for (;;) {
            int current = nextServerCyclicCounter.get();
            int next = (current + 1) % modulo;//求余法
            //自旋锁,防止并发带来的问题
            //只有当原子变量的值与current 值一致时,才会返回对应的next,否则说明存在并发获取的问题
            //那么继续找
            if (nextServerCyclicCounter.compareAndSet(current, next))
                return next;
        }
    }
​
    @Override
    public Server choose(Object key) {
        return choose(getLoadBalancer(), key);
    }
​
    @Override
    public void initWithNiwsConfig(IClientConfig clientConfig) {
    }
}

可以看到,默认的轮询算法的实现为:

Rest接口第几次请求数 % 服务器集群总数量 = 实际调用服务器位置下标。

且每次服务重启动后rest接口计数从1开始。

例如: 服务集群数:2(端口分别为8001,8002) List = 2 Instance(list下标从0开始,所以服务集群下标依次为0(8001),1(8002)) 1(第一次请求)% 2(服务集群数)=1(下标) List.get(下标); 8002服务器 2(第二次请求)% 2(服务集群数)=0(下标) List.get(下标); 8001服务器 3(第三次请求)% 2(服务集群数)=1(下标) List.get(下标); 8002服务器

…...

虽然实际上可能不会存储请求次数作为计数器(可能会OOM),但是实现思路大致是这样。

自定义轮询算法实现

自己试着写一个类似RoundRobinRule的本地负载均衡器。

实现思路

考虑到要与负载均衡以及注册中心解耦,因此采用spring-cloud-commons包下的DiscoveryClient获取注册中心的服务信息

  • 总的实现思路

Rest接口第几次请求数 % 服务器集群总数量 = 实际调用服务器位置下标。

  • 具体实现思路

首先获取服务下的服务列表和列表中的服务信息,可通过DiscoveryClient获取:

List<Servicelnstance> instances = discoveryClient.getInstances("service-provider-nacos");

然后采用 JUC包的CAS+自旋锁操作实现多线程下请求并发控制:

定义一个全局变量记录请求数量,将获取到的集群存入list中并通过计算的下标获取需要调用的服务器进行调用即可完成轮询操作。

手写随机负载算法

快速实现一个随机算法(注释掉@LoadBalanced和@RibbonClient注解,确保关闭了Ribbon自带的负载均衡算法)

在RibbonController中添加如下代码:

    @Autowired
    DiscoveryClient discoveryClient;
​
    @PostMapping("/myLoadBalancer")
    public String myLoadBalancer() { //快速实现自定义随机算法调用
        RestTemplate restTemplate = new RestTemplate();
​
        // 获取请求示例
        List<ServiceInstance> instances = discoveryClient.getInstances("service-provider-nacos");
        List<String> collect = instances.stream()
                .map(instance -> instance.getUri().toString() + "/provider-nacos/hello")
                .collect(Collectors.toList());
        // 随机算法
        int i = ThreadLocalRandom.current().nextInt(collect.size());
        String targetURL = collect.get(i);
​
        Logger logger = LoggerFactory.getLogger(RibbonController.class);
        logger.info("请求的目标地址: {}", targetURL);
​
        String forObject = restTemplate.getForObject(targetURL, String.class);
        return forObject;
    }

使用curl命令进行测试:

C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/myLoadBalancer
hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/myLoadBalancer
hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/myLoadBalancer
hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/myLoadBalancer
hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/myLoadBalancer
hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/myLoadBalancer
hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/myLoadBalancer
hi, this is service-provider-nacos!

控制台日志为:

2022-06-08 09:01:41.972  INFO 35544 --- [nio-9050-exec-1] c.deepinsea.controller.RibbonController  : 
请求的目标地址: http://192.168.174.1:9010/provider-nacos/hello
2022-06-08 09:01:43.151  INFO 35544 --- [nio-9050-exec-3] c.deepinsea.controller.RibbonController  : 
请求的目标地址: http://192.168.174.1:9040/provider-nacos/hello
2022-06-08 09:01:44.064  INFO 35544 --- [nio-9050-exec-4] c.deepinsea.controller.RibbonController  : 
请求的目标地址: http://192.168.174.1:9010/provider-nacos/hello
2022-06-08 09:01:44.866  INFO 35544 --- [nio-9050-exec-5] c.deepinsea.controller.RibbonController  : 
请求的目标地址: http://192.168.174.1:9040/provider-nacos/hello
2022-06-08 09:01:45.699  INFO 35544 --- [nio-9050-exec-6] c.deepinsea.controller.RibbonController  : 
请求的目标地址: http://192.168.174.1:9010/provider-nacos/hello
2022-06-08 09:01:46.658  INFO 35544 --- [nio-9050-exec-8] c.deepinsea.controller.RibbonController  : 
请求的目标地址: http://192.168.174.1:9010/provider-nacos/hello
2022-06-08 09:01:47.462  INFO 35544 --- [nio-9050-exec-9] c.deepinsea.controller.RibbonController  : 
请求的目标地址: http://192.168.174.1:9010/provider-nacos/hello

可以看到,成功手写了一个随机调用的负载均衡算法!

手写轮询算法与加权轮询算法

详细负载均衡算法实践(各种手写负载均衡算法,生产级可以适当解耦与抽象)

1.首先创建AbstractLoadBalancer负载均衡公共抽象接口

package com.deepinsea.common.loadbalancer;
​
import org.springframework.cloud.client.ServiceInstance;
​
import java.util.List;
​
/**
 * Created by deepinsea on 2022/6/11.
 * 负载均衡算法抽象接口
 */
public interface AbstractLoadBalancer {
​
    /**
     * 轮询算法
     */
    ServiceInstance round(List<ServiceInstance> instances);
​
    /**
     * 随机算法
     */
    ServiceInstance random(List<ServiceInstance> instances);
​
    /**
     * 加权轮询算法
     */
    ServiceInstance weight(List<ServiceInstance> instances, int... weight);
​
    /**
     * 随机权重算法
     */
    ServiceInstance weightRandom(List<ServiceInstance> instances, int... weight);
​
    /**
     * 权重优先算法
     */
    ServiceInstance weightFirst(List<ServiceInstance> instances, int... weight);
​
    /**
     * IP哈希算法
     */
    ServiceInstance ipHash(List<ServiceInstance> instances, int... weight);
​
    /**
     * HRL哈希算法
     */
    ServiceInstance urlHash(List<ServiceInstance> instances, int... weight);
​
}

2.然后创建负载均衡实现类CoolLoadBalancer实现该抽象接口

package com.deepinsea.common.loadbalancer;
​
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.stereotype.Component;
​
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicInteger;
​
/**
 * Created by deepinsea on 2022/6/9.
 * AtomicInteger+CAS+自旋实现轮询算法
 */
@Component
public class CoolLoadBalancer implements AbstractLoadBalancer {
    private AtomicInteger nextServerIndexCounter; // 服务下标原子计数器
    private static Logger log = LoggerFactory.getLogger(CoolLoadBalancer.class); //slf4j日志
​
    public CoolLoadBalancer() { //每次创建一个负载规则,都为对应的方服务设置一个初始值为0的服务下标计数器
        this.nextServerIndexCounter = new AtomicInteger(0);
    }
​
    // CAS操作更新下标(返回自增后的值)
    private int incrementAndGet(int allServerCount) { //服务总数从discoveryClient.size()获取
        int current; //假如有三台机器,则下标为0,1,2
        int next; //1,2,0
        do {
            current = this.nextServerIndexCounter.get(); //获取当前内存中原子变量的值
            next = (current + 1) % allServerCount; //每次当前值+1然后取余总数,这样相当于标记了下一次请求的机器
        } while (!this.nextServerIndexCounter.compareAndSet(current, next));
        // CAS更新成功,就跳出循环并设置原子变量值为当前时刻线程get的值;否则,就死循环(自旋)执行CAS操作(当然,内部或者外部控制次数)
        // 记住do-while加!,for(;;)中的while无需加!
        // CAS有3个操作数: 内存值V,旧的预期值A,要修改的新值B
        // 当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做
        // CAS更新成功(则说明),就跳出循环并无任何操作;否则,就死循环(自旋)执行CAS操作(当然,内部或者外部控制次数),并返回next
        return next; //返回下一次请求的机器下标
    }
​
    // 轮询算法
    public ServiceInstance round(List<ServiceInstance> instances) {
        if (instances != null || instances.size() != 0) {
            ServiceInstance serviceInstance = null;
            int count = 0; //初始化cas自旋次数
            if (serviceInstance == null && count++ <= 20) { //根据index获取服务信息主代码
                int index = incrementAndGet(instances.size()); //获取索引下标
                serviceInstance = instances.get(index);
                log.info("Current server index is " + index
                        + ", serviceId is " + serviceInstance.getServiceId()
                        + ", service info is " + serviceInstance.getHost() + ":" + serviceInstance.getPort());
                return serviceInstance;
            } else if (count > 20) {
                log.warn("No alive server after cas spinning over 20 times");
            }
​
            return serviceInstance; //效果等同于在if(count > 20)语句内部
        } else {
            log.warn("No available Server from loadbalancer: " + instances);
            return null;
        }
    }
​
    /**
     * 随机算法
     * ThreadLocalRandom:是JDK 7之后提供并发产生随机数,能够解决多个线程发生的竞争争夺。
     * ThreadLocalRandom不是直接用new实例化,而是第一次使用其静态方法current()。
     * 从Math.random()改变到ThreadLocalRandom有如下好处:我们不再有从多个线程访问同一个随机数生成器实例的争夺。
     */
    public ServiceInstance random(List<ServiceInstance> instances) {
        if (Optional.ofNullable(instances).isPresent()) {
            ServiceInstance serviceInstance = null;
            int count = 0; //初始化cas自旋次数
            if (serviceInstance == null && count++ <= 20) { //根据index获取服务信息主代码
                int index = ThreadLocalRandom.current().nextInt(instances.size()); //随机获取索引下标
                serviceInstance = instances.get(index);
//                log.info("Service " + serviceInstance.getServiceId() + " is using random rule");
                log.info("Current server index is " + index
                        + ", service:" + serviceInstance.getServiceId()
                        + ", info:" + serviceInstance.getHost() + ":" + serviceInstance.getPort());
                return serviceInstance;
            } else if (count > 20) {
                log.warn("No alive server after cas spinning over 20 times");
            }
​
            return serviceInstance; //效果等同于在if(count > 20)语句内部
        } else {
            log.warn("No available Server from loadbalancer: " + instances);
            return null;
        }
    }
​
    private AtomicInteger nextDomainIndex = new AtomicInteger(0); //记录主机下标
    private AtomicInteger nextRequestCounter = new AtomicInteger(0); //记录当前主机请求
​
    // CAS操作更新加权服务下标
    @Deprecated
    private int incrementAndGetWeight(int serverCount, int[] weight) {
        //一开始是想使用两个原子变量存储主机下标index和请求数req进行更新实现,但是发现需要对两个原子变量进行if嵌套判断
        //这就导致了CAS中两个原子变量更新并不能保证整体更新的问题了(CAS只能保证一个原子变量的共享操作)
        //因此改用为CurrentHashMap存储主机index和请求数req
        //假如权重为4,3,2
        int currentIndex; //假如有三台机器,则下标为0,1,2
        int nextIndex;//1,2,3
        int currentReq; //假如当前主机权重为4,则当前请求数为0,1,2,3
        int nextReq; //1,2,3,4
        //更新请求数
        do {
            currentIndex = this.nextDomainIndex.get();
            currentReq = this.nextRequestCounter.get();
            nextIndex = currentIndex;
            nextReq = currentReq + 1;
            //当请求数大于当前主机数,并且主机下标小于最大主机数-1时
            if (nextReq > weight[this.nextDomainIndex.get()] && currentIndex + 1 < serverCount) {
                System.out.println("进入下标更新流程...");
                //更新主机下标
                do {
                    currentIndex = this.nextDomainIndex.get();
                    nextIndex = currentIndex + 1; //切换到下一个主机
                    this.nextRequestCounter.set(0); //重置请求数为0
                } while (!this.nextDomainIndex.compareAndSet(currentIndex, nextIndex));
            }
            //当请求数大于当前主机数,并且主机下标等于最大主机数-1时
            if (nextReq > weight[this.nextDomainIndex.get()] && currentIndex + 1 == serverCount) {
                System.out.println("进入下标和请求数双重更新流程...");
                //重置全局主机下标和请求数
                this.nextDomainIndex.set(0);
                this.nextRequestCounter.set(0);
                nextIndex = this.nextDomainIndex.get();
//                System.out.println("currentIndex is " + currentIndex);
//                System.out.println("currentReq is " + currentReq);
//                System.out.println("nextIndex is " + nextIndex);
//                System.out.println("nextReq is " + nextReq);
            }
        } while (!this.nextRequestCounter.compareAndSet(currentReq, nextReq));
​
//        System.out.println("nextIndex is " + nextIndex);
//        System.out.println("nextReq is " + nextReq);
        log.info("nextIndex is " + nextIndex + ", nextReq is " + nextReq);
​
        return nextIndex;
    }
​
    private Map<Integer, Integer> indexReqMap = new ConcurrentHashMap<>(16); //key:value=主机下标:当前主机请求
​
    //ConcurrentHashMap优化
    private int incrementAndUpdateWeight(int serverCount, int[] weight) {
        int index;
        int req;
        if (indexReqMap.size() == 0){
            indexReqMap.put(0, 0);
        }
        index = indexReqMap.size() - 1;
        req = indexReqMap.get(index);
        indexReqMap.put(index, req + 1); //请求自增
        if (indexReqMap.get(index) > weight[index] && index < serverCount - 1) {
            index = index + 1;
            indexReqMap.put(index, 1);
        }
        if (indexReqMap.get(index) > weight[index] && index == serverCount - 1) {
            // 重置主机下标与请求数
            indexReqMap.clear();
            indexReqMap.put(0, 1);
            index = 0;
        }
//        System.out.println(indexReqMap);
        return index;
    }
​
    //加权轮询算法
    @Override
    public ServiceInstance weight(List<ServiceInstance> instances, int... weight) {
        if (Optional.ofNullable(instances).isPresent()) { //主机存活判空(从注册中心获取主机数据)
            // 提示服务绑定的权重
//            for (int i = 0; i < instances.size(); i++) {
//                log.info("当前主机[" + i + "]绑定的权重为: [" + " " + instances.get(i).getUri() + " " + weight[i] + "]");
//            }
            // 当设置的权重数超出主机时
            if (instances.size() <= weight.length) {
//                int i = incrementAndGetWeight(instances.size(), weight);
                int i = incrementAndUpdateWeight(instances.size(), weight);
                ServiceInstance instance = instances.get(i);
                log.info("当前主机[" + i + "]绑定的权重为: [" + " " + instances.get(i).getUri() + " " + weight[i] + "]");
                return instance;
            } else { //默认将未设置权重的主机的权重设置为1
                int[] arr = new int[instances.size()];
                for (int i = 0; i < weight.length; i++) {
                    arr[i] = weight[i];
                }
                for (int i = weight.length; i < instances.size(); i++) {
                    arr[i] = 1;
                }
//                System.out.println(Arrays.toString(arr));
//                int i = incrementAndGetWeight(instances.size(), arr);
                int i = incrementAndUpdateWeight(instances.size(), arr);
                ServiceInstance instance = instances.get(i);
                log.info("当前主机[" + i + "]绑定的权重为: [" + " " + instances.get(i).getUri() + " " + arr[i] + "]");
                return instance;
            }
        } else {
            log.warn("No available Server from loadbalancer: " + instances);
            return null;
        }
//        return null;
    }
​
    @Override
    public ServiceInstance weightRandom(List<ServiceInstance> instances, int... weight) {
        return null;
    }
​
    @Override
    public ServiceInstance weightFirst(List<ServiceInstance> instances, int... weight) {
        return null;
    }
​
    @Override
    public ServiceInstance ipHash(List<ServiceInstance> instances, int... weight) {
        return null;
    }
​
    @Override
    public ServiceInstance urlHash(List<ServiceInstance> instances, int... weight) {
        return null;
    }
​
}

然后将接口或接口实现类注入到Controller中调用即可:

package com.deepinsea.controller;
​
import com.deepinsea.common.loadbalancer.CoolLoadBalancer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
​
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
​
/**
 * Created by deepinsea on 2022/6/5.
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class RibbonController {
​
    @Autowired
    CoolLoadBalancer coolLoadBalancer;
​
    @PostMapping("/testMyRule")
    public String testMyRule() { //手写负载均衡算法
        List<ServiceInstance> instances = discoveryClient.getInstances("service-provider-nacos");
        //        ServiceInstance serviceInstance = coolLoadBalancer.round(instances);
        //        ServiceInstance serviceInstance = coolLoadBalancer.random(instances);
        ServiceInstance serviceInstance = coolLoadBalancer.weight(instances,4,3,2,1);
        //        coolLoadBalancer.weight(instances);
        String result = restTemplate.getForObject(serviceInstance.getUri() + "/provider-nacos/hello", String.class);
        return result;
    }
}

以手写的加权轮询算法为例,启动三个服务后,使用curl命令进行测试:

C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testMyRule
hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testMyRule
hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testMyRule
hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testMyRule
hi, this is service-provider-api!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testMyRule
hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testMyRule
hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testMyRule
hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d "" http://localhost:9050/consumer-ribbon/testMyRule
hi, this is service-provider-api!

可以看到加权轮询成功,控制台日志如下(多余的部分通过IDEA Alt+Shift列编辑删除了):

nextIndex is 0, nextReq is 1
当前主机[0]绑定的权重为: [ http://192.168.174.1:9040 4]
nextIndex is 0, nextReq is 2
当前主机[0]绑定的权重为: [ http://192.168.174.1:9040 4]
nextIndex is 0, nextReq is 3
当前主机[0]绑定的权重为: [ http://192.168.174.1:9040 4]
nextIndex is 0, nextReq is 4
当前主机[0]绑定的权重为: [ http://192.168.174.1:9040 4]
nextIndex is 1, nextReq is 1
当前主机[1]绑定的权重为: [ http://192.168.174.1:9010 3]
nextIndex is 1, nextReq is 2
当前主机[1]绑定的权重为: [ http://192.168.174.1:9010 3]
nextIndex is 1, nextReq is 3
当前主机[1]绑定的权重为: [ http://192.168.174.1:9010 3]
nextIndex is 0, nextReq is 1
当前主机[0]绑定的权重为: [ http://192.168.174.1:9040 4]

这里设置了两个原子变量来统计全局主机下标和请求数,然后通过两个原子变量之间的条件关系,进行CAS操作。

虽然使用AtomicInteger+CAS实现了加权轮询算法,但其实需要对两个原子变量做if嵌套条件判断,这导致出现了CAS中两个原子变量更新不能保证整体原子性的问题(CAS只能保证一个原子变量的共享操作)

还可以使用同步锁或者是多个变量放到一个对象里面。JDK提供的AtomicReference类来保证对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。

这个问题可以采用合并两个原子变量为一个原子变量进行更新的方法,但是这里明显不适用,因为两个原子变量是有层级关系的而不是同级并列的。另外,还有通过CPU层面增加原语同时更新两个原子变量,这种也不考虑。

这里采用另外一种思路:使用ConcurrentHashMap来进行优化。因为1.8中ConcurrentHashMap使用CAS+Synchronized实现,避免了CAS的多个共享变量原子更新整体原子性的问题。

旧实现方案(AtomicInteger+CAS)

    private AtomicInteger nextDomainIndex = new AtomicInteger(0); //记录主机下标
    private AtomicInteger nextRequestCounter = new AtomicInteger(0); //记录当前主机请求
​
    // CAS操作更新加权服务下标
    @Deprecated
    private int incrementAndGetWeight(int serverCount, int[] weight) {
        //一开始是想使用两个原子变量存储主机下标index和请求数req进行更新实现,但是发现需要对两个原子变量进行if嵌套判断
        //这就导致了CAS中两个原子变量更新并不能保证整体更新的问题了(CAS只能保证一个原子变量的共享操作)
        //因此改用为CurrentHashMap存储主机index和请求数req
        //假如权重为4,3,2
        int currentIndex; //假如有三台机器,则下标为0,1,2
        int nextIndex;//1,2,3
        int currentReq; //假如当前主机权重为4,则当前请求数为0,1,2,3
        int nextReq; //1,2,3,4
        //更新请求数
        do {
            currentIndex = this.nextDomainIndex.get();
            currentReq = this.nextRequestCounter.get();
            nextIndex = currentIndex;
            nextReq = currentReq + 1;
            //当请求数大于当前主机数,并且主机下标小于最大主机数-1时
            if (nextReq > weight[this.nextDomainIndex.get()] && currentIndex + 1 < serverCount) {
                System.out.println("进入下标更新流程...");
                //更新主机下标
                do {
                    currentIndex = this.nextDomainIndex.get();
                    nextIndex = currentIndex + 1; //切换到下一个主机
                    this.nextRequestCounter.set(0); //重置请求数为0
                } while (!this.nextDomainIndex.compareAndSet(currentIndex, nextIndex));
            }
            //当请求数大于当前主机数,并且主机下标等于最大主机数-1时
            if (nextReq > weight[this.nextDomainIndex.get()] && currentIndex + 1 == serverCount) {
                System.out.println("进入下标和请求数双重更新流程...");
                //重置全局主机下标和请求数
                this.nextDomainIndex.set(0);
                this.nextRequestCounter.set(0);
                nextIndex = this.nextDomainIndex.get();
//                System.out.println("currentIndex is " + currentIndex);
//                System.out.println("currentReq is " + currentReq);
//                System.out.println("nextIndex is " + nextIndex);
//                System.out.println("nextReq is " + nextReq);
            }
        } while (!this.nextRequestCounter.compareAndSet(currentReq, nextReq));
​
//        System.out.println("nextIndex is " + nextIndex);
//        System.out.println("nextReq is " + nextReq);
        log.info("nextIndex is " + nextIndex + ", nextReq is " + nextReq);
​
        return nextIndex;
    }

既然我们选择使用ConcurrentHashMap实现加权轮询算法,那么我们给原来的实现方法incrementAndGetWeight打上 @Deprecated弃用标签。

新实现方案(CopyOnWriteArrayList+ConcurrentHashMap)

与同步容器一样,并发容器在总体上也可以分为四大类,分别为:List、Set、Map和Queue。

img

这里我们考虑用线程安全的List与Queue来存储ConcurrentHashMap的key和value,那么有几种方案可以选择:

  • 并发容器中的List相对来说比较简单,就一个CopyOnWriteArrayList;
  • ConcurrentLinkedQueue、ArrayBlockingQueue和LinkedBlockingQueue。

ConcurrentSkipListMap是基于“跳表”实现的,在数据量大的情况下可以采用ConcurrentSkipListMap替换ConcurrentHashMap提高性能。

关于列表

CopyOnWrite,在写的时候进行复制操作,也就是说在进行写操作时,会将共享变量复制一份。那这样做有什么好处呢?最大的好处就是:读操作可以做到完全无锁化

使用CopyOnWriteArrayList时需要注意的是:

  • CopyOnWriteArrayList只适合写操作比较少的场景,并且能够容忍读写操作在短时间内的不一致;
  • CopyOnWriteArrayList的迭代器是只读的,不支持写操作。

CopyOnWriteArrayList类

使用场景:读操作远远大于写操作,读操作越快越好,写操作慢一些也没事 特点:读取不用加锁,写入不会阻塞读取操作,只有写入和写入需要同步等待,读性能大幅提升 原理:写入时进行一次自我复制,修改内容写入副本中,写完后再用副本内容替代原来的数据

参考:实战java高并发程序设计第三章(二)

并发编程踩坑实录二:并发容器踩坑总结!

关于队列:因为队列这种数据结构的特殊要求,所以它天然适合用链表的方式来实现,用两个变量分别记录链表头和链表尾,当删除或插入队列时,只要改变链表头或链表尾就可以了。在实际工作中,一般推荐使用有界队列,因为无界队列很容易导致内存溢出的问题(例如:Executors采用的就是无界阻塞队列)。 在Java的并发容器中,只有ArrayBlockingQueue和SynchronousQueue支持有界,其他的队列都是无界队列。LinkedTransferQueue与SynchronousQueue都是通过CAS和循环实现,而LinkedBlockingQueue是通过锁来实现的。因为LinkedBlockingQueue性能优于ArrayBlockingQueue,所以如果要使用LinkedBlockingQueue,则一定要提前指定容量大小,防止内存溢出(内存占用过大,导致新申请的内存大于剩余内存)。

LinkedBlockingQueue性能表现远超ArrayBlcokingQueue,不管线程多少,不管Queue长短,LinkedBlockingQueue都胜过ArrayBlockingQueue。SynchronousQueue表现很稳定,而且在20个线程之内不管Queue长短,SynchronousQueue性能表现是最好的,(其实SynchronousQueue跟Queue长短没有关系),如果Queue的capability只能是1,那么毫无疑问选择SynchronousQueue,这也是设计SynchronousQueue的目的吧。但大家也可以看到当超过1000个线程时,SynchronousQueue性能就直线下降了,只有最高峰的一半左右,而且当Queue大于30时,LinkedBlockingQueue性能就超过SynchronousQueue。

结论:

  • 线程多(>20),Queue长度长(>30),使用LinkedBlockingQueue;
  • 线程少 (<20) ,Queue长度短 (<30) , 使用SynchronousQueue。

当然,使用SynchronousQueue的时候不要忘记应用的扩展,如果将来需要进行扩展还是选择LinkedBlockingQueue好,尽量把SynchronousQueue限制在特殊场景中使用。

  • 少用ArrayBlcokingQueue,似乎没找到它的好处。

另外,关于ConcurrentLinkedQueueLinkedBlockingQueue,两者的区别在于:

  • ConcurrentLinkedQueue基于CAS的无锁技术,是非阻塞队列的高效的并发队列,使用链表实现。可以看作一个线程安全的LinkedList.,不需要在每个操作时使用锁,所以扩展性表现要更加优异,在常见的多线程访问场景,一般可以提供较高吞吐量。
  • LinkedBlockingQueue内部则是基于Lock锁,是阻塞队列,并提供了BlockingQueue的等待性方法。

首先二者都是线程安全的得队列,都可以用于生产与消费模型的场景。

LinkedBlockingQueue是阻塞队列,其好处是:多线程操作共同的队列时不需要额外的同步,由于具有插入与移除的双重阻塞功能,对插入与移除进行阻塞,队列会自动平衡负载,从而减少生产与消费的处理速度差距。

由于LinkedBlockingQueue有阻塞功能,其阻塞是基于锁机制实现的,当有多个线程消费时候,队列为空时消费线程被阻塞,有元素时需要再唤醒消费线程,队列元素可能时有时无,导致用户态与内核态切换频繁,消耗系统资源。从此方面来讲,LinkedBlockingQueue更适用于多线程插入,单线程取出,即多个生产者与单个消费者

ConcurrentLinkedQueue非阻塞队列,采用 CAS+自旋操作,解决多线程之间的竞争,多写操作增加冲突几率,增加自选次数,并不适合多写入的场景。当许多线程共享访问一个公共集合时,ConcurrentLinkedQueue 是一个恰当的选择。从此方面来讲,ConcurrentLinkedQueue更适用于单线程插入,多线程取出,即单个生产者与多个消费者

ConcurrentLinkedQueue是高并发环境中性能最好的队列,主要是利用CAS进行无锁操作,非阻塞队列。

总之,对于几个线程生产与几个线程消费,二者并没有严格的规定, 只有谁更适合。

最终对比CopyOnWriteArrayList与ConcurrentLinkedQueue:

在并发条件下,写的性能远远低于读。对于CopyOnWriteArrayList来说,内部有1000个元素的时候,写的性能要低于只有10个元素的时候,但是依然优于ConcurrentLinkedQueue。 Get操作,两者的读取性能差不多。由于实现上的差异,CopyOnWriteArrayList的size()操作的性能要好于ConcurrentLinkedQueue。 所以,即便是有少量的写入,在并发场景下,复制的消耗依然要很小。在元素总量不大的时候,CopyOnWriteArrayList的性能要好于ConcurrentLinkedQueue。

虽然CopyOnWriteArrayList在频繁写入时的性能还是稍强于ConcurrentLinkedQueue,但是基于ConcurrentLinkedQueue的CAS与非阻塞实现以及CopyOnWriteArrayList的读写实时一致性问题(也就是说ConcurrentLinkedQueue错误率更低 -- 在下面的测试类中可见一斑),综合读写频率、性能、稳定性等方面综合衡量,最终还是考虑选用ConcurrentLinkedQueue。当然还要注意一些,像:查询队列中元素数量size()和isEmpty()的性能差别(isEmpty更优),不要使用for循环遍历要使用while循环等问题。

最终选型:ConcurrentHashMap(发现可以不用ConcurrentLinkedQueue也可以实现)

参考:JMH之CopyOnWriteArrayList与ConcurrentLinkedQueue的性能测试

ConcurrentHashMap优化后的加权轮询算法

    private Map<Integer, Integer> indexReqMap = new ConcurrentHashMap<>(16); //key:value=主机下标:当前主机请求
​
    //ConcurrentHashMap优化
    private int incrementAndUpdateWeight(int serverCount, int[] weight) {
        int index;
        int req;
        if (indexReqMap.size() == 0){
            indexReqMap.put(0, 0);
        }
        index = indexReqMap.size() - 1;
        req = indexReqMap.get(index);
        indexReqMap.put(index, req + 1); //请求自增
        if (indexReqMap.get(index) > weight[index] && index < serverCount - 1) {
            index = index + 1;
            indexReqMap.put(index, 1);
        }
        if (indexReqMap.get(index) > weight[index] && index == serverCount - 1) {
            // 重置主机下标与请求数
            indexReqMap.clear();
            indexReqMap.put(0, 1);
            index = 0;
        }
//        System.out.println(indexReqMap);
        return index;
    }

手写负载均衡算法以及底层优化就到这里,暂时先告一段落,下面是一些扩展算法参考。

Ribbon扩展算法

支持Nacos权重算法

在Nacos的控制台,可以为每一个实例配置权重,取值在0~1之间,值越大,表示这个实例被调用的几率越大。而Ribbon内置的负载均衡的规则不支持权重,我们可以通过代码的方式让ribbon支持Nacos权重。

import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.ribbon.NacosServer;
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 org.springframework.beans.factory.annotation.Autowired;
​
/**
 * 基于Nacos 权重的负载均衡算法
 */publicclass NacosWeightRule extends AbstractLoadBalancerRule {
​
    @Autowired
    private NacosDiscoveryProperties nacosDiscoveryProperties;
​
    @Override
    publicvoid initWithNiwsConfig(IClientConfig iClientConfig) {
        // 读取配置文件,并初始化NacosWeightRule    }
​
    @Override
    public Server choose(Object o) {
        try {
            // ribbon入口
            BaseLoadBalancer loadBalancer = (BaseLoadBalancer) this.getLoadBalancer();
​
            //想要请求的微服务的名称
            String name = loadBalancer.getName();
​
            // 实现负载均衡算法
            // 拿到服务发现的相关api
            NamingService namingService = nacosDiscoveryProperties.namingServiceInstance();
            // nacos client自动通过基于权重的负载均衡算法,给我们一个示例
            Instance instance = namingService.selectOneHealthyInstance(name);
​
            returnnew NacosServer(instance);
        } catch (NacosException e) {
            returnnull;
        }
    }
}
同一集群优先调用算法
import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.ribbon.NacosServer;
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.util.CollectionUtils;
​
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
​
/**
 * 同一集群优先调用
 */
@Slf4j
publicclass NacosSameClusterWeightedRule extends AbstractLoadBalancerRule {
​
    @Autowired
    private NacosDiscoveryProperties nacosDiscoveryProperties;
​
    @Override
    publicvoid initWithNiwsConfig(IClientConfig iClientConfig) {
​
    }
​
    @Override
    public Server choose(Object o) {
        try {
            // 拿到配置文件中的集群名称
            String clusterName = nacosDiscoveryProperties.getClusterName();
​
            BaseLoadBalancer loadBalancer = (BaseLoadBalancer) this.getLoadBalancer();
            String name = loadBalancer.getName();
            NamingService namingService = nacosDiscoveryProperties.namingServiceInstance();
            // 找到指定服务的所有示例 A
            List<Instance> instances = namingService.selectInstances(name, true);
            // 过滤出相同集群的所有示例 B
            List<Instance> sameClusterInstances = instances.stream()
                    .filter(instance -> Objects.equals(instance.getClusterName(), clusterName))
                    .collect(Collectors.toList());
            // 如果B是空,就用A
            List<Instance> instancesToBeanChoose = new ArrayList<>();
            if (CollectionUtils.isEmpty(sameClusterInstances)) {
                instancesToBeanChoose = instances;
            } else {
                instancesToBeanChoose = sameClusterInstances;
            }
            // 基于权重的负载均衡算法返回示例
            Instance hostByRandomWeightExtend = ExtendBalancer.getHostByRandomWeightExtend(instancesToBeanChoose);
​
            returnnew NacosServer(hostByRandomWeightExtend);
        } catch (NacosException e) {
            log.error("发生异常了", e);
            returnnull;
        }
    }
}
​
class ExtendBalancer extends Balancer {
    publicstatic Instance getHostByRandomWeightExtend(List<Instance> hosts) {
        return getHostByRandomWeight(hosts);
    }
}
基于元数据的版本控制算法

即优先调用同一版本下的实例

import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.ribbon.NacosServer;
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.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.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
​
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
​
/**
 * 基于nacos元数据的版本控制
 */
@Slf4j
publicclass NacosFinalRule extends AbstractLoadBalancerRule {
    @Autowired
    private NacosDiscoveryProperties nacosDiscoveryProperties;
​
    @Override
    publicvoid initWithNiwsConfig(IClientConfig iClientConfig) {
​
    }
​
    @Override
    public Server choose(Object o) {
        try {
            // 负载均衡规则:优先选择同集群下,符合metadata的实例
            // 如果没有,就选择所有集群下,符合metadata的实例
​
            // 1. 查询所有实例 A
            String clusterName = nacosDiscoveryProperties.getClusterName();
            String targetVersion = this.nacosDiscoveryProperties.getMetadata().get("target-version");
​
            BaseLoadBalancer loadBalancer = (BaseLoadBalancer) this.getLoadBalancer();
            String name = loadBalancer.getName();
            NamingService namingService = nacosDiscoveryProperties.namingServiceInstance();
            List<Instance> instances = namingService.selectInstances(name, true);
​
            // 2. 筛选元数据匹配的实例 B
            List<Instance> metadataMatchInstances = instances;
            // 如果配置了版本映射,那么只调用元数据匹配的实例if (StringUtils.isNotBlank(targetVersion)) {
                metadataMatchInstances = instances.stream()
                        .filter(instance -> Objects.equals(targetVersion, instance.getMetadata().get("version")))
                        .collect(Collectors.toList());
                if (CollectionUtils.isEmpty(metadataMatchInstances)) {
                    log.warn("未找到元数据匹配的目标实例!请检查配置。targetVersion = {}, instance = {}", targetVersion, instances);
                    returnnull;
                }
            }
​
            // 3. 筛选出同cluster下元数据匹配的实例 C
            // 4. 如果C为空,就用B
            List<Instance> clusterMetadataMatchInstances = metadataMatchInstances;
            // 如果配置了集群名称,需筛选同集群下元数据匹配的实例if (StringUtils.isNotBlank(clusterName)) {
                clusterMetadataMatchInstances = metadataMatchInstances.stream()
                        .filter(instance -> Objects.equals(clusterName, instance.getClusterName()))
                        .collect(Collectors.toList());
                if (CollectionUtils.isEmpty(clusterMetadataMatchInstances)) {
                    clusterMetadataMatchInstances = metadataMatchInstances;
                    log.warn("发生跨集群调用。clusterName = {}, targetVersion = {}, clusterMetadataMatchInstances = {}", clusterName, targetVersion, clusterMetadataMatchInstances);
                }
            }
​
            // 5. 随机选择实例
            Instance instance = ExtendBalancer.getHostByRandomWeightExtend(clusterMetadataMatchInstances);
            returnnew NacosServer(instance);
        } catch (Exception e) {
            returnnull;
        }
    }
}

9. 配置Ribbon饥饿加载

前面进行一些Ribbon的使用,但是我们除了在自定义负载均衡策略的时候使用配置文件配置使用到了yml配置文件以外,就完全不用在配置文件配置Ribbon也能使用它的负载均衡功能。这是因为,Spring Alibaba 2.2.6版本的Nacos默认集成了Ribbon,并且Feign也默认集成了它;因此,在以前的版本可以直接通过自动配置来直接使用Ribbon而不需要进行配置,这就是SpringBoot的力量!在默认配置文件中,Ribbon支持以下配置:

image-20220527023657714

Ribbon配置参数:

配置项描述
ribbon.eager-load.enabledRibbon的饥饿加载模式开关
ribbon.eager-load.clients指定需要饥饿加载的(多个)客户端服务名称
ribbon.http.client.enabledHttpClient客户端开关
ribbon.okhttp.enabledOkHttp客户端开关
ribbon.restclient.enabledRibbon-Restclient客户端开关(已弃用)
ribbon.secure-ports声明服务端口启用https安全通信

什么是饥饿加载

上面有几个配置的功能和业务场景的解释:

  • 饥饿加载:我们服务消费方调用服务提供方接口的时候,第一次请求经常会超时,而之后的调用就没有问题了。下面我们就来说说造成这个问题的原因,以及如何解决的方法。

    • 问题描述:Ribbon进行客户端负载均衡的Client并不是在服务启动的时候就初始化好的,而是在调用的时候才会去创建相应的Client,所以第一次调用的耗时不仅仅包含发送HTTP请求的时间,还包含了创建Ribbon Client的时间,这样一来如果创建时间速度较慢,同时设置的超时时间又比较短的话,很容易就会出现第一次服务调用超时问题。
    • 解决方法:解决第一次微服务调用出现超时而失败的问题,可以通过饿加载解决。第一次调用时候产生Ribbon Client耗时,那么就让它真实调用前提前创建,而不是在第一次真实调用的时候创建。
    • 所谓饥饿加载(eager-load)模式,其实就是缓存Ribbon配置。Ribbon在第一次启动时,因为需要从注册中心获取服务列表,然后缓存服务列表,但是第一次创建请求比较慢。针对这种情况,可以通过开启饥饿模式,在应用启动阶段就立即加载所有配置项的应用程序上下文(包括Ribbon请求客户端),来加速请求默认情况下Ribbon是懒加载的。当服务起动好之后,第一次请求是非常慢的,第二次之后就快很多。

    这个问题类似于Vue的懒加载带来的首屏加载问题,解决方法也类似,不过Vue的是服务端渲染解决。

  • 饥饿加载客户端:即设置需要开启饥饿加载调用的服务提供者。

    因为Ribbon默认是采用懒加载,即第一次访问时才会去创建LoadBalanceClient,请求时间会很长。 而饥饿加载则会在项目启动时创建,降低第一次访问的耗时。通过下面配置开启饥饿加载:

配置饥饿加载

ribbon:
  eager-load:
    # 开启ribbon饥饿加载
    enabled: true
    # 开启饥饿加载的服务,对应@RibbonClients注解声明需要调用的服务,多个使用逗号隔开(一般两个相同服务名的负载均衡客户端,至少需要4个服务,当然也可以对单个非负载均衡的服务开启)
    clients: service-provider-nacos
  # httpclient客户端开关
  http:
    client:
      enabled: false
  # okhttp客户端开关
  okhttp:
    enabled: false
  # ribbon rest-client开关(已启用)
  restclient:
    enabled: false
  # 服务端口https安全
#  secure-ports: ${server.port}

总结:饥饿配置的本质就是将真实请求时创建LoadBalanceClient客户端变为在项目启动时就创建LoadBalanceClient客户端,这样可以降低第一次缓存服务列表、构建请求客户端对象的开销。

验证饥饿加载

验证(先关闭,然后再开启)

在关闭饥饿加载时,只有调用服务接口时,才有服务列表相关信息日志:

DynamicServerListLoadBalancer for client service-provider-nacos initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=service-provider-nacos,current list of Servers=[192.168.174.1:9040, 192.168.174.1:9010],Load balancer stats=Zone stats: {unknown=[Zone:unknown; Instance count:2;   Active connections count: 0;    Circuit breaker tripped count: 0;   Active connections per server: 0.0;]
},Server stats: [[Server:192.168.174.1:9040;    Zone:UNKNOWN;   Total Requests:0;   Successive connection failure:0;    Total blackout seconds:0;   Last connection made:Thu Jan 01 08:00:00 CST 1970;  First connection made: Thu Jan 01 08:00:00 CST 1970;    Active Connections:0;   total failure count in last (1000) msecs:0; average resp time:0.0;  90 percentile resp time:0.0;    95 percentile resp time:0.0;    min resp time:0.0;  max resp time:0.0;  stddev resp time:0.0]
, [Server:192.168.174.1:9010;   Zone:UNKNOWN;   Total Requests:0;   Successive connection failure:0;    Total blackout seconds:0;   Last connection made:Thu Jan 01 08:00:00 CST 1970;  First connection made: Thu Jan 01 08:00:00 CST 1970;    Active Connections:0;   total failure count in last (1000) msecs:0; average resp time:0.0;  90 percentile resp time:0.0;    95 percentile resp time:0.0;    min resp time:0.0;  max resp time:0.0;  stddev resp time:0.0]
]}ServerList:com.alibaba.cloud.nacos.ribbon.NacosServerList@17c448e1

也就是说,Ribbon只会在真实请求时才会构建客户端去请求服务接口(基于Netty实现请求的原因),因此在请求之前是没有服务信息的。

开启饥饿加载后,控制台日志为:

2022-06-07 03:41:27.274  INFO 25124 --- [           main] com.deepinsea.ServiceRibbonApplication   : Started ServiceRibbonApplication in 7.432 seconds (JVM running for 8.264)
// 表示应用启动了
2022-06-07 03:41:27.850  INFO 25124 --- [           main] c.netflix.loadbalancer.BaseLoadBalancer  : Client: service-provider-nacos instantiated a LoadBalancer: DynamicServerListLoadBalancer:{NFLoadBalancer:name=service-provider-nacos,current list of Servers=[],Load balancer stats=Zone stats: {},Server stats: []}ServerList:null
// 一开始服务列表信息为空
2022-06-07 03:41:27.855  INFO 25124 --- [           main] c.n.l.DynamicServerListLoadBalancer      : Using serverListUpdater PollingServerListUpdater
// 然后使用serverListUpdater主动拉取并更新服务列表信息
2022-06-07 03:41:27.871  INFO 25124 --- [           main] c.n.l.DynamicServerListLoadBalancer      : DynamicServerListLoadBalancer for client service-provider-nacos initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=service-provider-nacos,current list of Servers=[192.168.174.1:9040, 192.168.174.1:9010],Load balancer stats=Zone stats: {unknown=[Zone:unknown;    Instance count:2;   Active connections count: 0;    Circuit breaker tripped count: 0;   Active connections per server: 0.0;]
},Server stats: [[Server:192.168.174.1:9040;    Zone:UNKNOWN;   Total Requests:0;   Successive connection failure:0;    Total blackout seconds:0;   Last connection made:Thu Jan 01 08:00:00 CST 1970;  First connection made: Thu Jan 01 08:00:00 CST 1970;    Active Connections:0;   total failure count in last (1000) msecs:0; average resp time:0.0;  90 percentile resp time:0.0;    95 percentile resp time:0.0;    min resp time:0.0;  max resp time:0.0;  stddev resp time:0.0]
, [Server:192.168.174.1:9010;   Zone:UNKNOWN;   Total Requests:0;   Successive connection failure:0;    Total blackout seconds:0;   Last connection made:Thu Jan 01 08:00:00 CST 1970;  First connection made: Thu Jan 01 08:00:00 CST 1970;    Active Connections:0;   total failure count in last (1000) msecs:0; average resp time:0.0;  90 percentile resp time:0.0;    95 percentile resp time:0.0;    min resp time:0.0;  max resp time:0.0;  stddev resp time:0.0]
]}ServerList:com.alibaba.cloud.nacos.ribbon.NacosServerList@561953e3
// 然后就有注册中心的服务列表信息了

可以看到我们并没有调用服务接口,但还是出现了服务列表信息。在应用启动后,Ribbon经历了:没有服务列表信息 => 拉取并更新配置 => 获取到服务列表信息的过程。到此,可以通过console日志的服务列表信息从无到有更新验证到我们成功开启了饥饿加载

10. 配置超时与重试

我们在微服务调用服务的时候,会使用feign和ribbon,比如有一个实例发生了故障而该情况还没有被服务治理机制及时的发现和摘除,这时候客户端访问该节点的时候自然会失败。

所以,为了构建更为健壮的应用系统,我们希望当请求失败的时候能够有一定策略的重试机制,而不是直接返回失败

Feign和Ribbon重试机制配置

先看一个样例配置:

#预加载配置,默认为懒加载
ribbon:
     eager-load:
     enabled: true
     clients: zoo-plus-email
#Ribbon调用服务名  
zoo-plus-email: 
     ribbon:
     # 代表Ribbon使用的负载均衡策略
     NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
     # 每台服务器最多重试次数,但是首次调用不包括在内
     MaxAutoRetries: 1
     # 最多重试多少台服务器
     MaxAutoRetriesNextServer: 1
     # 无论是请求超时或者socket read timeout都进行重试
     OkToRetryOnAllOperations: true
     ReadTimeout: 3000
     ConnectTimeout: 3000

hystrix:
     command:
     default:
         execution:
         isolation:
             thread:
             timeoutInMilliseconds: 4000

一般情况下都是: ribbon的超时时间 < hystrix的超时时间(因为涉及到ribbon的重试机制)

① Feign重试机制

因为ribbon的重试机制和Feign的重试机制有冲突,所以源码中默认关闭Feign的重试机制,具体看一看源码

要开启Feign的重试机制如下(源码中Feign默认重试五次):

@Bean
Retryer feignRetryer() {
    return new Retryer.Default();
}
② Ribbon重试机制
# ribbon调用服务名
service-provider-nacos:
  ribbon:
    # 服务使用的负载均衡策略
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
    ReadTimeout: 3000 #端口读取超时
    ConnectTimeout: 60000 #请求连接超时
    MaxAutoRetries: 1 #同一台实例最大重试次数,不包括首次调用
    MaxAutoRetriesNextServer: 1 #重试负载均衡其他的实例最大重试次数,不包括首次调用
    OkToRetryOnAllOperations: false  #无论是请求超时或者socket read timeout都进行重试

计算重试的次数:

MaxAutoRetries + MaxAutoRetriesNextServer + (MaxAutoRetries * MaxAutoRetriesNextServer)

即:重试3次,加上Ribbon第一次调用,一共产生4次调用 。

注意:如果在重试期间,时间超过了hystrix的超时时间,便会立即执行熔断,fallback。所以要根据上面配置的参数计算hystrix的超时时间,使得在重试期间不能达到hystrix的超时时间,不然重试机制就会没有意义 。

首先声明一点,这里的重试并不是报错以后的重试,而是负载均衡客户端发现远程请求实例不可到达后,去重试其他实例。

注意:因为ribbon的重试机制和Feign的重试机制有冲突,所以源码中默认关闭Feign的重试机制。

hystrix超时时间的计算:

(1 + MaxAutoRetries + MaxAutoRetriesNextServer) * ReadTimeout

即按照以上的配置 hystrix的超时时间应该配置为 (1+1+1)*3=9秒

当ribbon超时后且hystrix没有超时,便会采取重试机制。当OkToRetryOnAllOperations设置为false时,只会对get请求进行重试。如果设置为true,便会对所有的请求进行重试,如果是put或post等写操作,如果服务器接口没做幂等性,会产生不好的结果,所以**OkToRetryOnAllOperations: true 慎用**。

如果不配置ribbon的重试次数,默认会重试一次

注意: 默认情况下,GET方式请求无论是连接异常还是读取异常,都会进行重试 ,非GET方式请求,只有连接异常时,才会进行重试。

hystrix超时时间的计算:

(1 + MaxAutoRetries + MaxAutoRetriesNextServer) * ReadTimeout

即按照以上的配置 hystrix的超时时间应该配置为 (1+1+1)*3=9秒

当ribbon超时后且hystrix没有超时,便会采取重试机制。当OkToRetryOnAllOperations设置为false时,只会对get请求进行重试。如果设置为true,便会对所有的请求进行重试,如果是put或post等写操作,如果服务器接口没做幂等性,会产生不好的结果,所以OkToRetryOnAllOperations慎用。

如果不配置ribbon的重试次数,默认会重试一次

注意: 默认情况下,GET方式请求无论是连接异常还是读取异常,都会进行重试 ,非GET方式请求,只有连接异常时,才会进行重试

当我们需要关闭重试功能的时候,是不是 spring.cloud.loadbalancer.retry.enabled=false 就可以了呢?

并不是,而是需要把 ribbon.OkToRetryOnAllOperations=false 设置关闭才行。

Feign、Ribbon和Hystrix超时时间配置

以下的配置部分适用于Spring Cloud旧版本,参考就好

① Feign超时时间
feign:
  hystrix:
  enabled: true
  client:
  config:
    # 全局配置
    default:
    connectTimeout: 5000
    readTimeout: 5000
    # 实例配置,feignName即@feignclient中的value,也就是服务名
    feignName:
      connectTimeout: 5000
      readTimeout: 5000
② Ribbon超时时间
# 全局配置
ribbon:
# 单个服务最大重试次数,不包含对单个服务的第一次请求,默认0
MaxAutoRetries: 3
# 服务切换次数,不包含最初的服务,如果服务注册列表小于 nextServer count 那么会循环请求 A > B > A,默认1
MaxAutoRetriesNextServer: 2
#是否所有操作都进行重试,默认只重试get请求,如果修改为true,则需注意post\put等接口幂等性
OkToRetryOnAllOperations: false
#连接超时时间,单位为毫秒,默认2秒
ConnectTimeout: 3000
#读取的超时时间,单位为毫秒,默认5秒
ReadTimeout: 3000
# 实例配置
clientName:
     ribbon:
     MaxAutoRetries: 5
     MaxAutoRetriesNextServer: 3
     OkToRetryOnAllOperations: false
     ConnectTimeout: 3000
     ReadTimeout: 3000
③ Hystrix超时时间
hystrix:
     command:
     #全局默认配置
     default:
         #线程隔离相关
         execution:
         timeout:
             #是否给方法执行设置超时时间,默认为true。一般我们不要改。
             enabled: true
         isolation:
             #配置请求隔离的方式,这里是默认的线程池方式。还有一种信号量的方式semaphore。
             strategy: THREAD
             thread:
             #方式执行的超时时间,默认为1000毫秒,在实际场景中需要根据情况设置
             timeoutInMilliseconds: 10000
     # 实例配置
     HystrixCommandKey:
         execution:
         timeout:
             enabled: true
         isolation:
             strategy: THREAD
             thread:
             timeoutInMilliseconds: 10000

Feign和Ribbon重试次数设置

feign自身重试目前只有一个简单的实现Retryer.Default,包含三个属性:

  • maxAttempts:重试次数,包含第一次
  • period:重试初始间隔时间,单位毫秒
  • maxPeriod:重试最大间隔时间,单位毫秒

ribbon重试包含两个属性:MaxAutoRetries和MaxAutoRetriesNextServer

总重试次数= 访问的服务器数 * 单台服务器最大重试次数

即:(1+MaxAutoRetriesNextServer)*(1+MaxAutoRetries )

按上面实例的配置,则:总重试次数 =(1+2)*(1+3) = 12 。

Feign和Ribbon超时时间设置

feign和ribbon的超时时间只会有一个生效,规则:如果没有设置过feign超时,也就是等于默认值的时候,就会读取ribbon的配置,使用ribbon的超时时间和重试设置。否则使用feign自身的设置。两者是二选一的,且feign优先。

以Ribbon的时间生效为例,Hystrix的超时时间需大于Ribbon重试总和时间,否则重试将失效,即:

Hystrix超时时间 > (Ribbon超时时间总和)x 重试次数

按上面的例子,hystrix超时时间>12*(3000+3000)

以上为个人经验,希望能给大家一个参考。

欢迎点赞还有评论,谢谢大佬ヾ(◍°∇°◍)ノ゙