Spring Cloud Gateway的斷路器(CircuitBreaker)功能

語言: CN / TW / HK

歡迎訪問我的GitHub

http://github.com/zq2599/blog_demos

內容:所有原創文章分類彙總及配套原始碼,涉及Java、Docker、Kubernetes、DevOPS等;

本篇概覽

  • 一起深入瞭解Spring Cloud Gateway的斷路器(CircuitBreaker)功能:
  1. 先聊聊理論
  2. 再結合官方和大神的資訊確定技術棧
  3. 再動手開發,先實現再驗證
  4. 再趁熱打鐵,看看它的原始碼
  5. 最後,回顧一下有哪些不足(下一篇文章解決這些不足)

關於斷路器(CircuitBreaker)

  • 下圖來自resilience4j官方文件,介紹了什麼是斷路器:

在這裡插入圖片描述

  1. CLOSED狀態時,請求正常放行
  2. 請求失敗率達到設定閾值時,變為OPEN狀態,此時請求全部不放行
  3. OPEN狀態持續設定時間後,進入半開狀態(HALE_OPEN),放過部分請求
  4. 半開狀態下,失敗率低於設定閾值,就進入CLOSE狀態,即全部放行
  5. 半開狀態下,失敗率高於設定閾值,就進入OPEN狀態,即全部不放行

確認概念

  • 有個概念先確認一下,即<font color="blue">Spring Cloud斷路器</font>與<font color="blue">Spring Cloud Gateway斷路器功能</font>不是同一個概念,Spring Cloud Gateway斷路器功能還涉及過濾器,即在過濾器的規則下使用斷路器:

在這裡插入圖片描述

  • 本篇的重點是Spring Cloud Gateway如何配置和使用斷路器(CircuitBreaker),因此不會討論Resilience4J的細節,如果您想深入瞭解Resilience4J,推薦資料是Spring Cloud Circuit Breaker

關於Spring Cloud斷路器

  • 先看Spring Cloud斷路器,如下圖,Hystrix、Sentinel這些都是熟悉的概念:

在這裡插入圖片描述

關於Spring Cloud Gateway的斷路器功能

  • 來看Spring Cloud Gateway的官方文件,如下圖,有幾個關鍵點稍後介紹:

在這裡插入圖片描述

  • 上圖透露了幾個關鍵資訊:
  1. Spring Cloud Gateway內建了斷路器filter,
  2. 具體做法是使用Spring Cloud斷路器的API,將gateway的路由邏輯封裝到斷路器中
  3. 有多個斷路器的庫都可以用在Spring Cloud Gateway(遺憾的是沒有列舉是哪些)
  4. Resilience4J對Spring Cloud 來說是開箱即用的
  • 簡單來說Spring Cloud Gateway的斷路器功能是通過內建filter實現的,這個filter使用了Spring Cloud斷路器;

  • 官方說多個斷路器的庫都可以用在Spring Cloud Gateway,但是並沒有說具體是哪些,這就鬱悶了,此時咱們去了解一位牛人的觀點:Piotr Mińkowski,就是下面這本書的作者:

在這裡插入圖片描述

  • Piotr Mińkowski的部落格對Spring Cloud Gateway的斷路器功能做了詳細介紹,如下圖,有幾個重要資訊稍後會提到:

在這裡插入圖片描述

  • 上圖可以get到三個關鍵資訊:
  1. 從2.2.1版本起,Spring Cloud Gateway集成了Resilience4J的斷路器實現
  2. Netflix的Hystrix進入了維護階段(能理解為即將退休嗎?)
  3. Netflix的Hystrix依然可用,但是已廢棄(deprecated),而且Spring Cloud將來的版本可能會不支援
  • 再關聯到官方文件也以resilience4為例(如下圖),膽小的我似乎沒有別的選擇了,就Resilience4J吧:

在這裡插入圖片描述

  • 理論分析就到此吧,接下來開始實戰,具體的步驟如下:
  1. 準備工作:服務提供者新增一個web介面<font color="blue">/account/{id}</font>,根據入參的不同,該介面可以立即返回或者延時500毫秒返回
  2. 新增名為<font color="blue">circuitbreaker-gateway</font>的子工程,這是個帶有斷路器功能的Spring Cloud Gateway應用
  3. 在<font color="blue">circuitbreaker-gateway</font>裡面編寫單元測試程式碼,用來驗證斷路器是否正常
  4. 執行單元測試程式碼,觀察斷路器是否生效
  5. 給斷路器新增fallback並驗證是否生效
  6. 做一次簡單的原始碼分析,一為想深入瞭解斷路器的同學捋清楚原始碼路徑,二為檢驗自己以前瞭解的springboot知識在閱讀原始碼時有麼有幫助

原始碼下載

名稱 連結 備註
專案主頁 http://github.com/zq2599/blog_demos 該專案在GitHub上的主頁
git倉庫地址(https) http://github.com/zq2599/blog_demos.git 該專案原始碼的倉庫地址,https協議
git倉庫地址(ssh) [email protected]:zq2599/blog_demos.git 該專案原始碼的倉庫地址,ssh協議
  • 這個git專案中有多個資料夾,本篇的原始碼在<font color="blue">spring-cloud-tutorials</font>資料夾下,如下圖紅框所示:

在這裡插入圖片描述

  • <font color="blue">spring-cloud-tutorials</font>資料夾下有多個子工程,本篇的程式碼是<font color="red">circuitbreaker-gateway</font>,如下圖紅框所示:

在這裡插入圖片描述

準備工作

  • 咱們要準備一個可控的web介面,通過引數控制它成功或者失敗,這樣才能觸發斷路器

  • 本篇的實戰中,服務提供者依舊是<font color="blue">provider-hello</font>,為了滿足本次實戰的需求,咱們在Hello.java檔案中增加一個web介面,對應的原始碼如下:

    @RequestMapping(value = "/account/{id}", method = RequestMethod.GET)
    public String account(@PathVariable("id") int id) throws InterruptedException {
        if(1==id) {
            Thread.sleep(500);
        }

        return Constants.ACCOUNT_PREFIX + dateStr();
    }
  • 上述程式碼很簡單:就是接收id引數,如果等於1就延時五百毫秒,不等於1就立即返回

  • 如果把斷路器設定為超過兩百毫秒就算失敗,那麼通過控制id引數的值,咱們就能模擬請求成功或者失敗了,<font color="blue">這是驗證斷路器功能的關鍵</font>

  • 準備完成,開始寫程式碼

實戰

  • 在父工程<font color="blue">spring-cloud-tutorials</font>下面新增子工程<font color="blue">circuitbreaker-gateway</font>

  • 增加以下依賴

<dependency>
	<groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>
  • 配置檔案application.yml如下:
server:
  #服務埠
  port: 8081
spring:
  application:
    name: circuitbreaker-gateway
  cloud:
    gateway:
      routes:
        - id: path_route
          uri: http://127.0.0.1:8082
          predicates:
            - Path=/hello/**
          filters:
            - name: CircuitBreaker
              args:
                name: myCircuitBreaker
  • 啟動類:
package com.bolingcavalry.circuitbreakergateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CircuitbreakerApplication {
    public static void main(String[] args) {
        SpringApplication.run(CircuitbreakerApplication.class,args);
    }
}
  • 配置類如下,這是斷路器相關的引數配置:
package com.bolingcavalry.circuitbreakergateway.config;

import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JCircuitBreakerFactory;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;

@Configuration
public class CustomizeCircuitBreakerConfig {

    @Bean
    public ReactiveResilience4JCircuitBreakerFactory defaultCustomizer() {

        CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom() //
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.TIME_BASED) // 滑動視窗的型別為時間視窗
                .slidingWindowSize(10) // 時間視窗的大小為60秒
                .minimumNumberOfCalls(5) // 在單位時間視窗內最少需要5次呼叫才能開始進行統計計算
                .failureRateThreshold(50) // 在單位時間視窗內呼叫失敗率達到50%後會啟動斷路器
                .enableAutomaticTransitionFromOpenToHalfOpen() // 允許斷路器自動由開啟狀態轉換為半開狀態
                .permittedNumberOfCallsInHalfOpenState(5) // 在半開狀態下允許進行正常呼叫的次數
                .waitDurationInOpenState(Duration.ofSeconds(5)) // 斷路器開啟狀態轉換為半開狀態需要等待60秒
                .recordExceptions(Throwable.class) // 所有異常都當作失敗來處理
                .build();

        ReactiveResilience4JCircuitBreakerFactory factory = new ReactiveResilience4JCircuitBreakerFactory();
        factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
                .circuitBreakerConfig(circuitBreakerConfig).build());

        return factory;
    }
}
  • 上述程式碼有一次需要注意:<font color="blue">timeLimiterConfig</font>方法設定了超時時間,服務提供者如果超過200毫秒沒有響應,Spring Cloud Gateway就會向呼叫者返回失敗

  • 開發完成了,接下來要考慮的是如何驗證

單元測試類

  • 為了驗證Spring Cloud Gateway的斷路器功能,咱們可以用Junit單元測試來精確控制請求引數和請求次數,測試類如下,可見測試類會連續發一百次請求,在前五十次中,請求引數始終在0和1之間切換,引數等於1的時候,介面會有500毫秒延時,超過了Spring Cloud Gateway的200毫秒超時限制,這時候就會返回失敗,等失敗多了,就會觸發斷路器的斷開:
package com.bolingcavalry.circuitbreakergateway;

import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.reactive.server.WebTestClient;

@SpringBootTest
@ExtendWith(SpringExtension.class)
@AutoConfigureWebTestClient
public class CircuitbreakerTest {

    // 測試的總次數
    private static int i=0;

    @Autowired
    private WebTestClient webClient;

    @Test
    @RepeatedTest(100)
    void testHelloPredicates() throws InterruptedException {
        // 低於50次時,gen在0和1之間切換,也就是一次正常一次超時,
        // 超過50次時,gen固定為0,此時每個請求都不會超時
        int gen = (i<50) ? (i % 2) : 0;

        // 次數加一
        i++;

        final String tag = "[" + i + "]";

        // 發起web請求
        webClient.get()
                .uri("/hello/account/" + gen)
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectBody(String.class).consumeWith(result  -> System.out.println(tag + result.getRawStatusCode() + " - " + result.getResponseBody()));

        Thread.sleep(1000);
    }
}

驗證

  • 啟動nacos(服務提供者依賴的)

  • 啟動子工程<font color="blue">provider-hello</font>

  • 執行咱們剛才開發的單元測試類,控制檯輸入的內容擷取部分如下,稍後會有分析:

[2]504 - {"timestamp":"2021-08-28T02:55:42.920+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"594efed1"}
[3]200 - Account2021-08-28 10:55:43
[4]504 - {"timestamp":"2021-08-28T02:55:45.177+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"427720b"}
[5]200 - Account2021-08-28 10:55:46
[6]503 - {"timestamp":"2021-08-28T02:55:47.227+00:00","path":"/hello/account/1","status":503,"error":"Service Unavailable","message":"","requestId":"6595d7f4"}
[7]503 - {"timestamp":"2021-08-28T02:55:48.250+00:00","path":"/hello/account/0","status":503,"error":"Service Unavailable","message":"","requestId":"169ae1c"}
[8]503 - {"timestamp":"2021-08-28T02:55:49.259+00:00","path":"/hello/account/1","status":503,"error":"Service Unavailable","message":"","requestId":"53b695a1"}
[9]503 - {"timestamp":"2021-08-28T02:55:50.269+00:00","path":"/hello/account/0","status":503,"error":"Service Unavailable","message":"","requestId":"4a072f52"}
[10]504 - {"timestamp":"2021-08-28T02:55:51.499+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"4bdd96c4"}
[11]200 - Account2021-08-28 10:55:52
[12]504 - {"timestamp":"2021-08-28T02:55:53.745+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"4e0e7eab"}
[13]200 - Account2021-08-28 10:55:54
[14]504 - {"timestamp":"2021-08-28T02:55:56.013+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"27685405"}
[15]503 - {"timestamp":"2021-08-28T02:55:57.035+00:00","path":"/hello/account/0","status":503,"error":"Service Unavailable","message":"","requestId":"3e40c5db"}
[16]503 - {"timestamp":"2021-08-28T02:55:58.053+00:00","path":"/hello/account/1","status":503,"error":"Service Unavailable","message":"","requestId":"2bf2698b"}
[17]503 - {"timestamp":"2021-08-28T02:55:59.075+00:00","path":"/hello/account/0","status":503,"error":"Service Unavailable","message":"","requestId":"38cb1840"}
[18]503 - {"timestamp":"2021-08-28T02:56:00.091+00:00","path":"/hello/account/1","status":503,"error":"Service Unavailable","message":"","requestId":"21586fa"}
[19]200 - Account2021-08-28 10:56:01
[20]504 - {"timestamp":"2021-08-28T02:56:02.325+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"4014d6d4"}
[21]200 - Account2021-08-28 10:56:03
[22]504 - {"timestamp":"2021-08-28T02:56:04.557+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"173a3b9d"}
[23]200 - Account2021-08-28 10:56:05
[24]504 - {"timestamp":"2021-08-28T02:56:06.811+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"aa8761f"}
[25]200 - Account2021-08-28 10:56:07
[26]504 - {"timestamp":"2021-08-28T02:56:09.057+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"769bfefc"}
[27]200 - Account2021-08-28 10:56:10
[28]504 - {"timestamp":"2021-08-28T02:56:11.314+00:00","path":"/hello/account/1","status":504,"error":"Gateway Timeout","message":"","requestId":"2fbcb6c0"}
[29]503 - {"timestamp":"2021-08-28T02:56:12.332+00:00","path":"/hello/account/0","status":503,"error":"Service Unavailable","message":"","requestId":"58e4e70f"}
[30]503 - {"timestamp":"2021-08-28T02:56:13.342+00:00","path":"/hello/account/1","status":503,"error":"Service Unavailable","message":"","requestId":"367651c5"}
  • 分析上述輸出的返回碼:
  1. 504是超時返回的錯誤,200是服務提供者的正常返回
  2. 504和200兩種返回碼都表示請求到達了服務提供者,所以此時斷路器是關閉狀態
  3. 多次504錯誤後,達到了配置的門限,觸發斷路器開啟
  4. 連續出現的503就是斷路器開啟後的返回碼,此時請求是無法到達服務提供者的
  5. 連續的503之後,504和200再次交替出現,證明此時進入半開狀態,然後504再次達到門限觸發斷路器從半開轉為開啟,五十次之後,由於不在傳送超時請求,斷路器進入關閉狀態

fallback

  • 通過上述測試可見,Spring Cloud Gateway通過返回碼來告知呼叫者錯誤資訊,這種方式不夠友好,我們可以自定義fallback,在返回錯誤時由它來構建返回資訊

  • 再開發一個web介面,沒錯,就是在<font color="blue">circuitbreaker-gateway</font>工程中新增一個web介面:

package com.bolingcavalry.circuitbreakergateway.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.text.SimpleDateFormat;
import java.util.Date;

@RestController
public class Fallback {

    private String dateStr(){
        return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date());
    }

    /**
     * 返回字串型別
     * @return
     */
    @GetMapping("/myfallback")
    public String helloStr() {
        return "myfallback, " + dateStr();
    }
}
  • application.yml配置如下,可見是給filter增加了<font color="blue">fallbackUri</font>屬性:
server:
  #服務埠
  port: 8081
spring:
  application:
    name: circuitbreaker-gateway
  cloud:
    gateway:
      routes:
        - id: path_route
          uri: http://127.0.0.1:8082
          predicates:
            - Path=/hello/**
          filters:
            - name: CircuitBreaker
              args:
                name: myCircuitBreaker
                fallbackUri: forward:/myfallback
  • 再執行單元測試,可見返回碼全部是200,原來的錯誤現在全部變成了剛才新增的介面的返回內容:
[2]200 - myfallback, 2021-08-28 11:15:02
[3]200 - Account2021-08-28 11:15:03
[4]200 - myfallback, 2021-08-28 11:15:04
[5]200 - Account2021-08-28 11:15:05
[6]200 - myfallback, 2021-08-28 11:15:06
[7]200 - myfallback, 2021-08-28 11:15:08
[8]200 - myfallback, 2021-08-28 11:15:09
[9]200 - myfallback, 2021-08-28 11:15:10
[10]200 - myfallback, 2021-08-28 11:15:11
[11]200 - Account2021-08-28 11:15:12
[12]200 - myfallback, 2021-08-28 11:15:13
[13]200 - Account2021-08-28 11:15:14
[14]200 - myfallback, 2021-08-28 11:15:15
  • 至此,咱們已完成了Spring Cloud Gateway的斷路器功能的開發和測試,如果聰明好學的您並不滿足這寥寥幾行配置和程式碼,想要深入瞭解斷路器的內部,那麼請您接往下看,咱們聊聊它的原始碼;

原始碼分析

  • RouteDefinitionRouteLocator的構造方法(bean注入)中有如下程式碼,將name和例項繫結:
gatewayFilterFactories.forEach(factory -> this.gatewayFilterFactories.put(factory.name(), factory));
  • 然後會在loadGatewayFilters方法中使用這個map,找到上面put的bean;

  • 最終的效果:路由配置中指定了name等於<font color="blue">CircuitBreaker</font>,即可對應SpringCloudCircuitBreakerFilterFactory型別的bean,因為它的name方法返回了"CircuitBreaker",如下圖:

在這裡插入圖片描述

  • 現在的問題:SpringCloudCircuitBreakerFilterFactory型別的bean是什麼?如下圖紅框,SpringCloudCircuitBreakerResilience4JFilterFactory是SpringCloudCircuitBreakerFilterFactory唯一的子類:

在這裡插入圖片描述

  • 從上圖來看,CircuitBreaker型別的filter應該是SpringCloudCircuitBreakerResilience4JFilterFactory,不過那只是從繼承關係推斷出來的,還差一個關鍵證據:在spring中,到底存不存在SpringCloudCircuitBreakerResilience4JFilterFactory型別的bean?

  • 最終發現了GatewayResilience4JCircuitBreakerAutoConfiguration中的配置,可以證明SpringCloudCircuitBreakerResilience4JFilterFactory會被例項化並註冊到spring:

@Bean
	@ConditionalOnBean(ReactiveResilience4JCircuitBreakerFactory.class)
	@ConditionalOnEnabledFilter
	public SpringCloudCircuitBreakerResilience4JFilterFactory springCloudCircuitBreakerResilience4JFilterFactory(
			ReactiveResilience4JCircuitBreakerFactory reactiveCircuitBreakerFactory,
			ObjectProvider<DispatcherHandler> dispatcherHandler) {
		return new SpringCloudCircuitBreakerResilience4JFilterFactory(reactiveCircuitBreakerFactory, dispatcherHandler);
	}
  • 綜上所述,當您配置了CircuitBreaker過濾器時,實際上是SpringCloudCircuitBreakerResilience4JFilterFactory類在為您服務,而關鍵程式碼都集中在其父類SpringCloudCircuitBreakerFilterFactory中;

  • 所以,要想深入瞭解Spring Cloud Gateway的斷路器功能,請閱讀SpringCloudCircuitBreakerFilterFactory.apply方法

一點遺憾

  • 還記得剛才分析控制檯輸出的那段內容嗎?就是下圖紅框中的那段,當時咱們用返回碼來推測斷路器處於什麼狀態:

在這裡插入圖片描述

  • 相信您在看這段純文字時,對欣宸的分析還是存在疑惑的,根據返回碼就把斷路器的狀態確定了?例如504的時候到底是關閉還是半開呢?都有可能吧,所以,這種推測只能證明斷路器正在工作,但是無法確定某個時刻具體的狀態

  • 所以,咱們需要一種更準確的方式知道每個時刻斷路器的狀態,這樣才算對斷路器有了深刻了解

  • 接下來的文章中,咱們在今天的成果上更進一步,在請求中把斷路器狀態打印出來,那就...敬請期待吧,欣宸原創,從未讓您失望;

你不孤單,欣宸原創一路相伴

  1. Java系列
  2. Spring系列
  3. Docker系列
  4. kubernetes系列
  5. 資料庫+中介軟體系列
  6. DevOps系列

歡迎關注公眾號:程式設計師欣宸

微信搜尋「程式設計師欣宸」,我是欣宸,期待與您一同暢遊Java世界... http://github.com/zq2599/blog_demos