8

處理 Deadlock、網路瞬斷、伺服器忙線等暫時性故障的利器 - Polly

 3 years ago
source link: https://blog.darkthread.net/blog/polly/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
處理 Deadlock、網路瞬斷、伺服器忙線等暫時性故障的利器

向「用生命追求 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);
}

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

Fig2_637437167981705093.png

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

Fig1_637437167982685308.png

程式部分先宣告一個 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%:

Fig3_637437167983231226.png

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

Fig4_637437167983719491.png

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

Fig5_637437167984283682.png

如果你還沒寫過 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";
        }
    }

}

成功率約八成左右:

Fig6_637437167984775787.png

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%:

Fig7_637437167985224049.png

上面示範的都是連續立即重試,實務應用時會細緻一些,例如:稍等一下再試、第 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 只做了簡單介紹,我還找到一些文章,想深入的同學可以參考:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK