昨天提到 EF Core 的 DbContext 有內建資料快取,與資料庫真實狀況可能存在落差,另外也提到,當兩個 DbContext 更新同一筆資料,則視 SaveChanges() 呼叫時機,以後者為準。

EF Core 能追蹤資料修改狀況,產生 UPDATE 指令時只會更新有修改的欄位,若兩個 DbContext 修改的欄位沒重疊,倒不至於發生資料覆蓋的狀況,但還是有點後遺症 - 資料庫的真實結果可能與使用者認知存在誤差。例如:使用者 A 叫出客戶資料,保留手機號碼改了地址跟Email,存檔的當下使用者 B 也更新了同一筆的手機號碼,兩人的儲存過程都沒錯誤,要更新的資料也沒被對方蓋掉。但使用者 A 以為寫入資料庫的是手機不改,地址與 Email 異動,事實上手機號碼卻是被使用者 B 改過的版本。在一些情境下,這個結果被視為合理且可被接受;但有些場合,會希望遇到這種資料更新衝突時要發出警告,避免使用者對資料有錯誤認知或違背特定商業邏輯。

常見更新衝突的處理心法有兩種:Pessimistic Concurrency (鎖定大法) 與 Optimistic Concurrency。

Pessimistic Concurrency 的觀念很簡單,鎖好鎖滿就對了~ 打從使用者打算編修資料就鎖定該筆資料不誰任何人修改(甚至讀取),就不用擔心更新時打架。但在高使用量環境鎖定資料的成本很高(想使用同一資源的其他人會被卡住,直到鎖定被釋放),實務上不太能被接受。

實現 Optimistic Concurrency 則有幾種策略:

  1. 追蹤哪些欄位被修改,只更新有異動的欄位
    即前篇文章最後範例,只要更新欄位沒重疊,除了整體資料結果與使用者的認知有異,不會有資料損失。但若更新到相同欄位,還是免不了發生資料覆寫。
  2. 後更新者覆寫前者寫入的資料
    「後者為準」(Last In Wins)原則,也稱為「客戶端為準」(Client Wins),基本不需要特別做什麼,系統本來就會依此運作。
  3. 儲存資料時如發現資料被他人異動過,即禁止寫入
    也稱為「儲存端為准」(Store Wins)原則,若使用者從讀取、編輯到寫入資料庫期間資料有被他人修改過,系統將顯示錯誤訊息,要求使用者取得最新資料重新編輯。

前一篇文章實驗結果可知,EF Core DbContext 預設採用前兩點策略,會追蹤欄位並只修改有更動的部分:

若不同 DbContext 同時修改同一欄位,則以後者為準,並不觸發錯誤:

若要實現第三種「若資料被別人改過即放棄寫入」策略,EF Core 提供以下做法:

  1. 要求 EF Core 在 Update 或 Delete 時在 WHERE 加入額外比對條件,確認指定欄位內容與當初讀取時是否一致,若有不同即代表資料被人改過。這個做法的缺點是更新或刪除時,WHERE 條件必須傳入額外比對用的資料參數(有時可能是好幾 KB 的文字、JSON 或 XML),不利效能。
  2. 在資料表加入 RowVersion 欄位,每次儲存後自動跳號或改變,儲存時比對 RowVersion 欄位是否與當被讀取時相同,若不同即表示資料被其他人修改過,但這需要資料庫有支援 RowVerion/Timestamp 才能實現。

先看方法一,這其實在 EF Core 筆記 2 - Model 設計 提過,設計 Enity Model 時在要額外比對的欄位加上 [ConcurrencyCheck] 或使用 Fluent API modelBuilder.Entity<DailyRecord>().Property(a => a.User).IsConcurrencyToken();。沿續上面同時更新 User 的案例,我設定 User 屬性為 IsConcurrencyToken(),dbCtx2.SaveChanges() 時將觸發 DbUpdateConcurrencyException。

RowVersion 要 SQL 資料庫支援才能測試,這裡不得不要表揚 Git 跟 EF Core Migrations。前幾個測試為了做到快速蓋檔還原 DB,我將 RazorPageCRUDExample 專案從 SQL LocalDB 版換成 SQLite 版(工程不大,改兩三行程式並重跑 dot ef migration add 而已),並在 Git 開了 Branch 做修改。現在要換回 LocalDB 測試,只需切回 master Branch,不用三秒鐘即回到從前,開開心心寫 Code。能隨心所欲在不同開發情境間切換,這等愉快的開發體驗大家一定要來感受一下呀。

換回 SQL LocalDB 後,下一步是在 DailyRecord Entity 加入 RowVersion 屬性並加註 [Timestamp],然後跑 dotnet ef migrations add AddNewVersion,EF Core 會比較 Schema 變化產生升級專用元件:

重跑程式,因為先前我有設自動升級:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env,
                //透過建構式參數取得 DBContext (依賴注入架構的標準做法)
                JournalDbContext dbContext)
{
    //檢查資料表是否已經存在,若不存在自動建立;
    //若資料表存在但版本太舊符則自動更新。
    //在正式環境自動更新資料庫有點可怕,我加了限定LocalDB執行的安全鎖
    if (dbContext.Database.GetDbConnection().ConnectionString.Contains("MSSQLLocalDB"))
    {
        dbContext.Database.Migrate();
    }
    //...略...

網站一執行,SQL 資料表已自動新增好 RowVersion 欄位:(別噓別噓,我知道正式環境不可能這樣玩,請參考:淺談正式環境資料庫建立與換版)

簡單實測,RowVersion 一樣能在資料被第三方異動阻止更新:

補充說明:在網頁編輯介面要應用 RowVersion,記得要將 RowVersion 埋入隱藏欄位,儲存時一併帶入 ,細節請參考微軟官方教學

【參考資料】

Tips of how to handle update conflict in EF Core DbContext.


Comments

# by Ho.Chun

Q1. 請問 RowVersion 加入 Table 後,EF Core 自己就會去判斷了嗎 ? Q2. 欄位名稱一定要叫 RowVersion 嗎 ?

# by Jeffrey

to Ho.Chun,C# 端的欄位名稱沒特別規定,但要註明 [Timestamp] 且資料庫有支援。參考:https://learn.microsoft.com/en-us/ef/core/modeling/concurrency?tabs=data-annotations#timestamprowversion

Post a comment