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;
});
}
}
getMessage1
和 getMessage2
一個是使用 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
透過觀察輸出結果,可以確認兩件事情:
- 兩者的非同步行為都是符合預期的,的確都跑在不同的執行緒上
- 使用原生的方法底層是透過 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。
另外,在規格書之中,是這樣描述斷路器的機制,斷路器共有三種狀態:
Closed
=> 關閉狀態。斷路條件未滿足時就是這個狀態。當斷路統計資料中,累計的失敗比率超過容許率,就會觸發斷路器,進入啟動狀態。Open
=> 啟動狀態。啟動狀態中,任何的呼叫都會直接拋出異常,直到斷路器的啟動時間結束為止。結束後將進入半啟動狀態。Half-open
=> 半啟動狀態。此階段中容許呼叫,但若失敗,將會回到啟動狀態,此外,必須達到成功請求數量的門檻值,才會解除半啟動狀態回到關閉狀態。
依照預設值和斷路器機制,搭配上實際運作時的情形來做個小結::
- 若連續 20 個請求中有 10 個發生錯誤,將觸發斷路器,啟動時間為 5 秒。
- 0 ~ 20 之中失敗的有 12 個,所以啟動了斷路器
- 啟動期間不管有多少請求,一律拋出
CircuitBreakerOpenException
例外。
- 20 後的 5 秒內,包括 21 ~ 24 都會直接收到例外
- 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