Skip to content

③SpringCloud 实战:使用 Ribbon 客户端负载均衡

Published: at 14:06:19

这是SpringCloud实战系列中第三篇文章,了解前面第两篇文章更有助于更好理解本文内容:

①SpringCloud 实战:引入Eureka组件,完善服务治理

②SpringCloud 实战:引入Feign组件,发起服务间调用

简介

Ribbon 是由 Netflix 发布的一个客户端负载均衡器,它提供了对 HTTP 和 TCP 客户端行为的大量控制。Ribbon 可以基于某些负载均衡的算法,自动为客户端选择发起理论最优的网络请求。常见的负载均衡算法有:轮询,随机,哈希,加权轮询,加权随机等。

客户端负载均衡的意思就是发起网络请求的端根据自己的网络请求情况来做相应的负载均衡策略,与之相对的非客户端负载均衡就有比如硬件F5、软件Nginx,它们更多是介于消费者和提供者之间的,并非客户端。

改造eureka-provider项目

在使用之前我们先把第二节里面的 eureka-provider 项目改造一下,在HelloController 里面新增一个接口,输出自己项目的端口信息,用于区别验证待会儿客户端负载均衡时所调用的服务。

  1. 新增接口方法,返回自己的端口号信息:

    @Controller
    public class HelloController{
        @Value("${server.port}")
        private int serverPort;
    	...
    
    	@ResponseBody
        @GetMapping("queryPort")
        public String queryPort(){
            return "hei, jinglingwang, my server port is:"+serverPort;
        }
    }
    
  2. 分别以8082,8083,8084端口启动该项目:eureka-provider 下图是 IDEA 快速启动三个不同端口项目方法截图,当然你也可以用其他办法

    ![1](/assets/upload/2020/11/1-c74d9874c7054e27934e03b381322a54.png)
  3. 然后启动,访问三个接口测试一下是否正常返回了对应端口

    2

至此,服务提供者的接口准备工作就做好了。

新建Ribbon-Client 项目

我们使用 Spring Initializr 生成SpringCloud项目基础框架,然后修改pom.xml里面的SpringBoot和SpringCloud的版本,对应版本修改请求如下:

...
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.0.3.RELEASE</version> <!--修改了版本jingling.im-->
    <relativePath/> <!-- lookup parent from repository -->
</parent>
...
<properties>
    <java.version>1.8</java.version>
    <spring-cloud.version>Finchley.SR4</spring-cloud.version><!--修改了版本-->
</properties>
...
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

为什么要单独修改版本呢?因为从 Spring Cloud Hoxton.M2 版本开始,Spring Cloud 已经不再默认使用Ribbon来做负载均衡了,而是使用 spring-cloud-starter-loadbalancer替代。所以我们在使用 Spring Initializr 生成项目框架的时,如果使用最新版本Spring Cloud将不再提供Ribbon相关的组件。需要我们自己引入或者使用低一点的版本。

之后就是在ribbon-client项目引入eureka-client依赖和openfeign的依赖,这个过程省略,如果不会的话请看前两篇文章。

Ribbon 的三种使用方式

我们在新建的ribbon-client项目里面来使用三种方式调用eureka-provider的queryPort接口,因为eureka-provider服务启动了三个节点,到时候只要观察三种方式的响应结果,就可以判断负载均衡是否有生效。

一、使用原生API

直接使用LoadBalancerClient来获得对应的实例,然后发起URL请求,编写对应的RibbonController:

@RestController
public class RibbonController{

    @Autowired
    private LoadBalancerClient loadBalancer;

    @GetMapping("ribbonApi")
    public String ribbonApi() throws Exception{
        ServiceInstance instance = loadBalancer.choose("eureka-provider");
        System.out.println(instance.getUri());
        URL url = new URL("http://localhost:" + instance.getPort() + "/queryPort");
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        InputStream inputStream = conn.getInputStream();
        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
        String line = null;
        StringBuffer buffer = new StringBuffer();
        while ((line = reader.readLine()) != null) {
            buffer.append(line);
        }
        reader.close();
        return Observable.just(buffer.toString()).toBlocking().first();
    }
}

启动Ribbon-Client服务,访问http://localhost:7071/ribbonApi 接口,多次刷新接口发现采用的是轮询方式,运行效果图如下:

3

二、结合RestTemplate使用

使用 RestTemplate 的话,我们只需要再结合@LoadBalanced注解一起使用即可:

@Configuration
public class RestTemplateConfig{
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
}

编写RibbonController:

@RestController
public class RibbonController{
    @Autowired
    private RestTemplate restTemplate;
    @GetMapping("queryPortByRest")
    public String queryPortByRest(){
        return restTemplate.getForEntity("http://eureka-provider/queryPort",String.class).getBody();
    }
}

启动ribbon-client服务,访问http://localhost:7071/queryPortByRest 接口,多次刷新接口发现采用的也是轮询方式,运行效果图如下:

4

三、结合Feign使用

新建一个Feign:

@FeignClient(value = "eureka-provider")
public interface ProviderFeign{
    /**
     * 调用服务提供方,其中会返回服务提供者的端口信息
     * @return jingling.im
     */
    @RequestMapping("/queryPort")
    String queryPort();
}

编写调用接口:

@RestController
public class RibbonController{
    ...
    @Autowired
    private ProviderFeign providerFeign;
    ...
    @GetMapping("queryPort")
    public String queryPort(){
	// 通过feign ribbon-client 调用 eureka-provider
        return providerFeign.queryPort();
    }
}

启动ribbon-client服务,访问 http://localhost:7071/queryPort 接口,多次刷新接口发现采用的也是轮询方式,运行效果图如下:

5

自定义Ribbon配置

为指定的客户端自定义负载均衡规则

在配置之前先做一点准备工作,我们把之前的服务eureka-provider再起3个节点,启动之前把端口改为8085、8086、8087,三个节点的服务名改为eureka-provider-temp。这样做的目的是等会儿我们新建一个Feign,但是名字和之前的区分开,相当于两个不同的服务,并且都是多节点的。

以上准备工作做完之后你会在IDEA中看到如下图的6个服务:

6

在注册中心也可以观察到2个不同的服务,一共6个节点:

7

eureka-provide 和 eureka-provide-temp 他们唯一的区别就是服务名不一样、端口不一样。

JavaBean的配置方式

现在开始为Feign配置ribbon:

  1. 新建一个Feign,命名为:ProviderTempFeign

    @FeignClient(value = "eureka-provider-temp")
    public interface ProviderTempFeign{
    
        @RequestMapping("/queryPort")
        String queryPort();
    }
  2. 使用JAVA Bean的方式定义配置项

    public class ProviderTempConfiguration{
        @Bean
        public IRule ribbonRule(){
            System.out.println("new ProviderTempConfiguration RandomRule");
            return new RandomRule(); // 定义一个随机的算法
        }
        @Bean
        public IPing ribbonPing() {
            //        return new PingUrl();
            return new NoOpPing();
        }
    }
  3. 使用注解@RibbonClient 配置负载均衡客户端:

    @RibbonClient(name = "eureka-provider-temp",configuration = ProviderTempConfiguration.class)
    public class ProviderTempRibbonClient{
    
    }
  4. 在Controller新增一个接口,来调用新增Feign(eureka-provider-temp)的方法

    @GetMapping("queryTempPort")
    public String queryTempPort(){
        return providerTempFeign.queryPort();
    }
  5. 再为另一个Feign(eureka-provider)也配置一下ribbon,对外接口还是上面已经写好了

    public class ProviderConfiguration{
        @Bean
        public IRule ribbonRule(){
            System.out.println("new ProviderConfiguration BestAvailableRule");
            return new BestAvailableRule(); // 选择的最佳策略
        }
        @Bean
        public IPing ribbonPing() {
            //        return new PingUrl();
            return new NoOpPing();
        }
    }
    
    @RibbonClient(name = "eureka-provider",configuration = ProviderConfiguration.class)
    public class ProviderRibbonClient{
    
    }
  6. 启动服务之后分别访问两个接口(http://localhost:7071/queryPorthttp://localhost:7071/queryTempPort),观察接口的端口返回情况

如果以上过程顺利的话,你访问queryPort接口的时候返回的端口不是随机的,几乎没怎么变化,访问queryTempPort接口的时候,接口返回的端口是随机的,说明我们以上配置是可行的。而且第一次访问接口的时候,我们在控制台打印了出对应的算法规则,你可以观察一下。

配置文件的配置方式

以上的配置也可以写到配置文件中,效果是一样的:

# 通过配置文件 分别为每个客户端配置
eureka-provider.ribbon.NFLoadBalancerRuleClassName=com.netflix.loadbalancer.BestAvailableRule
eureka-provider.ribbon.NFLoadBalancerPingClassName=com.netflix.loadbalancer.NoOpPing

eureka-provider-temp.ribbon.NFLoadBalancerRuleClassName=com.netflix.loadbalancer.RandomRule
eureka-provider-temp.ribbon.NFLoadBalancerPingClassName=com.netflix.loadbalancer.NoOpPing

配置的规则是:.ribbo. = xxxXXX,其中configKey可以在 CommonClientConfigKey.class类中查看。常用的有:

NFLoadBalancerClassName
NFLoadBalancerRuleClassName
NFLoadBalancerPingClassName
NIWSServerListClassName
NIWSServerListFilterClassName

为所有的客户端自定义默认的配置

这里需要用到的注解是@RibbonClients

@Configuration()
public class DefaultRibbonConfiguration{

    @Bean
    public IRule iRule() {
        // 轮询
        return new RoundRobinRule();
    }
    @Bean
    public IPing ribbonPing() {
        return new DummyPing();
    }
}
@RibbonClients(defaultConfiguration = DefaultRibbonConfiguration.class)
public class DefaultRibbonClient{

}

启动我们的ribbon-client服务,测试访问下我们的http://localhost:7071/queryPort 接口,发现返回的数据每次都不一样,变为轮询的方式返回接口信息了。

测试到这里的时候,配置文件中的相关配置我并没有注释掉,Java Bean方式的@RibbonClient被注释掉了,也就是说测试的时候同时配置了配置文件和@RibbonClients,最后测试下来是@RibbonClients配置生效了,配置文件中配置的策略没有生效。 测试下来,@RibbonClients 的优先级最高,之后是配置文件,再是@RibbonClient,最后是Spring Cloud Netflix 默认值。

同时使用@RibbonClients和@RibbonClient

如果同时使用@RibbonClients和@RibbonClient,全局默认配置和自定义单个ribbon配置,会按照哪个配置生效呢?

我把配置文件中的相关配置都注释,然后把两个配置 @RibbonClient 的地方都放开,然后重启项目,访问http://localhost:7071/queryPorthttp://localhost:7071/queryTempPort

测试结果是都报错,报错信息如下:

org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'com.netflix.loadbalancer.IRule' available: expected single matching bean but found 2: providerRule,iRule

报错信息的意思是预期需要一个bean,但是结果找到了两个(providerRule 和 iRule),结果不知道该用哪一个了,所以抛出异常。

那这个问题怎么解决呢?

首先直接说结论吧,就是给你想要生效的那个bean加@Primary注解,代码如下所示,如果eureka-provider 不加还是会继续报错:

public class ProviderTempConfiguration{
    @Primary
    @Bean("providerTempRule")
    public IRule ribbonRule(){
        System.out.println("new ProviderTempConfiguration RandomRule");
        return new RandomRule();
    }
    ...
}

再说下排查这个问题的思路:

  1. 通过查看异常输出栈的错误日志信息,定位到抛出异常的地方

    8

  2. 之后继续往前面找相关的逻辑,加断点,慢慢调试,发现有一个字段(autowiredBeanName)为空,才会进入到后面抛异常的逻辑

    9

  3. 断点也显示matchingBeans里面有两条数据,说明确实是匹配到了2个bean

    10

  4. 然后我们进入到determineAutowireCandidate方法,发现里面有个看起来很不一般的字段:primaryCandidate,如果这个字段不为空,会直接返回,那这个字段的值是怎么确认的呢?

  5. 继续进入到determinePrimaryCandidate方法,发现这个方法的主要功能就是从给定的多个bean中确定一个主要的候选对象bean,说白了就是选一个bean,那这个方法是怎么选的呢?上源代码:

    @Nullable
    protected String determinePrimaryCandidate(Map<String, Object> candidates, Class<?> requiredType) {
    	String primaryBeanName = null;
      // candidates 是匹配到的多个bean
      // requiredType 是要匹配的目标依赖类型
    	for (Map.Entry<String, Object> entry : candidates.entrySet()) { // 遍历map
    		String candidateBeanName = entry.getKey();
    		Object beanInstance = entry.getValue();
    		if (isPrimary(candidateBeanName, beanInstance)) { // 最重要的逻辑,看是不是主要的bean,看到这有经验的其实都知道要加@Primary注解了
    			if (primaryBeanName != null) {
    				boolean candidateLocal = containsBeanDefinition(candidateBeanName);
    				boolean primaryLocal = containsBeanDefinition(primaryBeanName);
    				if (candidateLocal && primaryLocal) {
    					throw new NoUniqueBeanDefinitionException(requiredType, candidates.size(),
    							"more than one 'primary' bean found among candidates: " + candidates.keySet());
    				}
    				else if (candidateLocal) {
    					primaryBeanName = candidateBeanName;
    				}
    			}
    			else {
    				primaryBeanName = candidateBeanName;
    			}
    		}
    	}
    	return primaryBeanName;
    }
  6. 进入到isPrimary(candidateBeanName, beanInstance)方法,最后实际就是返回的以下逻辑:

    @Override
    public boolean isPrimary() {
    	return this.primary;
    }
  7. 所以解决上面的问题,只需要在我们的ProviderTempConfiguration类里面为bean 再添加一个@Primary注解

Ribbon超时时间

全局默认配置

# 全局ribbon超时时间
#读超时
ribbon.ReadTimeout=3000
#连接超时
ribbon.ConnectTimeout=3000
#同一台实例最大重试次数,不包括首次调用
ribbon.MaxAutoRetries=0
#重试负载均衡其他的实例最大重试次数,不包括首次调用
ribbon.MaxAutoRetriesNextServer=1

为每个client单独配置

# 为每个服务单独配置超时时间
eureka-provider.ribbon.ReadTimeout=4000
eureka-provider.ribbon.ConnectTimeout=4000
eureka-provider.ribbon.MaxAutoRetries=0
eureka-provider.ribbon.MaxAutoRetriesNextServer=1

自定义Ribbon负载均衡策略

Ribbon定义了以下几个属性支持自定义配置:

<clientName>.ribbon.NFLoadBalancerClassName: Should implement ILoadBalancer
<clientName>.ribbon.NFLoadBalancerRuleClassName: Should implement IRule
<clientName>.ribbon.NFLoadBalancerPingClassName: Should implement IPing
<clientName>.ribbon.NIWSServerListClassName: Should implement ServerList
<clientName>.ribbon.NIWSServerListFilterClassName: Should implement ServerListFilter

这里以自定义负载均衡策略规则为例,只需要实现IRule接口或者继承AbstractLoadBalancerRule

public class MyRule implements IRule{
    private static Logger log = LoggerFactory.getLogger(MyRule.class);

    private ILoadBalancer lb;
    @Override
    public Server choose(Object key){
        if (lb == null) {
            return null;
        }
        Server server = null;

        while (server == null) {
            if (Thread.interrupted()) {
                return null;
            }
            List<Server> allList = lb.getAllServers();
            int serverCount = allList.size();
            if (serverCount == 0) {
                log.warn("No up servers available from load balancer: " + lb);
                return null;
            }
            // 是轮询、随机、加权、hash?自己实现从server list中选择一个server
            // 这里写简单点,总是请求第一台服务,这样的逻辑是不会用到真实的环境的
            server = allList.get(0);
        }
        return server;
    }

    @Override
    public void setLoadBalancer(ILoadBalancer lb){
        this.lb = lb;
    }

    @Override
    public ILoadBalancer getLoadBalancer(){
        return lb;
    }
}

然后就可以用Java Bean的方式或者配置文件的方式进行配置了,其他像自定义ping的策略也差不多。

Ribbon总结

  1. Ribbon 没有类似@EnableRibbon这样的注解
  2. 新版的SpringCloud已经不使用Ribbon作为默认的负载均衡器了
  3. 可以使用@RibbonClients@RibbonClient 注解来负载均衡相关策略的配置
  4. 实现对应的接口就可以完成自定义负载均衡策略
  5. Ribbon 配置的所有key都可以在CommonClientConfigKey类中查看

代码示例:Github ribbon client