MicroProfile Fault Tolerance・出錯怎麼辦?

MicroProfile 的推出,旨在提供以 Java 為基礎的微服務架構(Microservice Architecture)的解決方案。想當然,開發者在開發過程中所碰到的困難,只要問題群體夠大,都有機會被社群納入規格統一解決。

誠如 MPRestClient 想解決的問題,現今微服務架構是以 RESTful API 來進行串聯,自然所有使用 HTTP 時會碰到的問題,在 RESTful API 都會發生。雖並非所有不穩定的原因都來自網路,但肯定的是,網路基礎建設有時真的是不可控的因子。

導致服務不穩定的原因數不清,除前述網路問題外,從邏輯有誤這種不該發生的,到系統負載過高導致行為異常這類讓人又欣喜又無奈的(有人用系統耶~✌️,都包括在內。開發者的責任,就是在能力所及的範圍內,發生前預防,發生後阻止。其困難之處在於,為了預防和阻止這些問題的發生,程式碼常常穿插了各種問題處理的邏輯,開發者無法專注在應用邏輯上,而維護就變得困難。

為了讓開發者可以更容易地追求服務的品質與穩定,MicroProfile Fault Tolerance(後簡稱 MPFaultTolerance)應然而生。它一樣提供了用 @annotation 的方式,將錯誤容忍的邏輯自應用邏輯中抽離,更直接支援 MPConfig 來進行設定、MPMetrics 來進行監控,真的是佛心來的~❤️

完整的資料還請大家能參照規格書,以下將針對六個關鍵的 @annotation 來進行說明。


五個錯誤容忍處理+一個小確幸

MPFaultTolerance 提供了以下五種錯誤容忍處理的 @annotation,包括:

  • @Timeout => 被標註的方法若呼叫時超過所設定的時間,將拋出逾時異常
  • @Retry => 被標註的方法若呼叫時有異常,將依照所設定的重試政策(Retry Policy)自動重試
  • @Fallback => 被標註的方法若呼叫時有異常,將執行所設定的處理邏輯
  • @CircuitBreaker => 被標註的方法若呼叫時有異常,將依照設定的門檻值,決定是否要啟動斷路
  • @Bulkhead => 被標註的方法將被限制同時最多可以接受多少請求

另外,還 MPFaultTolerance 還提供了一個額外的 @Asynchronous,被標註的方法將在不同的執行緒被呼叫。

@Asynchronous 的效果是讓開發者可以容易開發非同步執行的邏輯,看似與錯誤容忍處理無關,但實際上卻息息相關。理由是,為了讓開發者可以因應複雜的系統實際運作情況,這六個 @annotation 可以混搭使用!為了讓錯誤容忍能更可控,所以 MPFaultTolerance 才有納入 @Asynchronous 的必要性。

以下將逐一說明,至於如何混搭使用、限制與細節等,還請詳讀規格書。

非同步怎麼寫

非同步方法的撰寫,其實原生的框架就有支援,但寫起來和使用 @Asynchronous 相比,卻有那麼一點點彆扭,以下將用實際的例子來說明:

@Dependent
public class AsynchronousService {

    @Asynchronous
    public Future<String> getMessage1(String to) {
        System.out.println("getMessage1 => " + Thread.currentThread().getName());
        try {
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException ex) {
        }
        var future = new CompletableFuture<String>();
        future.complete("Hello " + to + "!");
        System.out.println("getMessage1 => message generated");
        return future;
    }

    public Future<String> getMessage2(String to) {
        return CompletableFuture.supplyAsync(() -> {
            System.out.println("getMessage2 => " + Thread.currentThread().getName());
            try {
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException ex) {
            }
            var message = "Hello " + to + "!";
            System.out.println("getMessage2 => message generated");
            return message;
        });
    }
}

getMessage1getMessage2 一個是使用 MPFaultTolerance 來實現,一個是使用原生的方法來實現,以 @Asynchronous 來實作明顯簡單直覺了些。雖然邏輯上效果是相同的,但實際運作的底層機制卻略有不同,以下我們透過實際呼叫來實驗:

@Path("ft")
@ApplicationScoped
public class HelloFaultTolerance {

    @Inject
    private AsynchronousService aService;

    @Path("async/1")
    @GET
    public String sayHello1(@QueryParam("to") String to) throws InterruptedException, ExecutionException {
        System.out.println("sayHello1 => " + Thread.currentThread().getName());
        var future = aService.getMessage1(to);
        System.out.println("sayHello1 => continued");
        var result = future.get();
        System.out.println("sayHello1 => result got");
        return result;
    }

    @Path("async/2")
    @GET
    public String sayHello2(@QueryParam("to") String to) throws InterruptedException, ExecutionException {
        System.out.println("sayHello2 => " + Thread.currentThread().getName());
        var future = aService.getMessage2(to);
        System.out.println("sayHello2 => continued");
        var result = future.get();
        System.out.println("sayHello2 => result got");
        return result;
    }
}

個別實際呼叫後,可以在 console 看到以下的輸出:

sayHello1 => http-thread-pool::http-listener(2)
sayHello1 => continued
getMessage1 => concurrent/__defaultManagedExecutorService-managedThreadFactory-Thread-2
getMessage1 => message generated
sayHello1 => result got
sayHello2 => http-thread-pool::http-listener(2)
sayHello2 => continued
getMessage2 => ForkJoinPool.commonPool-worker-3
getMessage2 => message generated
sayHello2 => result got

透過觀察輸出結果,可以確認兩件事情:

  1. 兩者的非同步行為都是符合預期的,的確都跑在不同的執行緒上
  2. 使用原生的方法底層是透過 ForkJoin 的機制,而 MPFaultTolerance 使用了別的執行緒池來處理
  • 規格書中沒有規範要用什麼方式來實作,所以各 MicroProfile 的實作運行起來可能有所不同

如果希望能讓程式碼閱讀更直覺,且能與其它 @annotation 混搭使用的話,建議開發者直接採用 @Asynchronous 來撰寫非同步的邏輯。

程式卡住,是壞了嗎?

有時等待久了,雖然實際上系統還持續運作中,但在使用者看來就像是當機了般毫無反應,隨著科技的進步,多數人對於等待的容忍度日趨下降,這時身為開發者的我們該又該如何是好呢?

這類的問題通常牽扯到 IO,而 IO 又是層層堆疊的複雜情況,基本上並非可控,面對不可控的情況,最好的辦法就是 放棄 呃呃呃...... 我的意思是就跟生活中一樣,等待是有限度的,等太久就別等了。

@Timeout 就是要處理這種情況,使用也很簡單,我們直接看範例:

@Dependent
public class TimeoutService {

    @Timeout
    public String getMessage(String to) {
        try {
            TimeUnit.SECONDS.sleep((long) (Math.random() * 2));
        } catch (InterruptedException ex) {
        }
        return "Hello " + to + "!";
    }
}

和先前的範例一樣,邏輯很簡單,只是要產生一個簡單的訊息字串。在 getMessage 上標註了 @Timeout 的意思此方法若發生呼叫逾時,將會拋出 TimeoutException 的例外,而 @Timeout 的逾時設定預設為 1000 毫秒,所以我們在邏輯中隨機讓執行緒睡 1 秒鐘,希望能觸發逾時的例外。

使用的範例如下:

...
    @Inject
    private TimeoutService tService;
...
    @Path("timeout")
    @GET
    public String sayHello3(@QueryParam("to") String to) {
        var result = "Timeout!";
        var time = System.currentTimeMillis();
        try {
            result = tService.getMessage(to);
            System.out.println("Time => " + (System.currentTimeMillis() - time));
        } catch (TimeoutException ex) {
            System.out.println("Timeout => " + (System.currentTimeMillis() - time));
        }
        return result;
    }
...

實際呼叫幾次後,可以在 console 看到類似以下的輸出:

Time => 87
Time => 18
Timeout => 1014
Timeout => 1016
Time => 12  

若等待超過了 1000 毫秒,將拋出逾時的例外,當然,開發者可以自己決定逾時的等待時間要多長。有這樣的機制後,開發者就能容易的針對逾時情況來設計,以提升自己服務的總體品質。

自動重試不是很棒嗎

有的時候,因為一些不可預期的原因,雖程式邏輯無誤,卻發生短暫的異常。在這些異常是可容忍的情況下,開發者往往會希望,能不能有限度的自動重試呢?這麼一來,發生異常的請求就能像是正常的被服務一樣,沒有錯誤發生。

有的!@Retry 就提供了前述的解決方案!

@Dependent
public class RetryService {

    @Retry
    public String getMessage(String to) {
        System.out.println("getMessage => " + System.currentTimeMillis());
        throw new RuntimeException("getMessage fail");
    }
}

使用起來也非常簡單,只要標記上 @Retry 就好!
範例只是為了凸顯自動重試的效果,所以...... 請大家別真的寫了一個這樣穩錯不賠的可愛邏輯(笑~

...
    @Inject
    private RetryService rService;
...
    @Path("retry")
    @GET
    public String sayHello4(@QueryParam("to") String to) {
        var result = "Retry fail!";
        var start = System.currentTimeMillis();
        System.out.println("Start => " + start);
        try {
            result = rService.getMessage(to);
        } catch (RuntimeException ex) {
            var now = System.currentTimeMillis();
            System.out.println("Fail => " + now + "[" + (now - start) + "]");
        }
        return result;
    }
...

接著寫個簡單的程式來實際執行看看,會得到類似以下的結果:

Start => 1583292346298
getMessage => 1583292346301
getMessage => 1583292346364
getMessage => 1583292346475
getMessage => 1583292346598
Fail => 1583292346610[312]

可以看出,getMessage 被呼叫了四次,從開始執行到異常被攔截,總共花了 312 毫秒,四次呼叫間隔的時間略有差異。
若要理解為什麼是這樣的效果,就得清楚以下幾個重試政策(Retry Policy)的意義:

  • maxRetries => 最多重試幾次。預設值為 3。
  • delay => 每次重試間隔的時間。預設值為 0L。
  • delayUnit => 重試間隔的時間單位。預設值為毫秒。
  • maxDuration => 單次請求自重試起時間總長之限制。預設值為 180000L。
  • durationUnit => 時間總長的時間單位。預設值為毫秒。
  • jitter => 每次重試時間隔時間的隨機抖動值。預設值為 200L。
    • 若有設定此數值,則每次請求間隔的時間將落在 [delay - jitter, delay + jitter] 之間。若間隔時間計算出來為負值,則表示不延遲。
  • jitterDelayUnit => 抖動值的時間單位。預設值為毫秒。
  • retryOn => 發生什麼例外將自動重試。預設值為 java.lang.Exception.class
  • abortOn => 發生什麼例外將放棄自動重試。無預設值。

所以,基於以上預設值,就能清楚的了解到:

  • 呼叫四次,是因為正常呼叫一次,加上預設的三次重試;
  • 每次呼叫間隔略有差異,是因為有預設的 200 毫秒抖動值;
  • RuntimeException 能觸發重試,是因為預設所有的例外都會觸發重試。

所以,
@Retry 機制讓開發者可以依照實際情況定義需要的重試政策,就算不特別設定,也有一定程度的隨機等待,避免連續呼叫反而造成系統負擔加劇。

錯誤怎麼處理

例外處理一直是 Java 的根本之一,那麼,既然都已經有了 try-catch 的機制,為什麼還需要其它機制呢?理由就在於邏輯的分割。

為了處理例外,開發者會在邏輯之中加入 try-catch,實際上,通常正常運作的邏輯和例外處理的邏輯,也會被包裝成不同的方法。既然如此,若能把邏輯分開,不是更美好嗎?

@Fallback 就是為了因應前述狀況被設計出來的機制。這並不是要取代 try-catch,而是要讓開發者,能夠清楚的將「必要的運作邏輯」,與各種錯誤容忍皆無效後的「緊急應對邏輯」分開。這麼一來,程式的可讀性和可維護性就會有所提升。

@Dependent
public class FallbackService {

    @Fallback(fallbackMethod = "getMessage2")
    public String getMessage1(String to) {
        System.out.println("getMessage1 => " + System.currentTimeMillis());
        throw new RuntimeException("getMessage1 fail");
    }

    private String getMessage2(String to) {
        System.out.println("getMessage2 => " + System.currentTimeMillis());
        return "Fallback";
    }
}

只要在方法上標註 @Fallback(fallbackMethod)MPFaultTolerance 就會在發生例外時自動的呼叫指定的方法。此外,另有提供實作 FallbackHandler 的機制,請自行參考規格書。

...
    @Inject
    private FallbackService fService;
...
    @Path("fallback")
    @GET
    public String sayHello5(@QueryParam("to") String to) {
        var start = System.currentTimeMillis();
        System.out.println("Start => " + start);
        var result = fService.getMessage1(to);
        var now = System.currentTimeMillis();
        System.out.println("Fallback => " + now + "[" + (now - start) + "]");
        return result;
    }
...

實際執行以後,會看到類似以下的結果:

Start => 1583297552028
getMessage1 => 1583297552030
getMessage2 => 1583297552034
Fallback => 1583297552034[6]

沒有任何例外被拋出,在 getMessage1 發生異常時自動呼叫了 getMessage2。使用者不需要知道錯誤時該怎麼處理,而開發者只需透過簡單的標註,就達成了邏輯分割的效果!(爽快!!!

一直錯怎麼辦

系統的可用資源是有限的,就算有資源回收機制,資源回收的效率仍是個問題。倘若有個使用到較多資源的服務,一再的因為更底層的問題無法恢復正常運作,同時又接受到了大量的請求,這時系統會發生什麼事呢?
是的,如您所想,系統會進入不穩定的狀況,甚至有可能再起不能!(啊嗚~好可怕 =口=

感謝 MPFaultTolerance 提供了 @CircuitBreaker 的斷路器機制來協助解決這種窘境!
直接透過範例來理解什麼是斷路器:

@Dependent
public class CircuitBreakerService {

    @CircuitBreaker
    public String getMessage(String to) {
        if (Math.random() * 100 > 25) {
            throw new RuntimeException("getMessages Fail");
        }
        return "Hello " + to + "!";
    }
}

和其他 @annotation 相同,直接將 @CircuitBreaker 標註上去,就會有預設的效果。

...
    @Inject
    private CircuitBreakerService cService;
...
    @Path("cb")
    @GET
    public String sayHello6(@QueryParam("to") String to) {
        for (var i = 0; i < 40; i++) {
            try {
                cService.getMessage(to);
                System.out.println("Success => " + System.currentTimeMillis());
            } catch (CircuitBreakerOpenException ex) {
                System.out.println("CircuitBreaker => " + System.currentTimeMillis());
            } catch (RuntimeException ex) {
                System.out.println("Fail => " + System.currentTimeMillis());
            }
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException ex) {
            }
        }
        return "Completed";
    }
...

在這個範例中,呼叫的方法不拋出異常的機會是 75/100,每呼叫一次等待一秒。

執行後,可以得到類似以下的結果,為了方便說明我標上了步驟的數字:

01  Success => 1583308139420
02  Fail => 1583308140428
03  Fail => 1583308141437
04  Success => 1583308142463
05  Fail => 1583308143473
06  Fail => 1583308144484
07  Success => 1583308145490
08  Fail => 1583308146498
09  Fail => 1583308147507
10  Success => 1583308148516
11  Fail => 1583308149524
12  Fail => 1583308150530
13  Fail => 1583308151542
14  Fail => 1583308152555
15  Success => 1583308153562
16  Fail => 1583308154570
17  Success => 1583308155578
18  Fail => 1583308156590
19  Success => 1583308157594
20  Success => 1583308158611
21  CircuitBreaker => 1583308159619
22  CircuitBreaker => 1583308160623
23  CircuitBreaker => 1583308161629
24  CircuitBreaker => 1583308162639
25  Fail => 1583308163652
26  CircuitBreaker => 1583308164659
27  CircuitBreaker => 1583308165664
28  CircuitBreaker => 1583308166676
29  CircuitBreaker => 1583308167682
30  Fail => 1583308168694
31  CircuitBreaker => 1583308169705
32  CircuitBreaker => 1583308170710
33  CircuitBreaker => 1583308171715
34  CircuitBreaker => 1583308172729
35  Success => 1583308173739
36  Fail => 1583308174745
37  Fail => 1583308175757
38  Fail => 1583308176765
39  Fail => 1583308177771
40  Fail => 1583308178781

乍看之下好像有規則,但又看不太出來規則到底是什麼,別著急,我們先回頭看 @CircuitBreaker 的設定值,包括:

  • failOn => 哪些錯誤會被計入斷路統計。預設值為 java.lang.Throwable.class
  • delay => 斷路器啟動的時長。預設值為 5000L。
  • delayUnit => 斷路器啟動時長的時間單位。預設值為毫秒。
  • requestVolumeThreshold => 計入斷路統計的連續請求數量之閾值。預設值為 20。
  • failureRatio => 斷路統計的失敗容許率。預設值為 0.5。
  • successThreshold => 關閉斷路器的成功請求數量之閾值。預設值為 1。

另外,在規格書之中,是這樣描述斷路器的機制,斷路器共有三種狀態:

  1. Closed => 關閉狀態。斷路條件未滿足時就是這個狀態。當斷路統計資料中,累計的失敗比率超過容許率,就會觸發斷路器,進入啟動狀態。
  2. Open => 啟動狀態。啟動狀態中,任何的呼叫都會直接拋出異常,直到斷路器的啟動時間結束為止。結束後將進入半啟動狀態。
  3. Half-open => 半啟動狀態。此階段中容許呼叫,但若失敗,將會回到啟動狀態,此外,必須達到成功請求數量的門檻值,才會解除半啟動狀態回到關閉狀態。

依照預設值和斷路器機制,搭配上實際運作時的情形來做個小結::

  1. 若連續 20 個請求中有 10 個發生錯誤,將觸發斷路器,啟動時間為 5 秒。
  • 0 ~ 20 之中失敗的有 12 個,所以啟動了斷路器
  1. 啟動期間不管有多少請求,一律拋出 CircuitBreakerOpenException 例外。
  • 20 後的 5 秒內,包括 21 ~ 24 都會直接收到例外
  1. 5 秒後,進入半啟動狀態,若接下來的請求為成功,將會關閉斷路器,若失敗,則再次啟動斷路器。
  • 25 和 30 都呼叫失敗,再次啟動了斷路器
  • 35 時成功,因此斷路器關閉,重新開始斷路統計

理解了運作機制後,其實 @CircuitBreaker 使用上一點也不困難。三種狀態之間的切換提供給開發者極大的彈性,透過參數方便地調整出適合真實情況的機制,避免錯誤連續發生,進而提升系統的穩定性。

一次來太多沒辦法處理呀

就跟高速公路一樣,湧入太多車輛的時候,閘道管制是必要的,假如不管制會發生什麼事情呢?理想中,只要每輛車大家一起往前,就不會有塞車的現象,但是這是辦不到的。每個駕駛要上的入口、要下的入口不同,對於車距的拿捏也不同,各種原因交錯在一起,結果就是大塞車,不管是誰,都只能一動也不動。

資訊系統亦是如此!
雖然每個請求要佔用的資源不多,所需的處理時間也很短,但是當數量達到一定的量級,累積的數量亦是相當驚人,自然整個系統就完全卡住了。堆疊起應用的底層,各個機制必須去協調資源的運用,才能消化各個請求,超過了負荷必然出現無法正常服務的結果。

所幸,MPFaultTolerance 提供了 @Bulkhead 的機制,來控制並行(concurrent)呼叫的最大數量。

直接看範例!

@Dependent
public class BulkheadService {

    @Bulkhead
    public String getMessage(String to) {
        try {
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException ex) {
        }
        return "Hello " + to + "!";
    }
}
...
    @Inject
    private BulkheadService bService;
...
    @Path("bulkhead")
    @GET
    public String sayHello7(@QueryParam("to") String to) {
        String result = "Fail";
        try {
            System.out.println("Start => " + Thread.currentThread().getName());
            result = bService.getMessage(to);
            System.out.println("Success => " + Thread.currentThread().getName());
        } catch (BulkheadException ex) {
            System.out.println("Fail => " + Thread.currentThread().getName());
        }
        return result;
    }
...

範例中刻意延長了執行所需的時間,讓實驗比較好進行,只要持續重新整理瀏覽器,就可以達到並行呼叫的效果。實際執行後可得到類似的結果如下:

Start => http-thread-pool::http-listener(1)
Start => http-thread-pool::http-listener(2)
Start => http-thread-pool::http-listener(3)
Start => http-thread-pool::http-listener(4)
Start => http-thread-pool::http-listener(5)
Start => http-thread-pool::http-listener(6)
Start => http-thread-pool::http-listener(7)
Start => http-thread-pool::http-listener(8)
Start => http-thread-pool::http-listener(9)
Start => http-thread-pool::http-listener(10)
Start => http-thread-pool::http-listener(11)
Fail => http-thread-pool::http-listener(11)
...
Start => http-thread-pool::http-listener(11)
Fail => http-thread-pool::http-listener(11)
Success => http-thread-pool::http-listener(1)
Success => http-thread-pool::http-listener(2)
Start => http-thread-pool::http-listener(11)
Success => http-thread-pool::http-listener(3)
Start => http-thread-pool::http-listener(1)

觀察結果可發現,在第 11 個執行緒執行 getMessage 即拋出異常,直到第 1 個執行緒完成工作後,方法才能被正常的呼叫。至於為何是可接受 10 個並行呼叫呢?其理由為 @Bulkhead 的預設值是 10。


文末,做個簡單的結語,

MPFaultTolerance 提供了數種錯誤容忍機制,且可以混合使用,讓開發者足以應對設計服務時所面臨的困難。同時,在使用上讓開發者能將錯誤容忍機制與應用邏輯分開,提升了程式碼的可讀性進而得以更好維護。還在手工實作錯誤容忍機制的開發者們,請務必考慮使用!!!

by Arren Ping