③SpringCloud 實戰:使用 Ribbon 客戶端負載均衡

Admol發表於2020-11-30

這是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 快速啟動三個不同埠專案方法截圖,當然你也可以用其他辦法

  3. 然後啟動,訪問三個介面測試一下是否正常返回了對應埠

至此,服務提供者的介面準備工作就做好了。

新建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> <!--修改了版本jinglingwang.cn-->
    <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 介面,多次重新整理介面發現採用的是輪詢方式,執行效果圖如下:

二、結合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 介面,多次重新整理介面發現採用的也是輪詢方式,執行效果圖如下:

三、結合Feign使用

新建一個Feign:

@FeignClient(value = "eureka-provider")
public interface ProviderFeign{
    /**
     * 呼叫服務提供方,其中會返回服務提供者的埠資訊
     * @return jinglingwang.cn
     */
    @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 介面,多次重新整理介面發現採用的也是輪詢方式,執行效果圖如下:

自定義Ribbon配置

為指定的客戶端自定義負載均衡規則

在配置之前先做一點準備工作,我們把之前的服務eureka-provider再起3個節點,啟動之前把埠改為8085、8086、8087,三個節點的服務名改為eureka-provider-temp。這樣做的目的是等會兒我們新建一個Feign,但是名字和之前的區分開,相當於兩個不同的服務,並且都是多節點的。

以上準備工作做完之後你會在IDEA中看到如下圖的6個服務:

在註冊中心也可以觀察到2個不同的服務,一共6個節點:

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. 通過檢視異常輸出棧的錯誤日誌資訊,定位到丟擲異常的地方

  2. 之後繼續往前面找相關的邏輯,加斷點,慢慢除錯,發現有一個欄位(autowiredBeanName)為空,才會進入到後面拋異常的邏輯

  3. 斷點也顯示matchingBeans裡面有兩條資料,說明確實是匹配到了2個bean

  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

相關文章