ocean

「啊…我有開 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}");
}
}
}

✅ 結果:成功儲存!
TransactionScopeSameDB


但我們再進一步讓 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'...

TransactionScopeSameDBRollbackException


但要能達成這件事需要滿足這些條件

✔️ 條件 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
/// <summary>
/// 因為跨 DB 要使用 TransactionScope 會因為沒開啟支援分散式交易噴掉
/// </summary>
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
{
//// adventureWorks2022 DB
this._adventureWorks2022DbContext.Rds.Add(new Rd { RdName = "BBB",DeptCode = "avb" });
await this._adventureWorks2022DbContext.SaveChangesAsync();

//// nexCommerce DB
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)並明確開啟:
DistributedException



🧭 實驗三: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
/// <summary>
/// 連線到相同 DB 但使用不同的連線時,會遇到連線錯誤
/// </summary>
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();

//// 其實 _adventureWorks2022DbContextV2已經注入 有自己的連線
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.

結論:就算是同一資料庫,連線不同就沒轍。交易物件無法跨越連線使用。
ConnectionNotAssociate



🧭 實驗四: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
/// <summary>
/// 連線到相同 DB 並共用連線,成功儲存
/// </summary>
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();

//// 使用相同的連線建立Context
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 使用相同連線並共用交易物件,即使是不同實例,也能協同作業。
sameConnectionSuccessInsert



☘️ 結語

很多時候,我們並不是不懂交易怎麼運作,只是預設它「會幫我處理好」。

但事實是

  • 你用兩個 DbContext,就已經把自己放進一艘不確定的船;
  • 你跨資料庫,就已經進入需要特許航道的海域;
  • 你用了 BeginTransaction,卻讓它漂流在不同連線上,自然找不到彼此。

每一次踩坑後的 Rollback,都是一次「喔原來不能這樣」的醒悟。這篇文章不是要給出什麼神解法,反而是提醒我們:
EF Core 給了我們很大的彈性,但這份彈性,也需要我們主動去畫出界線、維持一致性。畢竟,程式的問題,多半不是出在「這樣寫可不可以」,而是「我們知不知道它底下怎麼跑」。就像一艘船能不能出港,不只看船造得漂不漂亮,還要看你知不知道洋流怎麼走。