交易與並行控制
什麼是交易(Transaction)?
想像你在銀行轉帳:
- 從 A 帳戶扣 1000 元
- 在 B 帳戶加 1000 元
這兩步必須同時成功或同時失敗。如果扣了 A 的錢但 B 沒加到,錢就憑空消失了! 這就是交易(Transaction)要解決的問題。
ACID 特性
| 特性 | 英文 | 說明 | 銀行比喻 |
|---|---|---|---|
| 原子性 | Atomicity | 全部成功或全部失敗 | 轉帳要嘛成功,要嘛完全沒動 |
| 一致性 | Consistency | 交易前後資料都合法 | 總金額不會改變 |
| 隔離性 | Isolation | 交易之間互不干擾 | 你轉帳時別人看不到中間狀態 |
| 持久性 | Durability | 完成後永久保存 | 轉完帳重開機錢不會消失 |
EF Core 中的交易
隱式交易(預設)
// SaveChanges 自動包在交易裡
var student = new Student { Name = "小賢" }; // 建立學生
db.Students.Add(student); // 加入追蹤
var course = new Course { CourseName = "C#" }; // 建立課程
db.Courses.Add(course); // 加入追蹤
await db.SaveChangesAsync(); // 一次送出 → 自動包在交易裡
// 如果任何一個 INSERT 失敗,全部都會回退(Rollback)
明確交易
// 需要跨多次 SaveChanges 的交易
using var transaction = await db.Database // 開始一個交易
.BeginTransactionAsync(); // 取得交易物件
try
{
// 步驟 1:從 A 帳戶扣款
var accountA = await db.Accounts // 查詢 A 帳戶
.FirstAsync(a => a.Id == 1); // 取得帳戶資料
accountA.Balance -= 1000; // 扣 1000 元
await db.SaveChangesAsync(); // 儲存(但交易尚未提交)
// 步驟 2:在 B 帳戶加款
var accountB = await db.Accounts // 查詢 B 帳戶
.FirstAsync(a => a.Id == 2); // 取得帳戶資料
accountB.Balance += 1000; // 加 1000 元
await db.SaveChangesAsync(); // 儲存(但交易尚未提交)
// 兩步都成功,提交交易
await transaction.CommitAsync(); // 提交交易(真正寫入資料庫)
}
catch (Exception ex) // 任何步驟出錯
{
await transaction.RollbackAsync(); // 回退所有變更
Console.WriteLine($"交易失敗: {ex.Message}"); // 記錄錯誤
throw; // 重新拋出例外
}
Dapper 中的交易
using var conn = new SqlConnection(connectionString); // 建立連線
await conn.OpenAsync(); // 開啟連線
using var transaction = conn.BeginTransaction(); // 開始交易
try
{
// 步驟 1:扣款
await conn.ExecuteAsync( // 執行 SQL
"UPDATE Accounts SET Balance = Balance - @Amount WHERE Id = @Id", // 扣款語句
new { Amount = 1000, Id = 1 }, // 參數
transaction // 傳入交易物件
);
// 步驟 2:加款
await conn.ExecuteAsync( // 執行 SQL
"UPDATE Accounts SET Balance = Balance + @Amount WHERE Id = @Id", // 加款語句
new { Amount = 1000, Id = 2 }, // 參數
transaction // 傳入同一個交易物件
);
transaction.Commit(); // 提交交易
}
catch
{
transaction.Rollback(); // 回退交易
throw; // 重新拋出
}
隔離等級(Isolation Level)
不同的隔離等級決定了交易之間的可見性。
| 隔離等級 | 髒讀 | 不可重複讀 | 幻讀 | 效能 |
|---|---|---|---|---|
| Read Uncommitted | ✅ 可能 | ✅ 可能 | ✅ 可能 | 最快 |
| Read Committed(預設) | ❌ 防止 | ✅ 可能 | ✅ 可能 | 快 |
| Repeatable Read | ❌ 防止 | ❌ 防止 | ✅ 可能 | 中等 |
| Serializable | ❌ 防止 | ❌ 防止 | ❌ 防止 | 最慢 |
名詞解釋:
- 髒讀:讀到別人還沒提交的資料(可能被回退)
- 不可重複讀:同一交易中,兩次讀取同一列得到不同結果
- 幻讀:同一交易中,兩次查詢得到不同數量的列
// 在 EF Core 中設定隔離等級
using var transaction = await db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable // 最嚴格的隔離等級
);
// ⚠️ Serializable 最安全但最慢,只在真正需要時使用
-- 在 SQL 中設定隔離等級
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 設定隔離等級
BEGIN TRANSACTION; -- 開始交易
SELECT * FROM Accounts WHERE Id = 1; -- 查詢帳戶
UPDATE Accounts SET Balance = Balance - 1000 WHERE Id = 1; -- 扣款
COMMIT TRANSACTION; -- 提交交易
樂觀並行控制 vs 悲觀並行控制
樂觀並行控制(Optimistic Concurrency)
假設衝突很少發生,先做再說,提交時再檢查是否有衝突。
// 在 Entity 中加入 RowVersion(並行權杖)
public class Product // 產品 Entity
{
public int Id { get; set; } // 主鍵
public string Name { get; set; } = ""; // 產品名稱
public decimal Price { get; set; } // 價格
public int Stock { get; set; } // 庫存
[Timestamp] // 標記為並行權杖
public byte[] RowVersion { get; set; } = null!; // 每次更新自動變更
}
// Fluent API 設定
modelBuilder.Entity<Product>() // 設定 Product Entity
.Property(p => p.RowVersion) // RowVersion 屬性
.IsRowVersion(); // 標記為列版本
// 使用時自動檢查並行衝突
try
{
var product = await db.Products // 查詢產品
.FirstAsync(p => p.Id == 1); // 取得 ID=1 的產品
product.Stock -= 1; // 減少庫存
await db.SaveChangesAsync(); // 儲存時會檢查 RowVersion
}
catch (DbUpdateConcurrencyException ex) // 並行衝突例外
{
// 有人在你之前修改了這筆資料!
var entry = ex.Entries.Single(); // 取得衝突的 Entity
var dbValues = await entry.GetDatabaseValuesAsync(); // 取得資料庫最新值
// 決定要:1. 用資料庫的值 2. 用你的值 3. 合併
entry.OriginalValues.SetValues(dbValues!); // 用資料庫最新值重試
await db.SaveChangesAsync(); // 重新儲存
}
悲觀並行控制(Pessimistic Concurrency)
假設衝突經常發生,先鎖定資源再操作。
-- 在 SQL 中使用悲觀鎖定
BEGIN TRANSACTION; -- 開始交易
SELECT * FROM Products WITH (UPDLOCK, ROWLOCK) -- 鎖定這一列
WHERE Id = 1; -- 其他交易無法修改這列
UPDATE Products SET Stock = Stock - 1 -- 更新庫存
WHERE Id = 1; -- 更新指定產品
COMMIT TRANSACTION; -- 提交並釋放鎖定
// 在 EF Core 中使用原始 SQL 實現悲觀鎖定
using var transaction = await db.Database // 開始交易
.BeginTransactionAsync();
var product = await db.Products // 使用原始 SQL 查詢並鎖定
.FromSqlRaw("SELECT * FROM Products WITH (UPDLOCK) WHERE Id = {0}", 1) // 加鎖
.FirstAsync(); // 取得產品
product.Stock -= 1; // 減少庫存
await db.SaveChangesAsync(); // 儲存變更
await transaction.CommitAsync(); // 提交交易並釋放鎖
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:長時間持有交易
// ❌ 在交易中做耗時操作
using var transaction = await db.Database.BeginTransactionAsync(); // 開始交易
var data = await db.Products.ToListAsync(); // 查詢(鎖定資源)
await SendEmailAsync(data); // ❌ 發送 Email(可能要好幾秒!)
await CallExternalApiAsync(); // ❌ 呼叫外部 API(更慢!)
await db.SaveChangesAsync(); // 儲存
await transaction.CommitAsync(); // 提交
// 在整段期間,其他交易都被擋住了!可能導致 Timeout
// ✅ 交易中只做資料庫操作,其他的放在交易外面
var data = await db.Products.ToListAsync(); // 先查詢資料(交易外)
using var transaction = await db.Database.BeginTransactionAsync(); // 開始交易
// 只做必要的資料庫操作
product.Stock -= 1; // 更新資料
await db.SaveChangesAsync(); // 儲存
await transaction.CommitAsync(); // 提交(快速完成)
// 交易結束後再做其他操作
await SendEmailAsync(data); // 發送 Email(交易外)
await CallExternalApiAsync(); // 呼叫外部 API(交易外)
❌ 錯誤 2:死鎖(Deadlock)
// ❌ 兩個交易互相等待對方釋放鎖定
// 交易 A:先鎖 Products → 再鎖 Orders
// 交易 B:先鎖 Orders → 再鎖 Products
// 結果:A 等 B 釋放 Orders,B 等 A 釋放 Products → 死鎖!
// ✅ 所有交易都按照相同的順序存取資源
// 交易 A 和 B 都先鎖 Orders → 再鎖 Products
// 這樣就不會產生循環等待
❌ 錯誤 3:忘記 Rollback
// ❌ 沒有 try-catch,異常時交易不會回退
var transaction = await db.Database.BeginTransactionAsync(); // 開始交易
await db.SaveChangesAsync(); // 如果這裡拋出例外...
await transaction.CommitAsync(); // 這行不會執行
// 交易會一直掛著,佔用資源,直到連線超時
// ✅ 用 using + try-catch 確保交易正確處理
using var transaction = await db.Database.BeginTransactionAsync(); // using 確保釋放
try
{
await db.SaveChangesAsync(); // 儲存變更
await transaction.CommitAsync(); // 提交交易
}
catch
{
await transaction.RollbackAsync(); // 明確回退
throw; // 重新拋出例外
}
💡 重點整理
| 概念 | 說明 |
|---|---|
| ACID | 原子性、一致性、隔離性、持久性 |
| SaveChanges | EF Core 自動包在隱式交易中 |
| BeginTransaction | 明確開始一個交易 |
| Isolation Level | 控制交易之間的可見性 |
| 樂觀並行 | 用 RowVersion 檢查衝突 |
| 悲觀並行 | 用 UPDLOCK 鎖定資源 |
| 死鎖 | 兩個交易互相等待 → 統一存取順序避免 |