前幾天分享的 暫時性故障重試利器 – Polly ,起源於我要處理一個 Deadlock 失敗自動重試 的案例。案件本身有點玄疑,發生在一段 WHERE、JOIN 條件頗複雜的純查詢上(對! 只有 SELECT,不涉及 UPDATE、DELETE),在線上環境尖峰時間偶爾發生 “交易 (處理序識別碼 70) 在 鎖定 資源上被另一個處理序鎖死並已被選擇作為死結的犧牲者。請重新執行該交易。” 的 Deadlock 錯誤訊息,頻率不高,幾萬次才會出現一次吧。調查 Deadlock 形成原因仍需費點工夫,暫時是無法解決了,但誠如上回討論的,這種暫時性故障程式再試一次就好了,何苦讓使用者察覺,還噴出什麼”死結”、”犠牲者”等恐怖字眼,搞資料庫的人明白怎麼一回事,祕書美眉看到,還以為自己生命受危脅嚇到想報警了呢。(並不會)
於是,借助神奇的 Polly,原本的程式可輕鬆改成如下。遇到 SQL 1205 錯誤時,會等 1、2、4 稍後重試一次(其實重試一次就好,連續 Deadlock 三次樂透可以中頭彩了)。這裡順便示範在 WaitAndTry() 傳入第二個參數 Action<Exception, TimeSpan> onRetry,可在重試時留下記錄:
static void Main(string[] args) { TestFunc(); Console.ReadLine(); } static void TestFunc() { var answer = Policy .Handle<SqlException>(se => se.Number == 1205) .WaitAndRetry(new TimeSpan[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }, (ex, timeSpan) => { //這裡用 Console.WriteLine 簡單記錄重試動作 Console.WriteLine( $"Retry after {timeSpan.TotalSeconds}s - {ex.Message}"); }) .Execute<int>(() => { return SomeComplexQueryJob(); }); Console.WriteLine($"The Answer = {answer}"); } //某個會觸發 Deadlock 的 SQL 查詢動作 //不要問我為什麼單純 SELECT 動作會引發 Deadlock,拎杯就是有遇到 //發生機率很低,我還沒找出觸發這種茶包的 Pattern static int SomeComplexQueryJob() { using (var cn = new SqlConnection(cnStr)) { //搞出問題的實際查詢很複雜,我模擬不出來,隨便寫一個 return cn.Query<int>("SELECT 42 AS AnswerToEverything").Single(); } }
問題來了,我要怎麼驗證重試流程有效?最直覺的想法是改寫 SomeComplexQueryJob(),拋出 Deadlock 錯誤。(如果程式有套用 DI / Interface 設計,遇到這種狀況要抽換就很簡單,實作範例可參考 ASP.NET Core 練習 – 依賴注入 DI 裡抽換成模擬資料 IWeatherService 類別 – FakeWeatherService 的例子)
要拋出 SQL 1025 Deadlock 錯誤,第一種做法是故意搞出 Deadlock,手續稍稍複雜些,還要找到適合的 Table 演一場戲,但至少是可行的。我很久沒有這樣玩 SQL,當作伸展實際演練一次。
原理是做兩個資料表(本例為 Emp, Cust),兩個更新動作用相反的順序 INSERT 資料,一個先塞 Emp 再塞 Cust、另一個先塞 Cust 才塞 Emp:
CREATE TABLE Emp ( UserId INT NOT NULL PRIMARY KEY, UserName NVARCHAR(16) ) CREATE TABLE Cust ( UserId INT NOT NULL PRIMARY KEY, UserName NVARCHAR(16) )
我寫了以下程式,在正常的 INSERT 之外,另開一條 Thread 跑相反順序的 INSERT,INSERT 過程插入 5 秒等待,刻意讓二者強碰以製造 Deadlock。這裡有個眉角,正常 INSERT 或 Task 跑的逆行 INSERT 都可能被選為 Deadlock 犠牲者,若是 Task 被挑中,若單純只用 Wait() 等待執行完成,程式抓到的將會是 AggregationException,要再查 InnerException 才會取得 SqlException,如此將不會觸發 Polly 的 Handle<SqlException> 設定。因此,先前介紹過的GetAwaiter()是讓程式如預期運作的重要關鍵:
//用計數器控製只製造兩次 Deadlock ,第三次成功 static int throwError = 2; static int DeadlockQueryJob() { using (var cn = new SqlConnection(cnStr)) { cn.Execute("DELETE FROM Emp; DELETE FROM Cust;"); } //若開 Thread 跑逆行的 INSERT var task = Task.Factory.StartNew(() => { if (throwError <= 0) return; throwError--; using (var cn = new SqlConnection(cnStr)) { Console.WriteLine("Executing INSERT in another thread..."); cn.Execute(@" BEGIN TRAN INSERT INTO Emp VALUES (1,'Jeffrey'); WAITFOR DELAY '00:00:05'; INSERT INTO Cust VALUES (1, 'Jeffrey'); COMMIT TRAN"); } }); using (var cn = new SqlConnection(cnStr)) { Console.WriteLine("Executing INSERT..."); cn.Execute(@" BEGIN TRAN INSERT INTO Cust VALUES (1,'darkthread'); WAITFOR DELAY '00:00:05'; INSERT INTO Emp VALUES (1, 'darkthread'); COMMIT TRAN "); } //https://blog.darkthread.net/blog/getawaiter-getresult-vs-result/ //等待另開 Thread 的執行結果 //這裡 GetAwaiter() 很重要,確保拋回 SqlException 而非 AggregateException task.GetAwaiter().GetResult(); return 42; }
我們如願看到 Polly WaitAndRetry 生效的結果:
要找到合適的 Table、設計兩組會強碰的指令、加開 Thread 同時跑更新真的很囉嗦,為何不 throw new SqlException 就好?
終於講到這篇文章的重點了(呼!),SqlException 是 SQL 內部使用的型別,沒提供公開的建構式,你沒法隨便 new 一個出來,更別提指定 SqlException.Number。(這裡得指定 Number = 1205 以模擬 Deadlock 錯誤)
下面是反組譯查到的 SqlException 設計,類別加了 sealed 沒法繼承改寫,只有兩個 private 建構式,設計上要透過內部靜態方法 internal static SqlException CreateException(…) 呼叫 private 建構式建立物件。SqlException 本質上限定 System.Data.SqlClient 內部使用,沒打算對外開放。
所以沒路了嗎?當然不是,有 System.Reflection 在手,private、internal 可擋不住我們。
介紹這次用到的 Reflection 技巧:
- 沒有參數的 private 建構式
用 object? CreateInstance (Type type, bool nonPublic) ,nonPublic 傳 true 就好了。例如:(SqlErrorCollection)Activator.CreateInstance(typeof(SqlErrorCollection), true);
- 有參數的 private 建構式
用 object CreateInstance (Type? type, System.Reflection.BindingFlags bindingAttr, System.Reflection.Binder binder, object[] args, System.Globalization.CultureInfo culture, object[] activationAttributes); ,args 傳參數陣列(若有多個建構式,型別要精準才會正確對映),binder、culture、activationAttributes 傳 null 即可 - 呼叫內部方法
先 MethodInfo? GetMethod (“methodName”, BindingFlags.Instance | BindingFlags.NonPublic) 取得內部方法的 MethodInfo,再 MethodInfo.Invoke (object obj, object[] parameters) 執行。
追了 SqlException 邏輯,我找出以下生出 Number = 1205 SqlException 的方法:
static int SqlExceptionJob() { if (throwError == 0) return 42; throwError--; var error = (SqlError)Activator.CreateInstance(typeof(SqlError), BindingFlags.Instance | BindingFlags.NonPublic, null, new object[] { (int)1205, //infoNumber (byte)255, //errorState (byte)0, //errorClass "NullServer", //server "Error Message", //errorMessage "NullProcedure", //procedure (int)255, //lineNumber (uint)5 }, null, null); SqlErrorCollection errors = (SqlErrorCollection)Activator.CreateInstance(typeof(SqlErrorCollection), true); //呼叫 internal Add(SqlError error) typeof(SqlErrorCollection).GetMethod("Add", BindingFlags.Instance | BindingFlags.NonPublic) .Invoke(errors, new object[] { error }); SqlException ex = (SqlException)Activator.CreateInstance(typeof(SqlException), BindingFlags.Instance | BindingFlags.NonPublic, null, new object[] { "交易 (處理序識別碼 9527) 在 鎖定 資源上被另一個處理序鎖死並已被選擇作為死結的犧牲者(你認命吧!)。請重新執行該交易。", errors, (Exception) null, Guid.Empty }, null, null); throw ex; }
實測成功! 管它什麼 internal、private,沒有拎杯 Call 不到的方法,爽!
This article shows how to cause a deadlock error on MSSQL and creating a SqlException instance with Reflection.