「啊…我有開 TransactionScopeAsyncFlowOption.Enabled 嗎?」 「咦?這兩個 Context 是不是不同連線?」 「等一下,我是不是跨資料庫了?那 MSDTC 呢?」
交易的邊界,就像洋流與領海,有時你以為你還在安全區,其實早已飄出防線。
這篇筆記,就是一趟從這場 Bug 航行而來的實驗紀錄 —— 我們用四種實際情境來測試 EF Core 的交易能力:
同資料庫、不同 DbContext
跨資料庫
共用連線 vs 不共用連線
TransactionScope vs BeginTransaction
如果你也曾在交易邊界迷航過,歡迎登船。
🧭 實驗開航前:DBContext 的布陣
為了這趟「交易航線實驗」,我們準備了三個 DbContext:
AdventureWorks2022:一個普通的 DbContext。
AdventureWorks2022V2:與 AdventureWorks2022 使用相同的資料庫,但是不同的 DbContext 實例。
NexCommerce_CouponContext:指向另一個資料庫。
1 2 3 builder.Services.AddDbContext<AdventureWorks2022>(options => options.UseSqlServer(configurationManager.GetConnectionString("AdventureWorks2022" ))); builder.Services.AddDbContext<AdventureWorks2022V2>(options => options.UseSqlServer(configurationManager.GetConnectionString("AdventureWorks2022" ))); builder.Services.AddDbContext<NexCommerce_CouponContext>(options => options.UseSqlServer(configurationManager.GetConnectionString("NexCommerce_Coupon" )));
這讓我們可以模擬「同庫多 Context」與「跨庫交易」兩種常見情境。
🧭 實驗一:TransactionScope 實測雙 DbContext 同一資料庫
我們嘗試用 TransactionScope 包住兩個 DbContext(AdventureWorks2022 與 AdventureWorks2022V2)的儲存行為,看是否能夠成功提交,或者在其中一個失敗時一併 Rollback
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public async Task TestTransactionScopeWith2SameDBContext (){ var transactionOptions = new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted, Timeout = TransactionManager.DefaultTimeout }; using (var transactionScope = new TransactionScope( TransactionScopeOption.Required, transactionOptions, TransactionScopeAsyncFlowOption.Enabled)) { try { this ._adventureWorks2022DbContext.Rds.Add(new Rd { RdName = "adven1" , DeptCode = "AD-1" }); await this ._adventureWorks2022DbContext.SaveChangesAsync(); this ._adventureWorks2022DbContextV2.Rds.Add(new Rd { RdName = "adven2" , DeptCode = "AD-2" }); await this ._adventureWorks2022DbContextV2.SaveChangesAsync(); transactionScope.Complete(); } catch (Exception ex) { System.Console.WriteLine($"{ex.Message} \n {ex.InnerException?.Message} " ); } } }
✅ 結果:成功儲存!
但我們再進一步讓 V2 寫入錯誤資料(例如超過長度限制的欄位值),觀察是否會 Rollback:
1 this ._adventureWorks2022DbContextV2.Rds.Add(new Rd { RdName = "adven2" , DeptCode = "TooLONG!!!!!!!!!!!!!!!!" });
💥 結果:例外發生,兩邊都沒有寫入(觀察資料庫無新增資料)
1 String or binary data would be truncated in table 'AdventureWorks2022.dbo.RD', column 'DeptCode'...
但要能達成這件事需要滿足這些條件
✔️ 條件 1:兩個 DbContext 使用的連線字串完全一樣(含連線參數) SQL Server ADO.NET provider 能偵測這兩條連線指向的是同一個 DB 並促成內部的 promotable transaction
✔️ 條件 2:你用了 TransactionScopeAsyncFlowOption.Enabled 這可以讓 async/await 不會破壞 ambient transaction 的流通
✔️ 條件 3:沒有觸發升級為分散式交易(MSDTC) 只要在 TransactionScope 中只涉及一個資料來源,通常不會升級為 DTC(而 DTC 有很多環境限制)
⚠️ 還是有「環境依賴風險」 ✅ 在本地測試 OK,但在這些情境就不一定會成功:
情境
風險
使用了 Azure SQL、非 MSSQL、或不同版本驅動
無法啟用 Promotable Transaction
使用不同的連線字串(即使是同一個 DB)
無法促成共享
中間夾帶第三方服務(Redis、Queue 等)
升級為 MSDTC,需環境支援
其中一個 DbContext 是在 await
後才 new 出來
ambient transaction context 可能失效
🧭 實驗二:TransactionScope 跨資料庫,一步成仙還是直接爆炸?
這次我們把 AdventureWorks2022 與 NexCommerce_CouponContext 放在同一個 TransactionScope 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public async Task TestTransactionScopeWithDiffDbsDBContext (){ var transactionOptions = new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted, Timeout = TransactionManager.DefaultTimeout }; using (var transactionScope = new TransactionScope( TransactionScopeOption.Required, transactionOptions, TransactionScopeAsyncFlowOption.Enabled)) { try { this ._adventureWorks2022DbContext.Rds.Add(new Rd { RdName = "BBB" ,DeptCode = "avb" }); await this ._adventureWorks2022DbContext.SaveChangesAsync(); this ._nexCommerce_CouponContext.Coupons.Add(new Coupon { CouponId = 12 , DiscountAmount = 100 , MinAmount = 100 }); await this ._nexCommerce_CouponContext.SaveChangesAsync(); transactionScope.Complete(); } catch (Exception ex) { System.Console.WriteLine($"{ex.Message} \n {ex.InnerException?.Message} " ); } } }
🚫 失敗訊息:
1 Implicit distributed transactions have not been enabled. If you're intentionally starting a distributed transaction, set TransactionManager.ImplicitDistributedTransactions to true.
💡 原因是:跨資料庫的操作會啟用「分散式交易」,這需要額外設定(例如 MSDTC)並明確開啟:
🧭 實驗三:BeginTransaction + 不同連線的代價
我們換一種做法,不用 TransactionScope,而是用 BeginTransaction()。這次我們觀察:如果 DbContext 雖然指向同一 DB,但使用不同的連線,交易會成功嗎?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public async Task TestBeginTransactionDiffConnectionFail (){ using (var connection = this ._adventureWorks2022DbContext.Database.GetDbConnection()) { var transaction = this ._adventureWorks2022DbContext.Database.BeginTransaction(); try { this ._adventureWorks2022DbContext.Add(new Rd { RdName = "BeginTran1" , DeptCode = "BT-1" }); await this ._adventureWorks2022DbContext.SaveChangesAsync(); this ._adventureWorks2022DbContextV2.Database.UseTransaction(transaction.GetDbTransaction()); this ._adventureWorks2022DbContextV2.Add(new Rd { RdName = "BeginTran2" , DeptCode = "BT-2" }); await this ._adventureWorks2022DbContextV2.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception ex) { System.Console.WriteLine($"{ex.Message} \n {ex.InnerException?.Message} " ); await transaction.RollbackAsync(); } } }
1 The specified transaction is not associated with the current connection. Only transactions associated with the current connection may be used.
結論:就算是同一資料庫,連線不同就沒轍。交易物件無法跨越連線使用。
🧭 實驗四:BeginTransaction + 共用連線
那如果我們手動建立第二個 DbContext 並共用連線,能讓交易撐下去嗎
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 public async Task TestBeginTransactionSameConnectionSuccess (){ using (var connection = this ._adventureWorks2022DbContext.Database.GetDbConnection()) { var transaction = this ._adventureWorks2022DbContext.Database.BeginTransaction(); try { this ._adventureWorks2022DbContext.Add(new Rd { RdName = "BeginTran1" , DeptCode = "BT-1" }); await this ._adventureWorks2022DbContext.SaveChangesAsync(); var v2optionBuilder= new DbContextOptionsBuilder<AdventureWorks2022V2>() .UseSqlServer(connection) .Options; using var v2DbContext = new AdventureWorks2022V2(v2optionBuilder); v2DbContext.Database.UseTransaction(transaction.GetDbTransaction()); v2DbContext.Add(new Rd { RdName = "BeginTran2" , DeptCode = "BT-2" }); await v2DbContext.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception ex) { System.Console.WriteLine($"{ex.Message} \n {ex.InnerException?.Message} " ); await transaction.RollbackAsync(); } } }
✅ 結果:交易成功,共兩筆資料寫入。這證明:只要 DbContext 使用相同連線並共用交易物件,即使是不同實例,也能協同作業。
☘️ 結語 很多時候,我們並不是不懂交易怎麼運作,只是預設它「會幫我處理好」。
但事實是
你用兩個 DbContext,就已經把自己放進一艘不確定的船;
你跨資料庫,就已經進入需要特許航道的海域;
你用了 BeginTransaction,卻讓它漂流在不同連線上,自然找不到彼此。
每一次踩坑後的 Rollback,都是一次「喔原來不能這樣」的醒悟。這篇文章不是要給出什麼神解法,反而是提醒我們: EF Core 給了我們很大的彈性,但這份彈性,也需要我們主動去畫出界線、維持一致性。畢竟,程式的問題,多半不是出在「這樣寫可不可以」,而是「我們知不知道它底下怎麼跑」。就像一艘船能不能出港,不只看船造得漂不漂亮,還要看你知不知道洋流怎麼走。