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

乾貨滿滿張雜湊發表於2021-11-10

本系列程式碼地址: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

相關文章