向「用生命追求 Coding 極致的男人」 - 91 哥請教幾個笨問題,談到遇到 Deadlock 失敗自動重試的機制,心裡想著這該寫成通用函式用起來才方便。91 說:你為什麼不問問神奇海螺呢? 建議我可以試試 Dapper Polly,讓我又認識了好物,收入軍火庫。(這感覺像無意聊到最近我的山坡果園老鼠多該想想辦法,對方馬上從倉庫推出一台「丘陵地形樹林區域專用小型動物捕捉系統」建議我試試... 靠! 人家的軍火庫到底有多大間啊?)

資料庫作業偶爾會遇到暫時性故障,所謂「暫時性故障」(Transient Fault) 指程式邏輯正確卻因突發因素失敗,稍後再試一次大多會成功。最經典案例莫過資料庫動作與別人強碰產生 Deadlock,被系統二選一當了犠牲者(Victim)。遇到這種狀況,再試一次多半就好了;其他像是網路不穩瞬斷等等,稍後再試也能解決。面對這類狀況,系統有兩種處理策略:直接回報錯誤,由使用者決定自行重試或放棄(或是客訴?) vs 系統自行重試,若重試成功,使用者只感到稍許延遲不會發現這個小亂流。顯然,後者的操作體驗更好一點。

有間美國軟體顧問公司 App vNext 開了一個開源 .NET 專案 Polly,為這類暫時性故障(不限資料庫,伺服器忙線、網路塞車或瞬斷都適用)提供專業的統一處理機制,透過設定方式選擇 Retry (重試)、Circuit Breaker (熔斷)、Timeout (逾時中止)、Bulkhead Isolation (艙倉隔離,將船艙漏水部分封閉防止沈船)、Fallback (替代措施)... 等策略。這些做法是大家熟知的例外處理技巧,Polly 程式庫將它們收納成統一機制,讓開發人員能使用一致且簡潔的寫法處理各式暫時性故障。

Polly 從 .NET 2.0/3.5/4.5/4.7 到 .NET Core 3.1/.NET 5 都支援,但適用版本不同,例如:最新版程式庫支援 .NET Standard 2.0、.NET Framework 4.6.1+,若你是 .NET 4.0,只能用 5.9.0 以前的版本,詳情可參考對照表

開始前先花點時間說明 Polly 所支援的因應策略:

  1. Retry
    用於暫時發生很快會就自行恢復的故障,通常設定自動重試便可搞定。
  2. Circuit Breaker
    出錯後若系統正在嘗試重啟或復原,直接告知暫停服務比讓使用者在線上傻等好,而在復原過程先擋住別讓請求流進來,也有助於系統恢復。做法是一定期間錯誤次數超過上限,即先停止執行相關動作。
  3. Timeout
    等到一定時間就放棄,不要讓呼叫端永遠傻等下去。
  4. Bulkhead Isolation
    避免出錯請求耗用過多資源拖垮整個系統,限定作業可用資源上限(主要是限制同時執行的請求數量),隔離其對其他系統的影響。
  5. Fallback
    出錯時啟用備援替代方案,勉強維持營運(像是停電期間的緊急照明)。
  6. PolicyWrap
    組合上述多種措施混用,彈性因應。

使用 Polly 程式庫有兩個步驟:

  1. 定義 Policy 例如 Policy.Handle<SqlException>(ex => ex.Number == 1205).Retry(3) 可鎖定特定 SQL 錯誤啟用因應措施,這個例子是連續重試三次。除了 Retry(),以下是一些常見範例,詳細介紹則請參考官方文件
  • RetryForever() 重試到死
  • WaitAndRetry() 重試前先等一小段時間以提高成功率或降低系統負擔
  • CircuitBreaker(2, TimeSpan.FromMinutes(1) 連錯兩次就先停止執行(直接報錯)一分鐘
  • Fallback() 失敗時執行備援作業
  • Policy.Handle().Fallback(() => ...FalbackResult)失敗時傳回替代結果
  • Timeout() 指定逾時上限 (需配合 CancellationToken 使用)
  • Bulkhead(12) 允許最多 12 個呼叫同時執行,超過時拋出 BulkheadRejectedException 錯誤
  1. 使用 Policy.Execute() 執行動作
    訂好 Policy,用 plolicyObject.Execute(() => DoSomething()) 將要執行的動作包起來就 OK 了。

看完介紹,寫個範例實測一下。

假設有個超熱門網站,偶爾會發生 503 伺服器忙碌錯誤,我用以下的 ASPX 模擬,約有 1/5 機率回傳 503 錯誤。

<%@ Page Language="C#"%>
<script runat="server">
static Random rnd = new Random();
void Page_Load(object sender, EventArgs e) 
{
	if (rnd.Next(5) == 0) 
		Response.StatusCode = 503;
	else 
		Response.Write("OK");
}
</script>

在沒有 Retry 機制時,連續發出 100 次呼叫,平均有 20% 會失敗。

const string url = "http://localhost/aspnet/random503.aspx";
static WebClient wc = new WebClient() { UseDefaultCredentials = true };
static bool CallWithWebClient() => wc.DownloadString(url) == "OK";
static void CallWebWithoutRetry()
{
    var succCount = Enumerable.Range(1, 1000).Select((i) =>
    {
        try
        {
            return CallWithWebClient();
        }
        catch (Exception ex)
        {
            return false;
        }
    }).Where(o => o).Count();
    Console.WriteLine(succCount);
}

一如預期,成功率大約八成。

接著我們用 Polly 加上 Retry 機制,Polly 可從 NuGet 下載引用:

程式部分先宣告一個 Polly Policy 指定處理 WebException 並鎖定 503 (ServerUnavailable) 錯誤重試最多三次,將 CallWithWebClient() 移到 Policy.Execute() 裡執行:

static void CallWebWithPollyRetry()
{
    var policy = Policy.Handle<WebException>(wex =>
        wex.Status == WebExceptionStatus.ProtocolError &&
        ((HttpWebResponse)wex.Response).StatusCode 
            == HttpStatusCode.ServiceUnavailable)
        .Retry(3);

    var succCount = Enumerable.Range(1, 1000).Select((i) =>
    {
        try
        {
            return policy.Execute<bool>(() => CallWithWebClient());
        }
        catch (Exception ex)
        {
            return false;
        }
    }).Where(o => o).Count();
    Console.WriteLine(succCount);
}

如下圖所示,成功率上升到 99.8%:

重試次數再拉高到 5 次,成功率便有機會 100%:

推薦使用 Polly 的另一個理由是:連 Microsoft 也將 Polly 視處理暫時性故障慣用做法,還為它寫了跟 HttpClientFactory 整合的套件。以下會將 WebClient 換成 HttpClient,展示如何讓 IHttpClientFactory 直接整合 Polly:

如果你還沒寫過 ASP.NET Core,看到下面的程式範例可能會嚇到,不是把 WebClient 換成 HttpClient 而已,怎麼把程式改成這樣?

IHttpClientFactory 屬於 .NET Core 世代,設計上與 DI (依賴注入)密不可分,不熟 .NET Core DI 起手式的同學看到一堆服務註冊、從建構式取得物件大概會滴咕:不過要用個 HttpClient 搞到這麼麻煩,夠扯。但我必須說,這是跨向 .NET Core/.NET 5 的門檻,未來很多程式都長成這樣,想繼續讓 .NET 帶你飛就必須學會。若對 DI 不熟,這裡有一些補充資料:

  • 基本概念 - 不可不知的 ASP.NET Core 依賴注入
  • 大部分 DI 範例都是以 ASP.NET Core 為例,在 Console Appication 實作的寫法有點不同 - Dependency Injection in .NET Core Console app using Generic HostBuilder
  • 引用 DI 所註冊服務或元件的典型做法是透過建構式參數,所以範例中我也依循此模式定義一個 MyApp 型別包含呼叫外部網站的方法,IHttpClientFactory 則由建構式參數取得。使用建構式參數取得服務是寫 .NET Core 的重要技巧,Visual Studio 還有快速鍵可以幫你搞定參數建構式,故也算該學的基本技能。
  • HttpClient 提供的 API 全是非同步版本 (例如:GetAsync()、ReadAsStringAsync()),在 Console 程式中,從頭到尾只有單執行緒,寫 async/await 的意義不大,故我習慣用 GetAwaiter().GetResult() 轉同步化讓程式碼簡單點。使用 GetAwaiter() 的好處是會直接回傳錯誤資訊。(GetAwaiter().GetResult() 與 Result 的差異)。至於該全面改成 async/await 或是用 GetAwaiter() 的討論,可參考這篇:GetAwaiter 到底能不能用?

(希望還沒開始玩 .NET Core 的朋友沒被這些先修課程資料嚇到,但這些都屬必須跨過的坎,悟得 DI 跟非同步的心法之後,後面就沒那麼難了)

先來看還沒加 Polly Retry 的版本:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

namespace PollyTest
{
    class Program
    {
        static IHost host;
        static void Main(string[] args)
        {
            var builder = new HostBuilder()
                .ConfigureServices((hostContext, services) =>
                {
                    //使用 AddHttpClient() 註冊 IHttpClientFactory
                    services.AddHttpClient();
                    //應用 IHttpClientFactory 具名功能另外定義使用預設認證的 HttpClient
                    services.AddHttpClient("UseDefaultCredentials")
                    .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
                    {
                        UseDefaultCredentials = true
                    });
                    //註冊 MyApp,MyApp 在建立時可透過 DI 取得 IHttpClientFactory 等服務
                    services.AddSingleton<MyApp>();
                }).UseConsoleLifetime();

            host = builder.Build();

            using (var serviceScope = host.Services.CreateScope())
            {
                var services = serviceScope.ServiceProvider;
                var myApp = services.GetRequiredService<MyApp>();
                var succCount = Enumerable.Range(1, 1000).Select((i) =>
                {
                    try
                    {
                        return myApp.CallWeb();
                    }
                    catch (Exception ex)
                    {
                        return false;
                    }
                }).Where(o => o).Count();
                Console.WriteLine(succCount);
            }
            Console.ReadLine();
        }
    }

    public class MyApp
    {
        private readonly IHttpClientFactory httpFactory;

        public MyApp(IHttpClientFactory httpFactory)
        {
            this.httpFactory = httpFactory;
        }
        const string url = "http://localhost/aspnet/random503.aspx";
        public bool CallWeb()
        {
            var client = httpFactory.CreateClient("UseDefaultCredentials");
            return
                client.GetAsync(url).GetAwaiter().GetResult()
                .Content.ReadAsStringAsync().GetAwaiter().GetResult() == "OK";
        }
    }

}

成功率約八成左右:

IHttpClientFactory 整合 Polly 的概念是註冊服務時設定好,之後建立的 HttpClient 都會套用該 Policy。在 AddHttpClient() 時透過 AddPolicyHandler 指定 Polly Policy,範例是用 HandleTransientHttpError() 涵蓋所有 5xx (伺服器端錯誤)及 408 (Request Timeout) 錯誤時重試,最多三次:

//應用 IHttpClientFactory 具名功能另外定義使用預設認證的 HttpClient
services.AddHttpClient("UseDefaultCredentials")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
    UseDefaultCredentials = true
})
//設定 Polly Policy 重試
.AddPolicyHandler(
    HttpPolicyExtensions
        //HandleTransientHttpError 包含 5xx 及 408 錯誤
        .HandleTransientHttpError()
        .RetryAsync(3));

套用後 httpFactory.CreateClient("UseDefaultCredentials") 取得的 HttpClient 便具有遇到伺服器錯誤自動重試的機制,實測成功率上升到 99.6%:

上面示範的都是連續立即重試,實務應用時會細緻一些,例如:稍等一下再試、第 2、3、4 次的等待時間呈等比級數拉長、等待隨機延遲後再試(術語為 Jitter,防止整批失敗的請求等待相同時間後又整批重試),Yowko 的這篇在 .NET Core 與 .NET Framework 上使用 HttpClientFactory 還有一些 HttpClientFactory 整合 Polly 的範例。

回到一開頭說的 Deadlock 重試需求,在擁有 Polly 之後,談笑間強虜灰飛煙滅。

我鎖定 MSSQL Error 1205 Transaction (Process ID %d) was deadlocked on %.*ls resources with another process and has been chosen as the deadlock victim. Rerun the transaction.,故用 Handle 指定 SqlException 型別並檢查 Number == 1205,再接 WaitAndRetry 等待 1, 2, 4 秒後再重試,就這麼簡單:

Policy
    .Handle<SqlException>(se => se.Number == 1205)

    .WaitAndRetry(new TimeSpan[]
    {
        TimeSpan.FromSeconds(1),
        TimeSpan.FromSeconds(2),
        TimeSpan.FromSeconds(4)
    })
    .Execute(() =>
    {
        using (var cn = new SqlConnection(cnStr))
        {
            cn.Execute("INSERT INTO ....");
        }
    });

實務上,除了 Deadlock 之外,還有一些情況可靠稍後重試解決(例如:複雜又肥大的 SQL 查詢第一次查因資料還沒進 Cache 逾時,再查一次就快了),資深開發者兼技術作家 Ben Hyrman 有分享一組相當完整的 SQL 重試擴充函式 - SQL Server Retries with Dapper and Polly,蒐羅了所有常見的 SQL 及網路連線的暫時性失敗,值得參考。

這篇文章對 Polly 只做了簡單介紹,我還找到一些文章,想深入的同學可以參考:

Introduction to .NET library Polly and examples of using Polly to implement retry on WebRequest, HttpClient and Dapper.


Comments

# by Yoana

請問您的 Visual Studio Theme 是什麼呢?覺得配色看起來滿舒服的。

# by Jeffrey

to Yoana,是 VS 內建的 Dark Color Theme: https://i.imgur.com/llKRSTj.png

# by 余小章

謝謝黑大的分享, 題外話,.NET Core 沒有用 SynchronizationContext 來等待了,所以可以不用 Task.GetAwaiter().GetResult()。 https://blog.stephencleary.com/2017/03/aspnetcore-synchronization-context.html

# by Jeffrey

to 余小章,謝謝分享好文。依我的理解,取消 SynchronizationContext 的好處是不會因為 Task.GetAwaiter().GetResult() 造成 Deadlock,如果不想要大量改寫 async/await,它仍是非同步轉同步最省力的解法。由於 Task.GetAwaiter().GetResult() 還是有 Thread Starvation 風險,宜優先考慮使用 async/await 寫非同步程式,它是較好的選擇;或者你不想搞懂那麼多細節,寫 async/await 也是首選,雖然麻煩些但不容易犯錯。這部分在「 GetAwaiter 到底能不能用?」有更多討論 https://blog.darkthread.net/blog/getawaiter-or-not/

Post a comment