1.常見的併發處理策略
要了解如何處理併發,就要知道併發的一般處理策略
悲觀併發策略
悲觀併發策略,正如其名,它指的是對資料被外界(包括本系統當前的其他事務,以及來自外部系統的事務處理)修改持保守悲觀的態度,因此,在整個資料處理過程中,將資料處於鎖定狀態。悲觀併發策略大多數情況下依靠資料庫的鎖機制實現,以保證操作最大程度的獨佔性。但隨之而來的就是資料庫效能的巨大開銷,特別是對長事務而言,這樣的開銷在大量的併發情況下往往無法承受。
樂觀併發策略
樂觀併發策略,一般是基於資料版本 Version記錄機制實現。何謂資料版本?即為資料增加一個版本標識,在基於資料庫表的版本解決方案中,一般是透過為資料庫表增加一個 “version” 欄位來實現.讀取出資料時,將此版本號一同讀出,之後更新時,對此版本號加一。此時,將提交資料的版本資料與資料庫表對應記錄的當前版本資訊進行比對,如果提交的資料版本號大於資料庫表當前版本號,則予以更新,否則認為是過期資料。需要注意的是,樂觀併發策略機制往往基於系統中的資料儲存邏輯,因此也具備一定的侷限性.
本篇就是講解,如何在我們的Entity Framework Core中來使用和自定義我們的併發策略
2.Entity Framework Core併發令牌
要使用Entity Framework Core中的併發策略,就需要使用我們的併發令牌(ConcurrencyCheck)
在Entity Framework Core中,併發的預設處理方式是無視併發衝突的,任何修改語句在條件符合的情況下,都可以修改成功.
在高併發的情況下這種處理方式,肯定會給我們的資料庫帶來很多髒資料,所以,Entity Framework Core提供了併發令牌(ConcurrencyCheck)這個特性.
如果一個屬性被配置為併發令牌,則EF將在儲存這條記錄時,會檢查沒有其他使用者修改過資料庫中的這個屬性的值。EF使用了樂觀併發策略,這意味著它將假定值沒有改變,並嘗試儲存資料,但如果發現值已更改,則丟擲異常。
舉個例子,我們有一個使用者類(User),我們配置 User中的 Name為併發令牌。這意味著,如果一個使用者試圖儲存一個有些變化的 User,但另一個使用者已經改變了 Name
那麼將丟擲一個異常。這在應用中一般是可取的,以便我們的應用程式可以提示使用者,在儲存他們的改變之前,以確保此記錄仍然代表同一個姓名的人。
2.1併發令牌在EF中工作的原理
當我們配置User中的Name為令牌的時候,EF會將併發令牌包含在Where、Update或delete命令的子句中並檢查受影響的行數來實現驗證。如果併發令牌仍然匹配,則一行將被更新。如果資料庫中的值已更改,則不會更新任何行。
比如,當我們設定Name為併發令牌,然後透過ID來修改User的PassWord的時候,EF會生成如下的修改語句:
UPDATE [User] SET [PassWord] = @p1 WHERE [ID] = @p0 AND [Name] = @p2;
當然,這時候,Name不匹配了,受影響的行數返回為0.
2.2併發令牌的使用約定
屬性預設不被配置為併發令牌。
2.3併發令牌的使用方式
1.直接使用特性,如下配置UserName為併發令牌:
public partial class UserTable { public int Id { get; set; } [ConcurrencyCheck] public string UserName { get; set; } public string PassWord { get; set; } public int? ClassId { get; set; } }
2.使用FluentAPI配置屬性為併發令牌
class MyContext : DbContext { public DbSet<UserTable> People { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<UserTable>() .Property(p => p.UserName) .IsConcurrencyToken(); } }
以上2種方式,效果是一樣的.
2.4使用時間戳和行級版本號
我們知道,SQL Server給我們提供了時間戳的屬性(當然,幾乎所有的關聯式資料庫都有這個).下面舉個SQL Server的例子
我們加一個時間戳欄位為TimestampV,加上特性Timestamp,實體程式碼如下:
public partial class UserTable { public int Id { get; set; } public string UserName { get; set; } public string PassWord { get; set; } public int? ClassId { get; set; } public ClassTable Class { get; set; } [Timestamp] public byte[] TimestampV { get; set; } }
CodeFrist生成的表如下:
自動幫我們生成的Timestamp型別的一個欄位.
配置時間戳屬性的方式也有2種,上面已經說了一種..特性的..
同樣我們也可以使用Fluent API配置屬性為時間戳,程式碼如下:
class MyContext : DbContext { public DbSet<UserTable> Blogs { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<UserTable>() .Property(p => p.TimestampV) .ValueGeneratedOnAddOrUpdate() .IsConcurrencyToken(); } }
3.如何根據需求自定義處理併發衝突
上面,我們已經配置好了需要併發處理的表,也配置好了相關的特性,下面我們就來講講如何使用它.
使用之前,我們先來了解一下,併發過程中所產生的3個值,也是我們需要處理的3個值
1.當前值是應用程式嘗試寫入資料庫的值。
2.原始值是在進行任何編輯之前最初從資料庫檢索的值。
3.資料庫值是當前儲存在資料庫中的值。
當我們配置好上面的併發令牌時,在EF執行SaveChanges()操作併產生併發的時候,我們會得到DbUpdateConcurrencyException的異常資訊,(注意:在不配置併發令牌時,這個異常一般不會觸發)
前面,我們已經講過樂觀併發策略是一種效能較高,也比較實用的處理方式,所以我們就透過時間戳來處理這個併發的問題.
示例測試程式碼如下:
public void Test() { //重新建立資料庫,並新增一條資料 using (var context = new School_TestContext()) { context.Database.EnsureDeleted(); context.Database.EnsureCreated(); context.UserTable.Add(new UserTable { UserName = "John", PassWord = "Doe" }); context.SaveChanges(); } using (var context = new School_TestContext()) { // 修改id為1的使用者名稱稱 var person = context.UserTable.Single(p => p.Id == 1); person.UserName = "555-555-5555"; // 直接透過訪問資料庫來修改同一條資料 (這裡是為了模擬併發) context.Database.ExecuteSqlCommand("UPDATE dbo.UserTable SET UserName = 'Jane' WHERE ID = 1"); try { //嘗試儲存修改 int a = context.SaveChanges(); } //獲取併發異常 catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { if (entry.Entity is UserTable) { var databaseEntity = context.UserTable.AsNoTracking().Single(p => p.Id == ((UserTable)entry.Entity).Id); var databaseEntry = context.Entry(databaseEntity); //當前上下文時間戳 var date = ConvertToTimeSpanString(entry.Property("TimestampV").CurrentValue); var dateint = Int32.Parse(date, System.Globalization.NumberStyles.HexNumber); //資料庫時間戳 var datebase = ConvertToTimeSpanString(databaseEntry.Property("TimestampV").CurrentValue); var dateint2 = Int32.Parse(datebase, System.Globalization.NumberStyles.HexNumber); //如果當前上下文時間戳與資料庫相同,或者更加新,則使用當前 if (dateint >= dateint2) { foreach (var property in entry.Metadata.GetProperties()) { //當前值 var proposedValue = entry.Property(property.Name).CurrentValue; //原始值 var originalValue = entry.Property(property.Name).OriginalValue; //資料庫值 var databaseValue = databaseEntry.Property(property.Name).CurrentValue; //更新當前值 entry.Property(property.Name).CurrentValue = proposedValue; //更新原始值來保證修改成功 entry.Property(property.Name).OriginalValue = databaseEntry.Property(property.Name).CurrentValue; // 嘗試重新儲存資料 int aa = context.SaveChanges(); } } } else { throw new NotSupportedException("無法處理併發," + entry.Metadata.Name); } } } } }
執行這段程式碼,會發現,符合我們樂觀併發策略的要求.
值為最後修改的UserName,為Jane,如圖:
解釋一下,為何最終結果為Jane.
首先,我們新增了一條UserName為John的資料,我們在上下文中修改它為"555-555-5555",
這時候,產生併發,另一個上下文在這個SaveChang之前,就執行完成了,把值修改為了Jane,所以EF透過併發令牌發現匹配失敗.則會觸發異常.
在異常中,我們將當前上下文的版本號和資料庫現有的版本號進行對比,發現當前上下文的版本號為過期資料,則不更新,並返回失敗.
請仔細看程式碼中的註釋.
注意:這裡的例子是根據樂觀併發處理策略要進行處理的.你可以根據你的業務,來任意處理當前值,原始值和資料庫值,選擇你需要的值儲存.