EF Core 樂觀鎖的假象:BulkExtensions 讓 Rowversion 形同虛設
前言
你有沒有遇過這樣的情況:
- DB 有
Rowversion欄位 - EF Core Model 設定了
IsRowVersion().IsConcurrencyToken() - 但實際測試發現並發更新時,完全沒有任何衝突偵測?
這篇文章從一個真實的促購活動加價購功能出發,完整拆解這個坑是怎麼形成的。
案例背景
系統有一張 PromotionEngineSpecialPrice(活動價格表),記錄加價購活動下每個 SKU 的加購價格。
業務流程是:
- SCMAPIV2(後台 API)接收營運人員的批次更新請求
- 驗證通過後,呼叫 PromotionWebAPI 的
POST /api/cart-extra-purchase/batch-update - PromotionWebAPI 查詢現有價格記錄,再批次更新價格與排序
這個流程看起來合理,但有一個隱藏的並發問題。
問題一:Lost Update(更新遺失)
情境
兩個請求 A、B 同時針對同一個活動的相同 SKU 發起更新:
1 | T=0 A 查詢 DB → specialPrice { TypeValue: 100, rowversion: 0x001 } |
這就是 Lost Update,B 的寫入憑空消失。
問題二:DB 明明有 Rowversion,為何沒保護?
DB Schema 與 EF 設定都是正確的
PromotionEngineSpecialPrice 有 Rowversion 欄位:
1 | // PromotionEngineSpecialPrice.cs |
EF Core DbContext 也設定了樂觀鎖:
1 | // WebStoreDBContext.cs |
標準的 EF Core SaveChangesAsync 下,這樣的設定會生效:
1 | -- EF Core 產生的 UPDATE(正確) |
若 B 已先改掉 rowversion,A 的 UPDATE 影響 0 筆,EF 拋出 DbUpdateConcurrencyException,保護機制生效。
問題三:BulkExtensions 繞過樂觀鎖
實際的寫入方法長這樣:
1 | // SpecialPriceRepository.cs |
BulkSaveChangesAsync 是 EFCore.BulkExtensions 提供的方法,底層使用 SqlBulkCopy 或直接批次 SQL,預設不走標準 EF SaveChanges 流程,因此不會檢查 IsConcurrencyToken。
產生的 SQL 大約是:
1 | -- BulkExtensions 產生的 UPDATE(有問題) |
不管 rowversion 是否已被改動,UPDATE 永遠成功,最後寫入的人贏。
問題四:AsNoTracking 讓問題雪上加霜
讀取資料的方法:
1 | // SpecialPriceRepository.cs |
AsNoTracking() 讓查出來的 entity 不被 DbContext 追蹤原始值。
標準 EF 樂觀鎖的工作原理需要 DbContext 記住「查詢時的 rowversion 原始值」,之後 SaveChanges 才能帶進 WHERE 條件。AsNoTracking() 後手動 Attach 雖然 rowversion 欄位值仍在,但 BulkExtensions 根本不讀它。
完整問題鏈
1 | 設計意圖 實際執行 |
這條路徑上的其他問題
問題 A:BatchCreateAsync 沒有 await
1 | // CartExtraPurchaseController.cs 第 60 行 |
後果:
- DB 寫入失敗時,例外被靜默吞掉
- Caller 收到 200 OK,但資料根本沒進 DB
問題 B:Add/Update/Remove 三批次無原子性
1 | // 三個各自獨立的 try/catch |
後果:Add 失敗,但 Delete 仍執行 → 資料被刪除但新增沒成功,停在中間狀態。
問題 C:錯誤訊息寫死類型名稱
1 | // CartExtraPurchaseController.cs 第 101 行 |
如何正確使用 EF Core 樂觀鎖
方式一:使用標準 SaveChangesAsync
1 | // 正確:讓 EF 追蹤並帶入 rowversion WHERE 條件 |
方式二:BulkExtensions 加入 Rowversion 條件
EFCore.BulkExtensions 有設定可開啟 ConcurrencyToken 檢查:
1 | var bulkConfig = new BulkConfig |
方式三:DB 層加 UPDLOCK/ROWLOCK
若需要悲觀鎖:
1 | -- Stored Procedure 內加鎖 |
總結
| 層次 | 狀態 | 說明 |
|---|---|---|
| DB Schema | ✅ 有 Rowversion 欄位 | 基礎設施備好了 |
| EF Core 設定 | ✅ IsRowVersion().IsConcurrencyToken() |
EF 層也設定好了 |
| 讀取方式 | ⚠️ AsNoTracking() |
版本值有讀到,但 context 不追蹤原始值 |
| 寫入方式 | ❌ BulkSaveChangesAsync |
繞過 EF 標準流程,樂觀鎖失效 |
| 最終效果 | ❌ 完全沒有並發保護 | Lost Update 問題存在 |
核心教訓:
IsRowVersion().IsConcurrencyToken()只在 標準 EF CoreSaveChangesAsync路徑下有效。
只要使用 BulkExtensions、Raw SQL、Stored Procedure 等方式繞過 EF 標準流程,樂觀鎖就形同虛設。
有設定 ≠ 有保護。
相關程式碼位置
| 檔案 | 說明 |
|---|---|
PromotionEngineSpecialPrice.cs |
DB Model,含 Rowversion 欄位定義 |
WebStoreDBContext.cs 第 528 行 |
IsRowVersion().IsConcurrencyToken() 設定 |
SpecialPriceRepository.cs 第 75 行 |
BulkUpdateAsync 實作(問題根源) |
CartExtraPurchaseService.cs 第 147 行 |
GetListWithSku + BulkUpdateAsync 呼叫鏈 |
CartExtraPurchaseController.cs 第 60 行 |
BatchCreateAsync 缺少 await |
PromotionEngineService.cs 第 991 行 |
Add/Update/Remove 三批次無原子性 |


