【茶包射手日記】TransactionScope 踩雷記 - 部分 Commit 的破損交易?
2 |
在我們的認知裡,所謂的資料庫交易(Transaction)必須要做到交易範圍內的所有更新動作,要嘛一起更新,要嘛一起失敗,不允許出現部分更新的狀態,這是最基本的要求。就像轉帳交易,A 帳戶減 100 跟 B 帳戶加 100 必須一起成立(Commit)或全部取消(Rollback),若存在 A 帳戶扣款失敗,但錢還是成功轉進 B 帳戶,肯定要天下大亂。
某個古蹟系統使用 TransactionScope 實現交易,比起手動控制 SqlTransaction,將要參與交易的動作包進 TransactionScope 範圍就自動被包進交易,是懶人的福音,但其中也容易暗藏陷阱。
隨著工作負載增加,發現系統久久會出現 TransactionScope 裡部分 INSERT/UPDATE Commit 部分 Rollback 的狀況,這在我們的認知裡萬萬不該發生,TransactionScope 跟 SQL Server 連這點基本要求都做不好,不可原諒。
實際的程式碼有點小複雜(畢竟是古蹟嘛),經過一番追蹤調查,確認 TransactionScope 跟 SQL Server 是無辜的,小丑還是自己。
用一個簡化的範例來重現這個問題。
先說,這個範例可以吐糟的點很多,BuggyDataProvider 在其生命週期共用連線物件、BusinessLogic 在 TransactionScope 中途重新開啟 SqlConnetion 讓狀況複雜化... 大家不要學。(也因為存在這些問題,才導致部分 Commit 的狀況)
// 註:此為不好的設計,物件生命週期共用 SqlConnection
public class BuggyDataProvider
{
public SqlConnection CreateConnection() => new SqlConnection(Program.GetConnectionString());
SqlConnection cn = null;
public void OpenConnection()
{
if (cn != null) cn.Close();
cn = CreateConnection();
cn.Open();
}
public void Insert(string id, string name, int score)
{
cn.Execute("INSERT INTO EXAMPLE VALUES (@id, @name, @score)", new
{ id, name, score });
}
}
public class BusinessLogic
{
BuggyDataProvider dp = new BuggyDataProvider();
public void Simulate()
{
using (var scope = new System.Transactions.TransactionScope())
{
// 第一段更新
dp.OpenConnection();
// 模擬執行商業邏輯的延遲
Thread.Sleep(200);
dp.Insert("A01", "Jeffrey", 32767);
// 重新開啟連線,進行第二段更新
dp.OpenConnection();
dp.Insert($"B01", "darkthread", 255);
// B01 Primary Key 重複,會觸發例外
dp.Insert($"B01", "Duplicated PK", 255);
scope.Complete();
}
}
}
internal class Program
{
const string cs = "Data Source=sqldb;User ID=Lab;Password=P@ssW0rd;Initial Catalog=Lab";
// TODO: 改由安全來源取得連線字串
public static string GetConnectionString() => cs;
public static SqlConnection CreateConnection() => new SqlConnection(cs);
static void Main(string[] args)
{
ClearData(); // 清空資料表
try
{
RunTxScope();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
CheckData(); // 顯示資料表內容
Console.ReadLine();
}
static void ClearData()
{
using (var conn = CreateConnection())
conn.Execute("DELETE FROM EXAMPLE");
}
static void CheckData()
{
using (var conn = CreateConnection())
{
Console.WriteLine("ID NAME SCORE");
Console.WriteLine("-- ------------ -----");
foreach (var dr in conn.Query("SELECT * FROM EXAMPLE"))
{
Console.WriteLine($"{dr.ID} {dr.NAME,-12} {dr.SCORE,5}");
}
}
}
static void RunTxScope()
{
var proc = new BusinessLogic();
proc.Simulate();
}
}
BuggyDataProvider 內含重新建立 SqlConnection 及執行 INSERT 的邏輯,Simulate() 方法呼叫它的 Insert() 方法寫入三筆資料,開啟連線後寫入第一筆,之後因某個特殊規格觸發重新建立連線,再寫入第二筆跟第三筆,而第三筆故意跟第二筆 Primary Key 重複以製造錯誤,預期整個 TransactionScope 內的動作會取消,連第一筆資料也被 Rollback。
雖然程式有一堆毛病,但執行結果是正確的 - 當發生 Primary Key 重複錯誤,scope.Complete() 未被執行,INSERT A01 的動作也被 Rollback,最後資料表沒有任何資料。
接著來模擬出錯的情境。
假設系統有其他平行作業,很要命地在另一個執行緒共用 BuggyDataProvider 物件(其依賴狀態(連線物件 cn),非 Thread-Safe),在特定狀況下也會重新建立 SqlConnection 連線。我這裡用 ThreadPool 延遲 0.1s 重開連線模擬,形成 TransactionScope 內新建連線並開啟(SqlConnection.Open())、另一條 Thread 再新建 SqlConnection 並開啟、TransactionScope 內用新開的 SqlConnection 執行 INSERT 的邏輯。
public void Simulate()
{
ThreadPool.QueueUserWorkItem((state) =>
{
Thread.Sleep(100);
dp.OpenConnection();
}, null);
using (var scope = new System.Transactions.TransactionScope())
{
// 第一段更新
dp.OpenConnection();
// 模擬執行商業邏輯的延遲
Thread.Sleep(200);
dp.Insert("A01", "Jeffrey", 32767);
// 重新開啟連線,進行第二段更新
dp.OpenConnection();
dp.Insert($"B01", "darkthread", 255);
// B01 Primary Key 重複,會觸發例外
dp.Insert($"B01", "Duplicated PK", 255);
scope.Complete();
}
}
重跑程式,這回就出事了,雖然沒有 scope.Complete(),第一筆寫入的 A01 留在資料庫中,沒被 Rollback。
經過一番研究,發現我原本「INSERT A01 被包進 TransactionScope,出錯了卻沒被 Rollback」的想法是錯的,正確來說是 INSERT A01 動作根本沒被包進 TransactionScope,從頭到尾沒參與交易,沒有 Rollback 或 Commit 的問題。依據官方文件的說明:(講 SQL Server Compact,但應適用 SQL Server)
Implicitly enlisting connections is not supported. To enlist in a transaction scope, you can do the following:
- Open a connection in a transaction scope.
- Or, if the connection is already opened, call EnlistTransaction method on the connection object.
要 SqlConnection 要參與 TransactionScope,要嘛在 TransactionScope 範圍內開啟,不然就得呼叫 SqlConnection.EnlistTransaction.Current)。INSERT A01 用的連線是在 ThreadPool 建立的,在 TransactionScope 之外,導致 INSERT A01 並未參與交易。
依據官方文件,修改方法有二:
- 不要共用 SqlConnection 並預先 Open(),改為 Insert() 方法內才建立連線及開啟,用完關閉
- Insert 時檢查若 Transaction.Current 有值,呼叫 cn.EnlistTransaction(Transaction.Current)。
實測,二者做法都可以將 A01 重新包進 TransactionScope,一起被 Rollback。但二者做法均涉及 SqlConnection 操作行為改變,要考慮對其他程式碼的影響,需依系統架構決定適當的修改做法。
幾十年前的老東西,今天再重新認識一次,下次就不會再犯相同的低級錯誤了。(應該也沒下次了)
Encountered issues with TransactionScope in a legacy system, where some updates committed while others rolled back. The cause was improper handling of SQL connections within the TransactionScope. By ensuring SQL connections are correctly enlisted in the transaction, this issue was resolved.
Comments
# by 小黑
這集很讚
# by bamboo
Insert 前贅有 dp. 很容易誤導