四. SpringCloud負載均衡與調用

1. Ribbon概述

1.1 Ribbon是什麼

SpringCloud Ribbon是基於Netflix Ribbon實現的一套客戶端,是負載均衡的工具。

Ribbon是Netflix發布的開源項目,主要功能是提供客戶端的軟體複雜均衡演算法和服務調用。Ribbon客戶端組件提供一系列完整的配置項如連接超時、重試等。簡單的說,就是在配置文件中列出Load Balancer(負載均衡簡稱LB)後面所有的機器,Ribbon會自動的幫助你基於某種規則(如簡單輪詢,隨機連接等)去連接這些機器。也可以使用Ribbon實現自定義的負載均衡演算法。

1.2 Ribbon能做什麼

主要是負載均衡(LB):所謂負載均衡,簡單的說就是將用戶的請求平攤的分配到多個服務上,從而達到系統的HA(High Available高可用),常見的負載均衡有軟體Nginx、LVS,硬體F5等。

Ribbon本地負載均衡客戶端和Nginx服務端負載均衡的區別:

  • Nginx是伺服器負載均衡,客戶端所有請求都會交給Nginx,然後由Nginx實現轉發請求,即負載均衡是由服務端實現的。
  • Ribbon是本地負載均衡,在調用微服務介面時候,會在註冊中心上獲取註冊資訊服務列表之後快取到JVM本地,從而在本地實現RPC遠程服務調用技術。

負載均衡又分為兩類,分別可以對應於Nginx和Ribbon:

  • 集中式LB:即在服務的消費方和提供方之間使用獨立的LB設施(可以是硬體,如F5,也可以是軟體,如Nginx),由該設施負責把訪問請求通過某種策略轉發至服務的提供方。
  • 進程內LB:將LB邏輯集成到消費方,消費方從服務註冊中心獲知有哪些地址可用,然後自己再從這些地址中選擇出一個合適的伺服器,Ribbon就屬於進程內LB,它只是一個類庫,集成於消費方進程,消費方通過它來獲取到服務提供方的地址。

Ribbon實際上就是負載均衡 + RestTemplate調用

2. Ribbon使用案例

2.1 架構說明

Ribbon其實就是一個負載均衡的客戶端組件,他可以和其他所需請求的客戶端結合使用,和eureka結合只是其中的一個實例。Ribbon在工作的時候分兩步:

  • 先選擇EurekaServer,優先選擇同一個區域內負載較少的Server

  • 根據用戶指定的策略,從Server取到的服務註冊列表中選擇一個地址

其中Ribbon提供了多種的負載均衡策略,如輪詢、隨機和根據響應時間加強等。

image-20210128235245749

2.2 pom.xml

在POM文件中我們引入了如下依賴:

<!--eureka-client-->
<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

點開該依賴的源碼,我們發現事實上該依賴內部已經引入了Ribbon,其引入Ribbon的源碼如下:

<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
    <version>2.2.1.RELEASE</version>
    <scope>compile</scope>
</dependency>

我們在Maven的依賴中也可以看到,在引入 spring-cloud-starter-netflix-eureka-client 的同時我們就已經引入了 **spring-cloud-starter-netflix-ribbon **,所以我們沒必要單獨添加Ribbon的依賴。

image-20210128231128616

1.3 RestTemplate使用

RestTemplate官方說明可以在RestTemplate官方API查看,下面簡要說明其主要方法

  • getForObject方法:返回對象為響應體數據轉化成的對象,基本上可以理解為Json對象。
  • getForEntity方法:返回對象為ResponseEntity對象,包含了響應中的一些重要資訊,比如響應頭、響應狀態碼、響應體等。
@RestController
@Slf4j
@RequestMapping("/consumer")
public class OrderController {
    @Resource
    private RestTemplate restTemplate;

    // private static final String PAYMENT_URL = "//localhost:8001";
    private static final String PAYMENT_SRV = "//CLOUD-PAYMENT-SERVICE";

    @GetMapping("/payment/get/{id}")
    public CommonResult<Payment> getPayment(@PathVariable("id") Long id) {
        return restTemplate.getForObject(PAYMENT_SRV
                + "/payment/get/"
                + id, CommonResult.class);
    }

    @GetMapping("/payment/getForEntity/{id}")
    public CommonResult<Payment> getPayment2(@PathVariable("id") Long id) {
        ResponseEntity<CommonResult> entity =
                restTemplate.getForEntity(PAYMENT_SRV
                 + "/payment/get/" + id, CommonResult.class);
        if (entity.getStatusCode().is2xxSuccessful()) {
            log.info("===> " + entity.getStatusCode()
                  + "\t" + entity.getHeaders());
            return entity.getBody(); //返回請求體
        } else {
            return new CommonResult<>(444, "操作失敗");
        }
    }
}

在後台控制台也輸出了狀態碼和請求頭的如下日誌:

===> 200 OK	[Content-Type:"application/json", Transfer-Encoding:"chunked", Date:"Thu, 28 Jan 2021 15:44:50 GMT", Keep-Alive:"timeout=60", Connection:"keep-alive"]

3. Ribbon核心組件IRule介面

3.1 IRule理解

它可以根據特定演算法從服務列表中選取一個要訪問的服務

IRule是一個介面,其源碼如下:

package com.netflix.loadbalancer;

/**
 * Interface that defines a "Rule" for a LoadBalancer. A Rule can be thought of
 * as a Strategy for loadbalacing. Well known loadbalancing strategies include
 * Round Robin, Response Time based etc.
 * 
 * @author stonse
 * 
 */
public interface IRule{
    /*
     * choose one alive server from lb.allServers or
     * lb.upServers according to key
     * 
     * @return choosen Server object. NULL is returned if none
     *  server is available 
     */

    public Server choose(Object key);
    
    public void setLoadBalancer(ILoadBalancer lb);
    
    public ILoadBalancer getLoadBalancer();    
}

以下是IRule介面的部分實現,這些實現分別對應了若干負載均衡演算法

image-20210128235959015

以下簡要說明7種主要的負載均衡演算法,這些負載均衡演算法均是抽象類com.netflix.loadbalancer.AbstractLoadBalancerRule 的實現,而給抽象類實現了IRule介面:

  • com.netflix.loadbalancer.RoundRobinRule:輪詢,為默認的負載均衡演算法
  • com.netflix.loadbalancer.RandomRule:隨機
  • com.netflix.loadbalancer.RetryRule:先按照RoundRobinRule(輪詢)的策略獲取服務,如果獲取服務失敗則在指定時間內進行重試,獲取可用的服務
  • com.netflix.loadbalancer.WeightedResponseTimeRule:對RoundRobinRule的擴展,響應速度越快的實例選擇權重越大,越容易被選擇。
  • com.netflix.loadbalancer.BestAvailableRule:先過濾掉由於多次訪問故障而處於斷路器跳閘狀態的服務,然後選擇一個並發量最小的服務
  • com.netflix.loadbalancer.AvailabilityFilteringRule:先過濾掉故障實例,再選擇並發較小的實例
  • com.netflix.loadbalancer.ZoneAvoidanceRule:複合判斷Server所在區域的性能和Server的可用性選擇伺服器
3.2 如何替換負載均衡演算法

服務消費者80添加輪詢演算法配置類

首先我們應該明確是服務消費方採用輪詢演算法來訪問同一服務提供方的不同微服務實例,所以我們應該在服務消費方80方的微服務中添加輪詢演算法配置類。

在添加配置類時,有必須要注意的點,就是官方文檔明確給出了警告:這個自定義的輪詢演算法配置類不能放在@ComponentScan註解所掃描的當前包下以及子包下,否則自定義的這個配置類就會被所有Ribbon客戶端所共享,就達不到特殊化訂製的目的了。換句話說,如果這個配置類我們能夠被@ComponentScan註解掃描到,那麼訪問所有的微服務提供方的具體實例時,我們都會採取配置類中的演算法,如果要特殊化訂製 – 即指定訪問某些微服務提供方時採用配置的輪詢演算法,那麼我們就應該使這個配置類讓@ComponentScan註解掃描不到,我們知道在主啟動類的@SpringBootApplication註解中,其實這個註解包含了@SpringBootConfiguration@EnableAutoConfiguration@ComponentScan這三個註解,所以我們寫的輪詢演算法配置類不能和主啟動類在同一個包下,所以我們需要建新的包,實現訂製輪詢演算法的配置類:

package com.polaris.myrule;

/**
 * @author polaris
 */
@Configuration
public class MySelfRule {

    @Bean
    public IRule myRule() {
        return new RandomRule(); //定義隨機負載均衡演算法
    }
}

包結構的內容如下,我們可以看到,輪詢演算法配置類在主啟動類的@ComponentScan掃描不到的包下:

image-20210129000859823

服務消費者80主啟動類中添加@RibbonClient註解

@SpringBootApplication
@EnableEurekaClient
//訪問的微服務為CLOUD-PAYMENT-SERVICE,採用配置文件中的輪詢演算法
@RibbonClient(name = "CLOUD-PAYMENT-SERVICE", configuration = MySelfRule.class)
public class OrderMain {
    public static void main(String[] args) {
        SpringApplication.run(OrderMain80.class);
    }
}

測試

測試發現我們用服務消費方訪問服務提供方的微服務時,8001和8002不再交替輪詢訪問,而是隨機訪問。

4. Ribbon負載均衡演算法

4.1 默認負載均衡演算法(輪詢)原理

輪詢負載均衡演算法原理:Rest介面第幾次請求數 % 伺服器集群總數量 = 實際調用伺服器位置下標, 每次服務重啟後Rest介面計數從1開始。

List<ServiceInstance> instances = discoveryClient.getInstances("CLOUD-PAYMENT-SERVICE")

根據服務方的服務名,獲取其所有實例,如有以下實例:

List[0] List[1]
服務名 payment8001 payment8002
服務地址 127.0.0.1:8001 127.0.0.1:8002

這兩個實例組合成一個集群,共2台機器,集群總數為2,按照輪詢負載均衡演算法原理:

  • 請求總數為1時,1 % 2 = 1,對應下標位置是1,獲得服務地址127.0.0.1:8001

  • 請求總數為2時,2 % 2 = 0,對應下標位置是0,獲得服務地址127.0.0.1:8002

  • 請求總數為3時,3 % 2 = 1,對應下標位置是1,獲得服務地址127.0.0.1:8001

4.2 輪詢源碼分析

com.netflix.loadbalancer.RoundRobinRule源碼的負載均衡演算法部分分析如下(程式碼中標註了中文注釋):

package com.netflix.loadbalancer;

/**
 * The most well known and basic load balancing strategy, i.e. Round Robin Rule.
 */
public class RoundRobinRule extends AbstractLoadBalancerRule {

   	//...

    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) {
            //獲得還活著的健康的服務實例(機器)即可達的,也就是Status為up的實例
            List<Server> reachableServers = lb.getReachableServers();
            //獲取所有服務實例,無論是死是活,只要註冊進服務中心即可
            List<Server> allServers = lb.getAllServers();
            //Status為up的服務實例數量
            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;
            }

            //調用伺服器位置下標 = incrementAndGetModulo(伺服器集群總數)
            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;
            if (nextServerCyclicCounter.compareAndSet(current, next))
                return next;
        }
    }
}
4.3 自己實現輪詢負載均衡演算法

首先我們將服務註冊中心(7001/7002構成集群)啟動,然後在服務提供方8001/8002中的Controller中添加功能,用來一會兒測試服務消費方80來輪詢訪問CLOUD-PAYMENT-SERVICE服務:

@GetMapping("/payment/lb")
public String getPaymentLB(){
	return serverPort;
}

服務提供方的這個方法就是簡單的在頁面輸出自己的埠號,也就是我們可以在頁面區分訪問的CLOUD-PAYMENT-SERVICE服務到底對應的是8001實例還是8002實例。

啟動8001/8002,將兩個服務實例註冊進服務註冊中心後,我們再改造服務消費方80服務,分為以下四步:

  • 首先我們先讓RestTemplate失去Ribbon中的負載均衡能力,取消掉@LoadBalanced註解即可:
@Configuration
public class ApplicationContextConfig {
    @Bean
//    @LoadBalanced//使用該註解賦予RestTemplate負載均衡的能力
    public RestTemplate getRestTemplate() {
        return new RestTemplate();
    }
}
//applicationContext.xml <bean id="" class="">
  • 然後編寫自己的負載均衡介面:

給介面定義了方法instances用於在服務提供方服務的所有服務實例中選擇一個具體實例。

public interface LoadBalancer {
    /**
     * 從服務列表中用負載均衡演算法選擇出具體的實例
     * @param serviceInstances 服務列表
     * @return
     */
    ServiceInstance instances(List<ServiceInstance> serviceInstances);
}
  • 用輪詢負載均衡演算法實現負載均衡介面:

RoundRobinRule源碼中用for(;;)實現的自旋鎖,這裡我們用do{} while();實現自旋鎖。

@Component
public class MyLB implements LoadBalancer {

    private AtomicInteger atomicInteger = new AtomicInteger(0);

    public final int getAndIncrement() {
        int current;
        int next;
        //自旋鎖
        do {
            current = this.atomicInteger.get(); //初始值為0
            next = current >= 2147483647 ? 0 : current + 1; //最大整數
        } while (!this.atomicInteger.compareAndSet(current, next));
        System.out.println("===> 訪問次數next:" + next);
        return next;
    }

    /**
     * 從服務列表中用輪詢負載均衡演算法選擇出具體的實例
     * Rest介面第幾次請求數 % 伺服器集群總數量 = 實際調用伺服器位置下標
     *
     * @param serviceInstances 服務列表
     * @return
     */
    @Override
    public ServiceInstance instances(List<ServiceInstance> serviceInstances) {
        int index = getAndIncrement() % serviceInstances.size();
        return serviceInstances.get(index);
    }
}
  • 最後我們在80服務的Controller中添加方法:
@RestController
@Slf4j
@RequestMapping("/consumer")
public class OrderController {
    @Resource
    private RestTemplate restTemplate;

    @Resource
    private LoadBalancer loadBalancer;

    @Resource
    private DiscoveryClient discoveryClient;

    @GetMapping("payment/lb")
    public String getPaymentLB() {
        //獲取服務提供方所有的服務實例
        List<ServiceInstance> instances = discoveryClient.getInstances("CLOUD-PAYMENT-SERVICE");
        if (instances == null || instances.size() <= 0) {
            return null;
        }
        //採用自己實現的輪詢負載均衡演算法選擇具體實例
        ServiceInstance serviceInstance = loadBalancer.instances(instances);
        URI uri = serviceInstance.getUri();
        return restTemplate.getForObject(uri + "/payment/lb", String.class);
    }
}

在瀏覽器中輸入//localhost/consumer/payment/lb,也就是80埠的服務消費方採用我們自己編寫的輪詢負載均衡演算法訪問CLOUD-PAYMENT-SERVICE服務的具體實例,測試成功,在服務消費方80服務的後端控制台也輸出了的日誌。