昨天提到 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

Post a comment