模擬 SqlException 物件 - 使用 Reflection
8 |
前幾天分享的暫時性故障重試利器 - 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.
Comments
# by oaww
用with onlock會不會比較快? XD
# by 馬克
我比較想知道select為什麼會有deadlock… 因為我也有遇到…
# by Jeffrey
to oaww,如果 Dirty Read 帶來的風險低於 Deadlock 造成的困擾,WITH (NOLOCK) 是特效藥無誤。 to 馬克,我也想知道... Orz
# by Xman
我的經驗,時間較長的長查詢容易發生,例如百萬對百萬的 table join... 再來是不同順序的 join 同時執行也有機會: Thread 1: select ..from A inner join B on .. Thread 1: select ..from B inner join A on ..
# by Xman
最後一行應該是 thread 2 才對,工程師喜歡 c & v 老是造成 bug 無誤。
# by Jeffrey
to Xman, Good Point! 感謝分享。
# by Alan
(SqlError)Activator.CreateInstance(typeof(SqlError) 執行到這裡會...System.MissingMethodException: 'Constructor on type 'System.Data.SqlClient.SqlError' not found.' 請問黑大何解...?
# by Kenny
我改成這樣可以繼續跑下去,你試試看囉 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 null, null }, null, null);