處理 Deadlock、網路瞬斷、伺服器忙線等暫時性故障的利器 - Polly
4 | 11,454 |
向「用生命追求 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 所支援的因應策略:
- Retry
用於暫時發生很快會就自行恢復的故障,通常設定自動重試便可搞定。 - Circuit Breaker
出錯後若系統正在嘗試重啟或復原,直接告知暫停服務比讓使用者在線上傻等好,而在復原過程先擋住別讓請求流進來,也有助於系統恢復。做法是一定期間錯誤次數超過上限,即先停止執行相關動作。 - Timeout
等到一定時間就放棄,不要讓呼叫端永遠傻等下去。 - Bulkhead Isolation
避免出錯請求耗用過多資源拖垮整個系統,限定作業可用資源上限(主要是限制同時執行的請求數量),隔離其對其他系統的影響。 - Fallback
出錯時啟用備援替代方案,勉強維持營運(像是停電期間的緊急照明)。 - PolicyWrap
組合上述多種措施混用,彈性因應。
使用 Polly 程式庫有兩個步驟:
- 定義 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 錯誤
- 使用 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 只做了簡單介紹,我還找到一些文章,想深入的同學可以參考:
- 要比權威,誰拼得過官方文件跟 Source Code 呢? App-vNext/Polly on Github
- Polly retry 之後的行為是? by Yowko's Notes
- 使用 Polly 實現重試 (Retry) 策略 by m@rcus 學習筆記
- Polly 重試機制搭配 jitter 策略 by m@rcus 學習筆記
- Polly 的超時 TimeOut 和 Wrap 策略 by m@rcus 學習筆記
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/