

模擬 SqlException 物件 - 使用 Reflection
source link: https://blog.darkthread.net/blog/create-sqlexception-instance/
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.

前幾天分享的暫時性故障重試利器 - 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 不到的方法,爽!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK