軟體系統的保險絲 - .NET Polly CircuitBreakerPolicy
0 | 3,533 |
昨天提到 Polly 的 Circuit Breaker,研究後發現比想像複雜,寫篇筆記備忘。
Circuit Breaker 概念類似電路系統的熔斷開關或保險絲,能在電流過大時切斷電路,避免持續通電釀成更大災害。套用在軟體系統上,是在累積指定錯誤次數後先一段時間暫停執行,直接告知暫停服務好過讓使用者在線上傻。另一方面,系統出錯後重啟復原階段比較脆弱,Circuit Breaker 先擋下流量提供一段喘息時間,避免雪上加霜。
Polly CircuitBreakerPolicy 的運作原理要從下面的狀態圖說起:
CircuitBreakerPolicy 共有三個狀態:Closed、Open、Half-Open。
一開始為 Closed (閉路/通路),正常執行所有動作並監控成功及失敗次數,當失敗次數超過門檻即切換為 Open (斷路)狀態。預設門檻由 exceptionsAllowedBeforeBreaking 設定「連續拋出 N 次 Exception」觸發,另外還有進階模式能依取樣週期的失敗比例控制斷路(並可要求最低樣本數以防過度敏感),實現更精準的控制。觸發斷路的該次 Exception 仍會向上拋,但狀態將轉為 Open。
狀態為 Open 時,.Execute() 要求將不會被執行,而是得到 BrokenCircuitException (其 InnerException 包含導致該次斷路的 Exception),Open 狀態會維持 durationOfBreak 指定的時間長度,之後的第一次 .Execute() 或查詢 CircuitState,狀態切換到 Half-Open。
狀態進入 Half-Open 後,第一個 .Execute() 會被視為健康檢測指標(只有第一個,其他 Execute() 仍會收到 BrokenCircuitException),若檢測結果出現指定的例外型別(Handled Exception,即 Policy.Handle<T> 指定的 Exception),則再次斷路;若動作正常執行,則切回 Closed;若發生不是 Policy.Handle<T> 指定的 Exception,則維持 Half-Open。
CircuitBreaker 遇到 Exception 時,不負責重試只監測失敗次數,對 Exception 也不做處理 Exception,統計次數後便會向上轉拋(Rethrow)。
程式端亦可透過 .Isolate() 強制斷路,此時 .Execute() 將到 IsolatedCircuitException,直到 .Reset() 被呼叫為止。
理解原理後,我寫了一個範例實地驗證。總共有三組實驗:
- Test 1
測試錯兩次觸發斷路,等待 5 秒後恢復,中斷期間將得到 BrokenCircuitException,其 InnerException 為觸發斷路的例外物件。 - Test 2 指定偵測自訂 BreakNowException 1 次就斷路,觀察狀態 Closed 切成 Open,等待 durationOfBreak (2 秒) 後狀態轉為 Half-Open,之後的第一次 Execute() 很關鍵,若成功立即轉 Closed;若發生 BreakNowException 則再次轉 Open 等兩秒;若為 BreakNowException 以外的 Exception,狀態維持 Half-Open,實測 Half-Open 狀態會持續一次 durationOfBreak 時間,由之後的第一次 Execute() 決定 Open/Closed/Half-Open。
- Test 3
測試 Isolate() 強制斷路,中斷期間將得到 IsolatedCircuitException,斷路會一直持續到呼叫 .Reset() 為止。
程式範例如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Polly;
using System.Text.Json;
using System.Threading;
using Polly.CircuitBreaker;
namespace PollyDemo
{
public class BreakNowException : Exception { }
public class CircuitBreakerDemo
{
public enum RespModes
{
Normal,
ThrowOtherEx,
ThrowBrkEx
}
string DoJob(RespModes respMode)
{
switch (respMode)
{
case RespModes.ThrowOtherEx:
throw new ApplicationException();
case RespModes.ThrowBrkEx:
throw new BreakNowException();
}
return $"> 執行成功 {DateTime.Now:mm:ss}";
}
public CircuitBreakerDemo()
{
}
void Write(string msg, ConsoleColor color = ConsoleColor.White)
{
var bak = Console.ForegroundColor;
Console.ForegroundColor = color;
Console.WriteLine(msg);
Console.ForegroundColor = bak;
}
void Log(CircuitBreakerPolicy<string> policy, string msg) =>
Write($"{DateTime.Now:mm:ss} State = {policy.CircuitState} ({msg})", ConsoleColor.Yellow);
public void Test1()
{
Write("\n**** Simple Test ****", ConsoleColor.Cyan);
//標準測試,錯兩次斷路 5 秒
var pBreaker = Policy<string>.Handle<Exception>().CircuitBreaker(2, TimeSpan.FromSeconds(5));
var pFallback =
Policy<string>
.Handle<Exception>()
.Fallback((delgRes, context, cancelToken) =>
{
var ex = delgRes.Exception;
return $"> {ex.GetType().Name}/{ex.InnerException?.GetType().Name} {DateTime.Now:mm:ss}";
},
onFallback: (ex, context) => { });
var p = Policy.Wrap(pFallback, pBreaker);
Console.WriteLine(p.Execute(() => DoJob(RespModes.Normal)));
Log(pBreaker, "BreakNowExcpetion 前");
Console.WriteLine(p.Execute(() => DoJob(RespModes.ThrowBrkEx)));
Log(pBreaker, $"1st BreakNowExcpetion");
Console.WriteLine(p.Execute(() => DoJob(RespModes.ThrowBrkEx)));
Log(pBreaker, $"2nd BreakNowExcpetion,預計斷至 {DateTime.Now.AddSeconds(5):mm:ss}");
Console.WriteLine(p.Execute(() => DoJob(RespModes.Normal)));
Thread.Sleep(1000);
Console.WriteLine(p.Execute(() => DoJob(RespModes.Normal)));
Thread.Sleep(5000);
Console.WriteLine(p.Execute(() => DoJob(RespModes.Normal)));
}
public void Test2()
{
Write("\n**** State Test ****", ConsoleColor.Cyan);
// 測試重點:
// 1. Half-Open 遇非指定錯誤會繼續 Half-Open
// 2. Half-Open 測試失敗會再延長
var pBreaker = Policy<string>.Handle<BreakNowException>()
.CircuitBreaker(1, TimeSpan.FromSeconds(2));
var pFallback =
Policy<string>
.Handle<Exception>()
.Fallback((delgRes, context, cancelToken) =>
{
var ex = delgRes.Exception;
return $"> {ex.GetType().Name}/{ex.InnerException?.GetType().Name} {DateTime.Now:mm:ss}";
},
onFallback: (ex, context) => { });
var p = Policy.Wrap(pFallback, pBreaker);
Console.WriteLine(p.Execute(() => DoJob(RespModes.ThrowBrkEx)));
// Open
Log(pBreaker, $"BreakNowExcpetion,預計斷至 {DateTime.Now.AddSeconds(2):mm:ss}");
Thread.Sleep(2000);
// Half-Open
Log(pBreaker, $"durationOfBreak 結束");
// Trial attempt
Console.WriteLine(p.Execute(() => DoJob(RespModes.Normal)));
Log(pBreaker, $"Half-Open 後下一次執行成功");
// Break it again
Console.WriteLine(p.Execute(() => DoJob(RespModes.ThrowBrkEx)));
Log(pBreaker, $"再 BreakNowExcpetion,預計斷至 {DateTime.Now.AddSeconds(2):mm:ss}");
Thread.Sleep(2000);
Log(pBreaker, $"durationOfBreak 結束");
// Trial attempt
Console.WriteLine(p.Execute(() => DoJob(RespModes.ThrowBrkEx)));
Log(pBreaker, $"Half-Open 後下一次執行失敗,預計斷至 {DateTime.Now.AddSeconds(2):mm:ss}");
Thread.Sleep(2000);
// Half-Open
Log(pBreaker, $"durationOfBreak 結束");
// Trial attempt
Console.WriteLine(p.Execute(() => DoJob(RespModes.ThrowOtherEx)));
// Unhandled Exception -> Half-Open
Log(pBreaker, $"丟出非目標例外,維持 Half-Open");
// Half-Open 的其他執行收到錯誤
for (int i = 0; i < 3; i++)
{
Console.WriteLine(p.Execute(() => DoJob(RespModes.Normal)));
Log(pBreaker, $"每秒呼叫一次 {(i + 1)}/3");
Thread.Sleep(1000);
}
}
public void Test3()
{
Write("\n**** Isolate Test ****", ConsoleColor.Cyan);
//標準測試,錯兩次斷路 5 秒
var pBreaker = Policy<string>.Handle<Exception>().CircuitBreaker(10, TimeSpan.FromSeconds(1));
var pFallback =
Policy<string>
.Handle<Exception>()
.Fallback((delgRes, context, cancelToken) =>
{
var ex = delgRes.Exception;
return $"> {ex.GetType().Name}/{ex.InnerException?.GetType().Name} {DateTime.Now:mm:ss}";
},
onFallback: (ex, context) => { });
var p = Policy.Wrap(pFallback, pBreaker);
Console.WriteLine(p.Execute(() => DoJob(RespModes.Normal)));
Log(pBreaker, "正常運作");
pBreaker.Isolate();
Log(pBreaker, "呼叫 Isolate()");
Console.WriteLine(p.Execute(() => DoJob(RespModes.Normal)));
Thread.Sleep(2000);
Log(pBreaker, "超過 durationOfBreak 也不會解除");
Console.WriteLine(p.Execute(() => DoJob(RespModes.ThrowBrkEx)));
pBreaker.Reset();
Log(pBreaker, "呼叫 Reset()");
Console.WriteLine(p.Execute(() => DoJob(RespModes.Normal)));
}
}
}
測試結果符合預期,我現在才敢說自己知道怎麼用 Polly CircuitBreakerPolicy。
範例程式已送上 Github,有需要的同學請自取。
Principle and exmaples of Polly CircuitBreakerPolicy.
Comments
Be the first to post a comment