polly - 熔斷機制

熔斷機制這個名詞我自己的理解是核心觀念就是類似電路過載保護的概念。家裡面的繼電器負責的就是提供一個保護機制,當同一電流迴路的用電負載超出安全電流,則繼電器就會跳脫,使得電路斷路,達到隔離的作用。原因是連續通過的電流會讓電線過熱(這個有興趣的話好像可以去看看電磁學,我都還給老師了沒有記得很詳細),容易使電線的絕緣體老化(也就是電線外面那層塑膠皮),甚至是燃燒的情況,從而有電線走火的危機

上面那個說法我不確定離開學校那麼久之後有沒有講錯,但大致上的觀念就是這樣:當系統發生問題,透過保護機制將發生問題的系統斷開,不讓問題繼續擴大

REF: 過載保護 - 中文百科

系統可用性相關文章

  1. Polly - 熔斷機制
  2. 常見限流策略

遭遇的問題

而在軟體產業也有類似的情況,也就是網站向外部請求資源失敗後整個卡住,從而破壞使用者體驗。這樣的情況應該很多人都有碰過吧。發生這樣的情況,原因可能有很多,但大致上我們可以想到應該是

  1. 瞬間的故障:指的是發生當下因為種種原因瞬間造成請求失敗,但通常再重試第二次就成功了,例如比較常見的 DB 資源鎖死
  2. 暫時性故障:比較常見的像外部服務故障,即使短時間內重試也沒有辦法解決問題,像是跟第三方請求資源等等

而發生了這樣的情況,我們當然會希望還是讓使用者體驗好一些,整個網站不要當在那邊,所以期望能夠改善

  1. 當服務失敗的時候,應該要能夠自動使用替代方案
  2. 管理者應該要能夠即時發現問題並處理

前者是站在使用者的角度來看,我希望就算網站的其他服務失敗了,也不應該破壞我的使用者體驗;就像是我搜尋商品,結果你一直給我卡住畫面在那邊轉圈圈,我寧願你直接回覆我【伺服器發生問題】或者是其他的回應;而後者則是站在維運的角度來看,我期望系統發生問題,我會比顧客早知道情況,這就牽涉到了其他主題,有興趣的話可以透過查詢一些關鍵字像是RUMAPM去學習研究,但在這邊我們的重點不是這個。

C# 有一個 library 叫做 polly,允許在系統服務失敗的時候執行預先定義好一些處理方式

斷路器狀態

在說明斷路器之前需要先知道電路的三種狀態:通路、開路、短路。這三種狀態的中文其實是我剛剛查來的,實際上我都是在腦子裡面記電路的圖,再用中文去想,所以我常常把開路說成斷路,因為對我來說就是電路斷開來了

  1. 通路 aka 閉路 aka Closed:通路表示的是『電路暢通』;又因為開關是關閉的,所以又稱之為『閉路』。怕搞錯最好還是直接用 Closed表示開關的狀態是關閉的
  2. 斷路 aka 開路 aka Open:斷路表示『電路中斷』;又因為開關是處於打開的狀態,所以又稱『開路』。當然也可以用英文 Open 來表示開關的狀態是打開的
  3. 短路:由於在電子電路中的短路表示電流沒有經過負載,負載不工作。但因為轉換為軟體的概念的話,就等於沒有呼叫執行程式碼,其實就沒有相對應的部分。

簡單的來說就是記住開關的狀態就是了,開關就是像下面這樣,打開電流過不去就不能用;關閉電流過去才能用

上面說完了三種傳統的電路狀態,轉換到軟體的話,我們有多一種狀態叫做 Half-Open

在解釋為什麼會有 Half-Open之前,我們需要先理解一下傳統電路的保護機制如何復原電路狀態…

是的,就是家裡跳電了怎麼辦?我們需要先檢查用電的電器是否能夠使用(跳電後燒壞了原有的電器電路?)然後接著我們要看看繼電器是否有明顯可見的異常,像是溫度過高?繼電器是否有燒焦痕跡等等,這些東西都看過沒問題了之後,我們才手動把跳脫的開關復原(也就是把跳脫的開關關閉後,再重新打開的這個行為)

現實世界的斷路器我們需要人工手動復原,但軟體的部分我們該怎麼辦?難道也需要手動復原嗎?所以這個時候 Half-Open 概念就出來了,所謂的 Half-Open 其實就是先放一個請求去試試看,其他的請求一樣先給你擋下來:然後我們看看這個放過去的請求回應是成功還是失敗,如果是成功的表示服務正常,後續的請求也可以通過;失敗表示服務還沒恢復,後續的請求還是只能先拒絕。

  1. CLOSED:正常運行,允許執行動作
  2. OPEN:電路開關處於打開的狀態,不允許執行動作
  3. HALF-OPEN:用來測試服務是否正常,同時間只會有一個請求嘗試發出,其餘被拒絕;若執行成功則將電路狀態改為CLOSED;失敗則為OPEN

REF:How the Polly CircuitBreaker works

具體的改善方式

這裡我查了一下熔斷,都說是從股票那邊過來的名詞,但我總認為是從電子電路學那邊的概念,畢竟我就不相信股票有什麼保險絲會因為電流過高而燃燒造成電路中斷從而使的保護機制生效,不過我也沒有那麼閒真的去找一堆資料來證明這件事情,反正我只是打算讓大家比較容易理解這個概念而已。之後提到熔斷大家就能夠理解是這樣的一個過載保護的概念就行了

我們一開始的期望:當服務失敗不應該破壞使用者的操作體驗,就可以透過實現斷路器來實現熔斷機制,在這邊我們查看一下 Polly LibraryCircuitBreaker 提供的 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Break the circuit after the specified number of consecutive exceptions
// and keep circuit broken for the specified duration.
Policy
.Handle<SomeExceptionType>()
.CircuitBreaker(2, TimeSpan.FromMinutes(1));

// Break the circuit after the specified number of consecutive exceptions
// and keep circuit broken for the specified duration,
// calling an action on change of circuit state,
// passing a context provided to Execute().
Action<Exception, TimeSpan, Context> onBreak = (exception, timespan, context) => { ... };
Action<Context> onReset = context => { ... };
CircuitBreakerPolicy breaker = Policy
.Handle<SomeExceptionType>()
.CircuitBreaker(2, TimeSpan.FromMinutes(1), onBreak, onReset);

大概就是這兩種吧,先是宣告你要處理的例外,然後後面就是使用斷路器,並給予必要的參數,講明白一點就是:發生幾次錯誤之後在某個時間段之內都拒絕請求,直到後續電路狀態復原為止

所以發生了幾次錯誤,你要告訴他;某個時間段是多久,你也要告訴他;發生哪一種錯誤我們要處理,一開始也要定義。當然當電路狀態改變的時候,他也提供了事件讓我們可以做些事情,也就是電路狀態中斷;復原的時候可以做些事情

用法很單純,所以我們在用的時候只需要透過他的方法

  1. 先準備好斷路器
  2. 在我們需要保護的程式碼那邊,加上斷路器

假設我的網站要跟我自己的一個 API Gateway 互相溝通,所以網站跟 API Gateway 中間應該要有一顆斷路器。只要是跟 API Gateway 溝通的請求,都用同一個斷路器就行了

REF: Statefulness-of-policies: CircuitBreaker

準備斷路器

.netFramework 的網站,為了要讓網頁的請求都用同一個斷路器,在這邊我用一個靜態類別取得斷路器,下面的程式碼宣告了一個斷路器,當這個斷路器偵測到兩次例外就會變更電路狀態為 Open,同時在十秒鐘之內一律拒絕請求;十秒中之後的第一個請求會讓電路狀態變更為 Half-Open

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static class PolicyFactory
{
/// <summary>
/// 連續記數斷路器
/// </summary>
/// <returns></returns>
/// <remarks>連續失敗兩次則斷路十秒鐘</remarks>
private static ISyncPolicy GetCircuitBreaker<T>()
{
var logPrefix = $"[{typeof(T)}]";
Action<Exception, TimeSpan> onBreak = (exception, timespan) => { Debug.WriteLine($"{logPrefix} CircuitBreaker: Open at {DateTime.Now}"); };
Action onReset = () => { Debug.WriteLine($"{logPrefix} CircuitBreaker: Closed at {DateTime.Now}"); };
Action onHalfOpen = () => { Debug.WriteLine($"{logPrefix} CircuitBreaker: Half-Open at {DateTime.Now}"); };

return Policy
.Handle<Exception>()
.CircuitBreaker(
2,
TimeSpan.FromSeconds(10),
onBreak,
onReset,
onHalfOpen
);
}
}

Circuit Breaker

Sample Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// <summary>
/// 連續記數斷路器
/// </summary>
/// <returns></returns>
/// <remarks>連續失敗兩次則斷路十秒鐘</remarks>
private static ISyncPolicy GetCircuitBreaker<T>()
{
var logPrefix = $"[{typeof(T)}]";
Action<Exception, TimeSpan> onBreak = (exception, timespan) => { Debug.WriteLine($"{logPrefix} CircuitBreaker: Open at {DateTime.Now}"); };
Action onReset = () => { Debug.WriteLine($"{logPrefix} CircuitBreaker: Closed at {DateTime.Now}"); };
Action onHalfOpen = () => { Debug.WriteLine($"{logPrefix} CircuitBreaker: Half-Open at {DateTime.Now}"); };

return Policy
.Handle<Exception>()
.CircuitBreaker(
2,
TimeSpan.FromSeconds(10),
onBreak,
onReset,
onHalfOpen
);
}

Advance Circuit Breaker

基本上就是連續記數斷路器的進階版,詳情參考Advanced Circuit Breaker

以下面程式碼的參數來解釋,大致的意思就是:採樣時間內至少有 8 次請求時,異常的發生比例超過 50%,則斷路 30 秒

Sample Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// <summary>
/// Consecutive County Circuit Controller
/// </summary>
/// <returns></returns>
/// <remarks>採樣時間內至少有8次請求時,異常的發生比例超過 50%,則斷路 30 秒</remarks>
private static ISyncPolicy GetAdvanceCircuitBreaker<T>()
{
var logPrefix = $"[{typeof(T)}]";
Action<Exception, TimeSpan> onBreak = (exception, timespan) => { Debug.WriteLine($"{logPrefix} AdvanceCircuitBreaker: Open at {DateTime.Now}"); };
Action onReset = () => { Debug.WriteLine($"{logPrefix} AdvanceCircuitBreaker: Closed at {DateTime.Now}"); };
Action onHalfOpen = () => { Debug.WriteLine($"{logPrefix} AdvanceCircuitBreaker: Half-Open at {DateTime.Now}"); };

return Policy
.Handle<Exception>()
.AdvancedCircuitBreaker(
0.5,
TimeSpan.FromSeconds(10),
8,
TimeSpan.FromSeconds(30),
onBreak,
onReset,
onHalfOpen
);
}

Timeout

這邊設定了一個測試用的 api,在執行之前會先延遲 10 秒來模擬 timeout 的情況

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestController : BaseController
{
/// <summary>
/// test for 10 sec timeout
/// </summary>
/// <returns></returns>
[HttpPost]
public string AlwaysTimeout()
{
Thread.Sleep(10000);
return "always timeout for 10 sec";
}
}

接著設定好 timeout policy 並測試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// policyFactory.cs

/// <summary>
/// Timeout 斷路器
/// </summary>
/// <returns></returns>
/// <remarks>執行超過2秒的請求會直接放棄掉</remarks>
private static ISyncPolicy GetTimeoutBreaker<T>()
{
var logPrefix = $"[{typeof(T)}]";
Action<Context,TimeSpan,Task> onTimeout = (context, timespan, task) => { Debug.WriteLine($"{logPrefix} 逾時時間:{timespan}"); };

return Policy.Timeout(TimeSpan.FromSeconds(2), TimeoutStrategy.Pessimistic, onTimeout);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 呼叫端
public string Test(string str)
{
try
{
return GetTimeoutBreaker<TestClient>().Execute(() =>
{
Debug.WriteLine("請求網路資源中...");
var timeoutUri = "http://localhost:4334/api/Test/AlwaysTimeout";
return _httpClient.Value.PostAsync(timeoutUri,null).Result.Content.ReadAsStringAsync().Result;
});
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message + " Now:" + DateTime.Now);
throw new TimeoutException("發生例外!!");
}
}

測試情境:斷路器設為 2 秒,實際上資源需要 10 秒,實際執行 Test 方法

斷路器在第二秒就直接放棄等待並直接回應一個例外,但是已經發送出去的請求還是會對 URI 請求資源。

所以單純只套用一個 timeout 的話,實際上對於整個系統來說,並沒有太大的幫助,因為它還是會向後面的服務送出請求而浪費資源。

關於 timeout 策略有區分悲觀、樂觀這部分可以再詳細察看一下官網說明Pessimistic timeout,這邊我並沒有深入再研究練習

Policy Wrap

Polly 有提供一個策略包的方法,使的各種斷路器可以串在一起使用,只需要將剛才提到的各種斷路器實體透過Policy.Wrap()包裝起來就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public static class PolicyFactory
{
// 略...
public static ISyncPolicy GetInstance<T>()
{
if (typeof(T) == typeof(TestClient))
{
var timeout = GetTimeoutBreaker<T>();
var circuitBreaker = GetCircuitBreaker<T>();
return Policy.Wrap(circuitBreaker, timeout);
}

throw new Exception($"No Match PolicyWrap For {typeof(T)}");
}
}

public class TestClient
{
private static ISyncPolicy _currentPolicyWrap;
private static ISyncPolicy CurrentPolicyWrap => _currentPolicyWrap ?? (_currentPolicyWrap = PolicyFactory.GetInstance<TestClient>());

public string SendRequest(MyRequest request)
{
return CurrentPolicyWrap.Execute(() =>
{
// 略...
});
}
}

這樣在呼叫端只要將原先的程式碼,包裝在 Execute() 之內就可以了,上述程式碼會先送出請求,先判斷是否 timeout,接著才判斷 circuitBreaker。也就是說,送出請求如果沒有在兩秒鐘之內完成,就算失敗一次,如果連續失敗兩次,則十秒鐘之內後續送出的請求都會直接被拒絕

源碼查看

說是源碼查看,因為我覺得還沒有到源碼解析的程度,大概就是分享一下斷路器這一部份的程式碼心得,首先我們可以看一下斷路器的用法大概像是下面這個樣子

1
CircuitBreakerPolicy breaker = Policy.Handle<DivideByZeroException>().CircuitBreaker(1, TimeSpan.MaxValue);

在呼叫端的語法都是先透過 Handle 並給予一個例外的類型,來表示我們要偵測的Exception,而 Handle 會返回一個 PolicyBuilder 類型,同時也會將我們設定的例外,放在裡面一併回傳

接著後續的 CircuitBreaker() 方法在建立真正的斷路器的時候,才把真正的斷路器以及我們所設定的例外,一起聚合成一個 CircuitBreakerPolicy 類型,實際上只是將一個連續記數斷路器(ConsecutiveCountCircuitController)包裝起來,該類別繼承ICircuitController<TResult>

1
2
3
4
5
6
7
8
9
10
11
12
internal interface ICircuitController<TResult>
{
CircuitState CircuitState { get; }
Exception LastException { get; }
TResult LastHandledResult { get; }
void Isolate();
void Reset();
void OnCircuitReset(Context context);
void OnActionPreExecute();
void OnActionSuccess(Context context);
void OnActionFailure(DelegateResult<TResult> outcome, Context context);
}

這個介面定義了必要的操作,像是執行成功事件、執行失敗事件、電路重置事件;也包含了手動斷路、重置事件。也有當前電路狀態供查驗

但是這些都是屬於預先定義好的東西,在實際上要呼叫使用,還必須透過斷路器繼承自PolicyExecute()等方法,將原本要執行的程式碼包裝在內

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void Execute(Action<Context, CancellationToken> action, Context context, CancellationToken cancellationToken)
{
if (context == null) throw new ArgumentNullException(nameof(context));

SetPolicyContext(context, out string priorPolicyWrapKey, out string priorPolicyKey);

try
{
Implementation(action, context, cancellationToken);
}
finally
{
RestorePolicyContext(context, priorPolicyWrapKey, priorPolicyKey);
}
}

而在這個例子當中,我們使用的是熔斷過載保護開關斷路器,因此根據原始碼,可以看到如下

1
2
3
4
5
6
7
8
9
10
11
12
protected override TResult Implementation<TResult>(Func<Context, CancellationToken, TResult> action, Context context, CancellationToken cancellationToken)
{
TResult result = default;
CircuitBreakerEngine.Implementation<EmptyStruct>(
(ctx, ct) => { result = action(ctx, ct); return EmptyStruct.Instance; },
context,
cancellationToken,
ExceptionPredicates,
ResultPredicates<EmptyStruct>.None,
_breakerController);
return result;
}

具體的行為是由 CircuitBreakerEngine.Implementation()來實現,而其他的策略如 Retry , Timeout 其實都有自己的 Engine 類別

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
 internal class CircuitBreakerEngine
{
internal static TResult Implementation<TResult>(
Func<Context, CancellationToken, TResult> action,
Context context,
CancellationToken cancellationToken,
ExceptionPredicates shouldHandleExceptionPredicates,
ResultPredicates<TResult> shouldHandleResultPredicates,
ICircuitController<TResult> breakerController)
{
cancellationToken.ThrowIfCancellationRequested();
// 執行執行之前的事件
breakerController.OnActionPreExecute();

try
{
TResult result = action(context, cancellationToken);
// 若執行結果符合預先定義的錯誤
if (shouldHandleResultPredicates.AnyMatch(result))
{
// 斷路器執行失敗事件
breakerController.OnActionFailure(new DelegateResult<TResult>(result), context);
}
else
{
// 執行成功事件
breakerController.OnActionSuccess(context);
}

return result;
}
catch (Exception ex)
{
Exception handledException = shouldHandleExceptionPredicates.FirstMatchOrDefault(ex);
if (handledException == null)
{
throw;
}

breakerController.OnActionFailure(new DelegateResult<TResult>(handledException), context);

if (handledException != ex)
{
ExceptionDispatchInfo.Capture(handledException).Throw();
}
throw;
}
}
}

透過這樣的設計,使得呼叫端呈現的是一個語意化的程式碼

  1. 建立斷路器的擴充方法都放在CircuitBreakerSyntax類別內
  2. 斷路器的主要邏輯都放在CircuitBreakerEngine
  3. 斷路器的實體類別,實作細節放在ConsecutiveCountCircuitController(連續記數斷路器)

還有比較看不懂的地方,就是Execute方法內的priorPolicyKey還看不懂他的用途

實際範例 POC

這邊我做了一個簡單的 POC:Github:PollyDemo,用來驗證 Polly 實際上的應用方式,當然這個方式也是我自己想的,沒有正式上線。我本來是想要將 PolicyFactoryDataService 搬移到 Core,但是想想這也只是一個簡單的 POC,就不多事了。

這個範例針對的是 .netFramework 4.5.2

如果依照這樣的方式,在 nuget 安裝套件完畢後,只要將設定值跟 factory 弄好,應該就很快地可以幫網站加上熔斷機制,但是需要特別注意一下,polly 對於.netFrameworkNET6 Core都有不太一樣的支援版本,所以未來可能還是要針對實際情況調整

2022-08-02 補充

Polly 也有提供限流,但是在舊的版本並沒有提供,實際測試 7.1.1 也還沒有加入 RateLimit,在 7.2.3版本才有。如果要測試的話可以在先前的 Repo 取得範例。如果是單體應用程式,不涉及分散式部署,可以透過 Polly 來做;但若是分散式部署的情況要實現限流,就需要自己實做,或是用別的套件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class RateLimitController : ApiController
{
private static Policy _currentPolicy;
private static Policy CurrentPolicy => _currentPolicy ?? (_currentPolicy = PolicyFactory.RateLimitPolicy());

[HttpPost]
public string Post()
{
var timeStamp = DateTime.Now;

try
{
return CurrentPolicy.Execute(() => DoSomething(timeStamp));
}
catch (RateLimitRejectedException ex)
{
return ex.Message;
}
}


private static string DoSomething(DateTime timeStamp)
{
return "This is a POST response from the RateLimitController - " + timeStamp;
}
}

public static class PolicyFactory
{
public static RateLimitPolicy RateLimitPolicy()
{
return Policy.RateLimit(3, TimeSpan.FromSeconds(1));
}
}

心得

在實做完斷路器之後,應該就會接著接觸到幾個關鍵字,這些關鍵字通常又會跟著一個 Domain 叫做 Site Reliability Engineering 網站可靠性工程 aka SRE,發現我最近其實重點都放在相關領域上,從先前的ELK StackKibanaSerilog,這些大概都是為了要做到能夠監控網站的情況而去學習的工具

以我自己的開發經驗大概可以分成三個階段

  • 第一個階段比較單純直白,大概就是拿到需求就動手開幹,也沒有什麼特別好的方法改善
  • 第二個階段學習目標主要都是在如何將想法變成程式碼,讓這個過程盡量平滑順利,例如學習單元測試,TDD 等開發方法
  • 第三個階段學習目標都是環繞在監控及自動化,透過數據的量化得知較客觀的數據,並透過數據的比較來決策,例如 APM, CI/CD

而今天學習的這個斷路器的部分,則是比較偏向架構,這應該是我很少涉及到的領域,所以學習過程當中出現很多名詞都沒看過,像是熔斷;降級;重試也是這一次才搞懂,他們的目的也是為了網站可靠性,最終都是為了使用者體驗,先前也看過了一篇網路文章,提到從單體網站逐步演化成微服務架構的改變,很可惜我沒有加入書籤

在學習的過程中發現越是後面的階段,怎麼去實做功能的程式碼細節,已經越來越不看重;因為程式碼的部分可以透過官網去找用法,網路也能找到文件去看;但是架構上的設計就需要理解才能夠真的拿來用,像是這一次說到的熔斷機制,程式碼反而就比較不重要,重要的是這個熔斷的觀念,後續帶出來的則是其他幾個類似的策略,都是為了一個目標:『可靠性』

相對的在如何達成網站可靠性,又有很多很多的事情可以做,而且範疇就不僅僅是程式碼,當然也包含了其他幾個領域的知識,只能說學海無涯,隨便聯想到的幾個關鍵字真的要學下去真的是學不完,所以也只能挑工作上用的到,或是較有可能用的到的先學習。