SpringCloud升級之路2020.0.x版-31. FeignClient 實現斷路器以及線程隔離限流的思路


本系列代碼地址:https://github.com/JoJoTec/spring-cloud-parent

在前面一節,我們實現了 FeignClient 粘合 resilience4j 的 Retry 實現重試。細心的讀者可能會問,為何在這里的實現,不把斷路器和線程限流一起加上呢:


@Bean
public FeignDecorators.Builder defaultBuilder(
        Environment environment,
        RetryRegistry retryRegistry
) {
    //獲取微服務名稱
    String name = environment.getProperty("feign.client.name");
    Retry retry = null;
    try {
        retry = retryRegistry.retry(name, name);
    } catch (ConfigurationNotFoundException e) {
        retry = retryRegistry.retry(name);
    }

    //覆蓋其中的異常判斷,只針對 feign.RetryableException 進行重試,所有需要重試的異常我們都在 DefaultErrorDecoder 以及 Resilience4jFeignClient 中封裝成了 RetryableException
    retry = Retry.of(name, RetryConfig.from(retry.getRetryConfig()).retryOnException(throwable -> {
        return throwable instanceof feign.RetryableException;
    }).build());

    return FeignDecorators.builder().withRetry(
            retry
    );
}

主要原因是,這里增加斷路器以及線程隔離,其粒度是微服務級別的,這樣的壞處是:

  • 微服務中只要有一個實例一直異常,整個微服務就會被斷路
  • 微服務只要有一個方法一直異常,整個微服務就會被斷路
  • 微服務的某個實例比較慢,其他實例正常,但是輪詢的負載均衡模式導致線程池被這個實例的請求堵滿。由於這一個慢實例,倒是整個微服務的請求都被拖慢

回顧我們想要實現的微服務重試、斷路、線程隔離

請求重試

來看幾個場景:

1.在線發布服務的時候,或者某個服務出現問題下線的時候,舊服務實例已經在注冊中心下線並且實例已經關閉,但是其他微服務本地有服務實例緩存或者正在使用這個服務實例進行調用,這時候一般會因為無法建立 TCP 連接而拋出一個 java.io.IOException,不同框架使用的是這個異常的不同子異常,但是提示信息一般有 connect time out 或者 no route to host。這時候如果重試,並且重試的實例不是這個實例而是正常的實例,就能調用成功。如下圖所示:

image

2.當調用一個微服務返回了非 2XX 的響應碼

a) 4XX:在發布接口更新的時候,可能調用方和被調用方都需要發布。假設新的接口參數發生變化,沒有兼容老的調用的時候,就會有異常,一般是參數錯誤,即返回 4XX 的響應碼。例如新的調用方調用老的被調用方。針對這種情況,重試可以解決。但是為了保險,我們對於這種請求已經發出的,只重試 GET 方法(即查詢方法,或者明確標注可以重試的非 GET 方法),對於非 GET 請求我們不重試。如下圖所示:

image

b) 5XX:當某個實例發生異常的時候,例如連不上數據庫,JVM Stop-the-world 等等,就會有 5XX 的異常。針對這種情況,重試也可以解決。同樣為了保險,我們對於這種請求已經發出的,只重試 GET 方法(即查詢方法,或者明確標注可以重試的非 GET 方法),對於非 GET 請求我們不重試。如下圖所示:

image

3.斷路器打開的異常:后面我們會知道,我們的斷路器是針對微服務某個實例某個方法級別的,如果拋出了斷路器打開的異常,請求其實並沒有發出去,我們可以直接重試。

4.限流異常:后面我們會知道,我們給調用每個微服務實例都做了單獨的線程池隔離,如果線程池滿了拒絕請求,會拋出限流異常,針對這種異常也需要直接重試。

這些場景在線上在線發布更新的時候,以及流量突然到來導致某些實例出現問題的時候,還是很常見的。如果沒有重試,用戶會經常看到異常頁面,影響用戶體驗。所以這些場景下的重試還是很必要的。對於重試,我們使用 resilience4j 作為我們整個框架實現重試機制的核心

微服務實例級別的線程隔離

再看下面一個場景:

image

微服務 A 通過同一個線程池調用微服務 B 的所有實例。如果有一個實例有問題,阻塞了請求,或者是響應非常慢。那么久而久之,這個線程池會被發送到這個異常實例的請求而占滿,但是實際上微服務 B 是有正常工作的實例的。

為了防止這種情況,也為了限制調用每個微服務實例的並發(也就是限流),我們使用不同線程池調用不同的微服務的不同實例。這個也是通過 resilience4j 實現的。

微服務實例方法粒度的斷路器

如果一個實例在一段時間內壓力過大導致請求慢,或者實例正在關閉,以及實例有問題導致請求響應大多是 500,那么即使我們有重試機制,如果很多請求都是按照請求到有問題的實例 -> 失敗 -> 重試其他實例,這樣效率也是很低的。這就需要使用斷路器

在實際應用中我們發現,大部分異常情況下,是某個微服務的某些實例的某些接口有異常,而這些問題實例上的其他接口往往是可用的。所以我們的斷路器不能直接將這個實例整個斷路,更不能將整個微服務斷路。所以,我們使用 resilience4j 實現的是微服務實例方法級別的斷路器(即不同微服務,不同實例的不同方法是不同的斷路器)

使用 resilience4j 的斷路器和線程限流器

下面我們先來看下斷路器的相關配置,來理解下 resilience4j 斷路器的原理:

CircuitBreakerConfig.java

//判斷一個異常是否記錄為斷路器失敗,默認所有異常都是失敗,這個相當於黑名單
private Predicate<Throwable> recordExceptionPredicate = throwable -> true;
//判斷一個返回對象是否記錄為斷路器失敗,默認只要正常返回對象就不認為是失敗
private transient Predicate<Object> recordResultPredicate = (Object object) -> false;
//判斷一個異常是否可以不認為是斷路器失敗,默認所有異常都是失敗,這個相當於白名單
private Predicate<Throwable> ignoreExceptionPredicate = throwable -> false;
//獲取當前時間函數
private Function<Clock, Long> currentTimestampFunction = clock -> System.nanoTime();
//當前時間的單位
private TimeUnit timestampUnit = TimeUnit.NANOSECONDS;
//異常名單,指定一個 Exception 的 list,所有這個集合中的異常或者這些異常的子類,在調用的時候被拋出,都會被記錄為失敗。其他異常不會被認為是失敗,或者在 ignoreExceptions 中配置的異常也不會被認為是失敗。默認是所有異常都認為是失敗。
private Class<? extends Throwable>[] recordExceptions = new Class[0];
//異常白名單,在這個名單中的所有異常及其子類,都不會認為是請求失敗,就算在 recordExceptions 中配置了這些異常也沒用。默認白名單為空。
private Class<? extends Throwable>[] ignoreExceptions = new Class[0];
//失敗請求百分比,超過這個比例,`CircuitBreaker`就會變成`OPEN`狀態,默認為 50%
private float failureRateThreshold = 50;
//當`CircuitBreaker`處於`HALF_OPEN`狀態的時候,允許通過的請求數量
private int permittedNumberOfCallsInHalfOpenState = 10;
//滑動窗口大小,如果配置`COUNT_BASED`默認值100就代表是最近100個請求,如果配置`TIME_BASED`默認值100就代表是最近100s的請求。
private int slidingWindowSize = 100;
//滑動窗口類型,`COUNT_BASED`代表是基於計數的滑動窗口,`TIME_BASED`代表是基於計時的滑動窗口
private SlidingWindowType slidingWindowType = SlidingWindowType.COUNT_BASED;
//最小請求個數。只有在滑動窗口內,請求個數達到這個個數,才會觸發`CircuitBreaker`對於是否打開斷路器的判斷。
private int minimumNumberOfCalls = 100;
//對應 RuntimeException 的 writableStackTrace 屬性,即生成異常的時候,是否緩存異常堆棧
//斷路器相關的異常都是繼承 RuntimeException,這里統一指定這些異常的 writableStackTrace
//設置為 false,異常會沒有異常堆棧,但是會提升性能
private boolean writableStackTraceEnabled = true;
//如果設置為`true`代表是否自動從`OPEN`狀態變成`HALF_OPEN`,即使沒有請求過來。
private boolean automaticTransitionFromOpenToHalfOpenEnabled = false;
//在斷路器 OPEN 狀態等待時間函數,默認是固定 60s,在等待與時間后,會退出 OPEN 狀態
private IntervalFunction waitIntervalFunctionInOpenState = IntervalFunction.of(Duration.ofSeconds(60));
//當返回某些對象或者異常時,直接將狀態轉化為另一狀態,默認是沒有配置任何狀態轉換機制
private Function<Either<Object, Throwable>, TransitionCheckResult> transitionOnResult = any -> TransitionCheckResult.noTransition();
//當慢調用達到這個百分比的時候,`CircuitBreaker`就會變成`OPEN`狀態
//默認情況下,慢調用不會導致`CircuitBreaker`就會變成`OPEN`狀態,因為默認配置是百分之 100
private float slowCallRateThreshold = 100;
//慢調用時間,當一個調用慢於這個時間時,會被記錄為慢調用
private Duration slowCallDurationThreshold = Duration.ofSeconds(60);
//`CircuitBreaker` 保持 `HALF_OPEN` 的時間。默認為 0, 即保持 `HALF_OPEN` 狀態,直到 minimumNumberOfCalls 成功或失敗為止。
private Duration maxWaitDurationInHalfOpenState = Duration.ofSeconds(0);

然后是線程隔離的相關配置:

ThreadPoolBulkheadConfig.java

//以下五個參數對應 Java 線程池的配置,我們這里就不再贅述了
private int maxThreadPoolSize = Runtime.getRuntime().availableProcessors();
private int coreThreadPoolSize = Runtime.getRuntime().availableProcessors();
private int queueCapacity = 100;
private Duration keepAliveDuration = Duration.ofMillis(20);
private RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.AbortPolicy();
//對應 RuntimeException 的 writableStackTrace 屬性,即生成異常的時候,是否緩存異常堆棧
//限流器相關的異常都是繼承 RuntimeException,這里統一指定這些異常的 writableStackTrace
//設置為 false,異常會沒有異常堆棧,但是會提升性能
private boolean writableStackTraceEnabled = true;
//Java 很多 Context 傳遞都基於 ThreadLocal,但是這里相當於切換線程了,某些任務需要維持上下文,可以通過實現 ContextPropagator 加入這里即可
private List<ContextPropagator> contextPropagators = new ArrayList<>();

在添加了上一節所說的 resilience4j-spring-cloud2 依賴之后,我們可以這樣配置斷路器和線程隔離:

resilience4j.circuitbreaker:
  configs:
    default:
      registerHealthIndicator: true
      slidingWindowSize: 10
      minimumNumberOfCalls: 5
      slidingWindowType: TIME_BASED
      permittedNumberOfCallsInHalfOpenState: 3
      automaticTransitionFromOpenToHalfOpenEnabled: true
      waitDurationInOpenState: 2s
      failureRateThreshold: 30
      eventConsumerBufferSize: 10
      recordExceptions:
        - java.lang.Exception
resilience4j.thread-pool-bulkhead:
  configs:
    default:
      maxThreadPoolSize: 50
      coreThreadPoolSize: 10
      queueCapacity: 1000

如何實現微服務實例方法粒度的斷路器

我們要實現的是每個微服務的每個實例的每個方法都是不同的斷路器,我們需要拿到:

  • 微服務名
  • 實例 ID,或者能唯一標識一個實例的字符串
  • 方法名:可以是 URL 路徑,或者是方法全限定名。

我們這里方法名采用的是方法全限定名稱,而不是 URL 路徑,因為有些 FeignClient 將參數放在了路徑上面,例如使用 @PathVriable,如果參數是類似於用戶 ID 這樣的,那么一個用戶就會有一個獨立的斷路器,這不是我們期望的。所以采用方法全限定名規避這個問題。

那么在哪里才能獲取到這些呢?回顧下 FeignClient 的核心流程,我們發現需要在實際調用的時候,負載均衡器調用完成之后,才能獲取到實例 ID。也就是在 org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient 調用完成之后。所以,我們在這里植入我們的斷路器代碼實現斷路器。

另外就是配置粒度,可以每個 FeignClient 單獨配置即可,不用到方法這一級別。舉個例子如下:

resilience4j.circuitbreaker:
  configs:
    default:
      slidingWindowSize: 10
    feign-client-1:
      slidingWindowSize: 100

下面這段代碼,contextId 即 feign-client-1 這種,不同的微服務實例方法 serviceInstanceMethodId 不同。如果 contextId 對應的配置沒找到,就會拋出 ConfigurationNotFoundException,這時候我們就讀取並使用 default 配置。

try {
    circuitBreaker = circuitBreakerRegistry.circuitBreaker(serviceInstanceMethodId, contextId);
} catch (ConfigurationNotFoundException e) {
    circuitBreaker = circuitBreakerRegistry.circuitBreaker(serviceInstanceMethodId);
}

如何實現微服務實例線程限流器

對於線程隔離限流器,我們只需要微服務名和實例 ID,同時這些線程池只做調用,所以其實和斷路器一樣,可以放在 org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient 調用完成之后,植入線程限流器相關代碼實現。

微信搜索“我的編程喵”關注公眾號,每日一刷,輕松提升技術,斬獲各種offer


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM