Java 中使用 Failsafe 實現容錯

banq發表於2024-05-19

在本文中,我們將探索Failsafe庫,並瞭解如何將其合併到我們的程式碼中,以使其對故障情況更具彈性。

什麼是容錯?
無論我們將應用程式構建得多麼好,總會有可能出錯的地方。通常,這些都是我們無法控制的——例如,呼叫不可用的遠端服務。因此,我們必須構建我們的應用程式來容忍這些故障併為我們的使用者提供最佳體驗。

我們可以透過多種不同的方式對這些失敗做出反應,具體取決於我們正在做什麼以及出了什麼問題。例如,如果我們正在呼叫一個我們知道間歇性中斷的遠端服務,我們可以重試並希望呼叫能夠正常進行。或者我們可以嘗試呼叫提供相同功能的不同服務。

還有一些方法可以構建我們的程式碼來避免這些情況。例如,限制對同一遠端服務的併發呼叫數量將減少其負載。

依賴關係
在使用 Failsafe 之前,我們需要在構建中包含最新版本,在撰寫本文時為3.3.2 。

如果我們使用 Maven,我們可以將其包含在pom.xml中:

<dependency>
    <groupId>dev.failsafe</groupId>
    <artifactId>failsafe</artifactId>
    <version>3.3.2</version>
</dependency>

或者,如果我們使用 Gradle,我們可以將其包含在build.gradle中:

i

mplementation(<font>"dev.failsafe:failsafe:3.3.2")

此時,我們已準備好開始在我們的應用程式中使用它。

4. 使用故障保護執行操作
故障安全與策略的概念一起工作。每個策略都會確定它是否認為該操作失敗以及它將如何對此做出反應。

1.確定失敗
預設情況下,如果策略丟擲任何Exception,則策略將認為操作失敗。但是,我們可以將策略配置為僅處理我們感興趣的一組確切的異常,可以透過型別或透過提供檢查它們的 lambda 來實現:

policy
  .handle(IOException.class)
  .handleIf(e -> e instanceof IOException)

我們還可以將它們配置為將我們的操作的特定結果視為失敗,無論是作為精確值還是透過提供 lambda 來為我們檢查:

policy
  .handleResult(null)
  .handleResultIf(result -> result < 0)

預設情況下,策略始終將所有異常視為失敗。如果我們新增對異常的處理,這將取代該行為,但新增對特定結果的處理將是對策略的異常處理的補充。此外,我們所有的控制代碼檢查都是附加的——我們可以新增任意數量的檢查,如果任何檢查透過,策略將認為該操作失敗。

2.制定政策
一旦我們制定了政策,我們就可以根據它們構建一個執行器。這是我們執行功能並獲取結果的方法——無論是我們行動的實際結果還是透過我們的政策修改的結果。我們可以透過將所有策略傳遞到Failsafe.with()來做到這一點,或者我們可以使用compose()方法來擴充套件它:

Failsafe.with(defaultFallback, npeFallback, ioFallback)
  .compose(timeout)
  .compose(retry);

我們可以按照任何順序新增所需數量的策略。策略始終按照新增的順序執行,每個策略都包含下一個策略。所以,上面的內容將是:


其中每一個都會對其所包裝的策略或操作的異常或返回值做出適當的反應。這使我們能夠根據需要採取行動。例如,上面的內容在所有重試中應用超時。我們可以交換它,將超時單獨應用於每次嘗試的重試。

3.執行動作
一旦我們制定了策略,Failsafe 就會向我們返回一個FailsafeExecutor例項。然後,這個例項有一組方法,我們可以使用它們來執行我們的操作,具體取決於我們想要執行的內容以及我們希望它如何返回。

執行操作的最直接方法是T get<T>(CheckedSupplier<T>)和void run(CheckedRunnable)。CheckedSupplier和CheckedRunnable都是函式式介面,這意味著如果需要,我們可以使用 lambda 或方法引用來呼叫這些方法。

它們之間的區別在於get()將返回操作的結果,而run()將返回void – 並且操作也必須返回void:

Failsafe.with(policy).run(this::runSomething);
var result = Failsafe.with(policy).get(this::doSomething);

此外,我們有各種方法可以非同步執行我們的操作,為我們的結果返回一個CompletableFuture 。但是,這些不屬於本文的討論範圍。

故障安全策略
現在我們知道如何構建FailsafeExecutor來執行我們的操作,我們需要構建使用它的策略。 Failsafe 提供了多種標準策略。每個都使用構建器模式來使構建它們變得更容易。

1.後備政策
我們可以使用的最直接的策略是Fallback。此政策將使我們能夠在連鎖操作失敗時提供新的結果。

使用它的最簡單方法是簡單地返回一個靜態值:

Fallback<Integer> policy = Fallback.builder(0).build();

在這種情況下,如果操作因任何原因失敗,我們的策略將返回固定值“0”。

此外,我們可以使用CheckedRunnable或CheckedSupplier來生成替代值。根據我們的需求,這可能像在返回固定值之前寫出日誌訊息一樣簡單,也可能像執行完全不同的執行路徑一樣複雜:

Fallback<Result> backupService = Fallback.of(this::callBackupService)
  .build();
Result result = Failsafe.with(backupService)
  .get(this::callPrimaryService);

在這種情況下,我們將執行callPrimaryService()。如果失敗,我們將自動執行callBackupService()並嘗試以這種方式獲取結果。

最後,我們可以使用Fallback.ofException()在任何失敗的情況下丟擲特定的異常。這允許我們將任何配置的失敗原因摺疊為單個預期異常,然後我們可以根據需要進行處理:

Fallback<Result> throwOnFailure = Fallback.ofException(e -> new OperationFailedException(e));

2.重試策略
回退策略允許我們在操作失敗時給出替代結果。與此相反,重試策略允許我們簡單地再次嘗試原始操作。

如果沒有配置,此策略將呼叫該操作最多 3 次,並在成功時返回結果,或者在從未成功時丟擲FailsafeException :

RetryPolicy<Object> retryPolicy = RetryPolicy.builder().build();

這已經非常有用,因為這意味著如果我們偶爾執行錯誤的操作,我們可以在放棄之前重試幾次。

但是,我們可以進一步配置此行為。我們可以做的第一件事是使用withMaxAttempts()呼叫調整重試次數:

RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withMaxAttempts(5)
  .build();

現在,這將執行該操作最多五次,而不是預設值。

我們還可以將其配置為在每次嘗試之間等待固定的時間。這在短暫故障(例如網路故障)無法立即自行修復的情況下非常有用:

RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withDelay(Duration.ofMillis(250))
  .build();

我們還可以使用更復雜的變體。例如,withBackoff()將允許我們配置遞增延遲:

RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withMaxAttempts(20)
  .withBackoff(Duration.ofMillis(100), Duration.ofMillis(2000))
  .build();

這將在第一次故障後延遲 100 毫秒,在第 20 次故障後延遲 2,000 毫秒,逐漸增加干預故障的延遲。

3.超時策略
回退和重試策略可以幫助我們從操作中獲得成功的結果,而超時策略則恰恰相反。如果我們呼叫的操作花費的時間比我們想要的時間長,我們可以使用它來強制失敗。如果我們需要在某個操作花費太長時間的情況下失敗,這可能是無價的。

當我們構建超時時,我們需要提供目標持續時間,在此之後操作將失敗:

Timeout<Object> timeout = Timeout.builder(Duration.ofMillis(100)).build();

預設情況下,這將執行操作直至完成,如果所需時間超過我們提供的持續時間,則失敗。

或者,我們可以將其配置為在達到超時時中斷操作,而不是執行完整。當我們需要快速響應而不是僅僅因為速度太慢而失敗時,這非常有用:

Timeout<Object> timeout = Timeout.builder(Duration.ofMillis(100))
  .withInterrupt()
  .build();

我們也可以有效地組合超時策略和重試策略。如果我們在重試之外設定超時,則超時時間將分佈在所有重試中:

Timeout<Object> timeoutPolicy = Timeout.builder(Duration.ofSeconds(10))
  .withInterrupt()
  .build();
RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withMaxAttempts(20)
  .withBackoff(Duration.ofMillis(100), Duration.ofMillis(2000))
  .build();
Failsafe.with(timeoutPolicy, retryPolicy).get(this::perform);

這將嘗試執行我們的操作最多 20 次,每次嘗試之間的延遲會逐漸增加,但如果整個嘗試執行時間超過 10 秒,則會放棄。

相反,我們可以在重試內部編寫超時,以便每次單獨的嘗試都配置一個超時:

Timeout<Object> timeoutPolicy = Timeout.builder(Duration.ofMillis(500))
  .withInterrupt()
  .build();
RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withMaxAttempts(5)
  .build();
Failsafe.with(retryPolicy, timeoutPolicy).get(this::perform);

這將嘗試該操作五次,如果每次嘗試花費的時間超過 500 毫秒,則每次嘗試都會被取消。

4.艙壁政策
到目前為止,我們看到的所有策略都是關於控制應用程式對故障的反應方式。然而,我們也可以使用一些政策來首先減少失敗的可能性。

隔離策略的存在是為了限制執行操作的併發次數。這可以減少外部服務的負載,因此有助於減少它們失敗的機會。

當我們構造Bulkhead時,我們需要配置它支援的最大併發執行數:

Bulkhead<Object> bulkhead = Bulkhead.builder(10).build();

預設情況下,當艙壁已滿時,任何操作都會立即失敗。

我們還可以將艙壁配置為在新操作進入時等待,如果容量可用,那麼它將執行等待任務:

Bulkhead<Object> bulkhead = Bulkhead.builder(10)
  .withMaxWaitTime(Duration.ofMillis(1000))
  .build();

一旦容量可用,任務將按照執行順序透過艙壁。一旦等待時間到期,任何必須等待超過此配置的等待時間的任務都將失敗。然而,它們後面的其他任務可能會成功執行。

5.速率限制器策略
與艙壁類似,速率限制器有助於限制可能發生的操作的執行次數。然而,與僅跟蹤當前正在執行的運算元量的隔板不同,速率限制器限制給定時間段內的運算元量。

故障安全為我們提供了兩個可以使用的速率限制器——突發和平滑。

突發速率限制器使用固定時間視窗,並允許在此視窗中執行最大數量:

RateLimiter<Object> rateLimiter = RateLimiter.burstyBuilder(100, Duration.ofSeconds(1))
  .withMaxWaitTime(Duration.ofMillis(200))
  .build();

在本例中,我們每秒能夠執行 100 個操作。我們配置了一個等待時間,操作可以阻塞,直到執行或失敗。這些被稱為突發,因為計數在視窗結束時回落到零,因此我們可以突然允許執行再次開始。

特別是,隨著我們的等待時間,所有阻塞該等待時間的執行將突然能夠在速率限制器視窗結束時執行。

平滑速率限制器透過在時間視窗內分散執行來發揮作用:

RateLimiter<Object> rateLimiter = RateLimiter.smoothBuilder(100, Duration.ofSeconds(1))
  .withMaxWaitTime(Duration.ofMillis(200))
  .build();

這看起來和以前非常相似。然而,在這種情況下,執行將在視窗內平滑。這意味著我們允許每 1/100 秒執行一次,而不是在一秒視窗內允許 100 次執行。任何比這更快的執行都會達到我們的等待時間,否則就會失敗。

6.斷路器政策
與大多數其他策略不同,我們可以使用斷路器,因此如果操作被認為已經失敗,我們的應用程式可能會快速失敗。例如,如果我們正在呼叫遠端服務並且知道它沒有響應,那麼嘗試就沒有意義 – 我們可能會立即失敗,而無需先花費時間和資源。

斷路器在三態系統中工作。預設狀態為“關閉”,這意味著將嘗試所有操作,就好像斷路器不存在一樣。然而,如果足夠多的這些操作失敗,斷路器將轉為“開啟”。

Open 狀態意味著不嘗試任何操作,並且所有呼叫都將立即失敗。在進入半開狀態之前,斷路器將保持這種狀態一段時間。

半開放狀態意味著嘗試執行操作,但我們有不同的失敗閾值來確定是轉到“關閉”還是“開啟”。

例如:

CircuitBreaker<Object> circuitBreaker = CircuitBreaker.builder()
  .withFailureThreshold(7, 10)
  .withDelay(Duration.ofMillis(500))
  .withSuccessThreshold(4, 5)
  .build();

如果最後 10 個請求中有 7 次失敗,此設定將從“關閉”變為“開啟”;如果最後 10 個請求中有 4 次成功,則該設定將從“開啟”變為“半開”;如果最後 10 個請求中有 4 個成功,則該設定將從“半開”變為“關閉”;或者返回到如果最近 5 個請求中有 2 次失敗,則開啟。

我們還可以將故障閾值配置為基於時間。例如,如果在過去 30 秒內發生了 5 次故障,我們就斷開電路:

CircuitBreaker<Object> circuitBreaker = CircuitBreaker.builder()
  .withFailureThreshold(5, Duration.ofSeconds(30))
  .build();

我們還可以將其配置為請求的百分比而不是固定數量。例如,如果在任何 5 分鐘內至少有 100 個請求的失敗率為 20%,我們就開啟電路:

CircuitBreaker<Object> circuitBreaker = CircuitBreaker.builder()
  .withFailureRateThreshold(20, 100, Duration.ofMinutes(5))
  .build();

這樣做可以讓我們更快地調整載入。如果我們的負載非常低,我們可能根本不想檢查故障,但是如果我們的負載非常高,那麼發生故障的可能性就會增加,因此我們只想在負載超過閾值時才做出反應。

 

相關文章