使用EF框架遇到並發時,一般采取樂觀並發控制。
1支持並發檢驗
為支持並發檢驗,需要對實體進行額外的設置。默認情況下是不支持並發檢驗的。有以下兩種方式:
| 方式名稱 |
說明 |
| 時間戳注解/行版本 |
使用TimestampAttribute特性,實體的屬性必須是byte數組類型 |
| 非時間戳注解 |
使用ConcurrencyCheckAttribute |
| Fluent API |
使用StringPropertyConfiguration.IsConcurrencyToken方法 |
注釋
1)時間戳注解
- 一個類只能有一個屬性可以配置為TimeStamp特性。
- 任何時候行內數據被修改時,數據庫都會自動為此屬性創建新值。
- 只要對相應的表執行更新操作,EF框架就會執行並發檢測。
例:
[Timestamp] public byte[] RowVersion { get; set; }
2)非時間戳注解
- 此方式,是對表的一個或多個字段進行並發檢測
- 當更改一行時,EF框架就會執行並發檢測。
例:
[ConcurrencyCheck] public string Email { get; set; }
3)Fluent API
- 此方式,是對表的一個或多個字段進行並發檢測
- 當更改一行時,EF框架就會執行並發檢測。
例如:
public static void Set(DbModelBuilder modelBuilder) { //其他配置 modelBuilder.Entity<User>().Property(u => u.Email) .IsRequired() .IsUnicode(false) .HasMaxLength(100) .IsConcurrencyToken(); }
2樂觀並發控制
2.1使用數據庫中的數據(服務端勝)
使用DbEntityEntry.Reload方法加載數據庫中的數據而不是使用當前實體的值。
例:
using (CustomDbContext context = new CustomDbContext()) { var user = context.Users.Find(1); user.Email = "eftxt8326@163.com"; bool saveFailed; do { saveFailed = false; try { context.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { saveFailed = true; ex.Entries.Single().Reload(); } } while (saveFailed); }
分析:
當發生並發沖突時,context.SaveChanges();這行代碼拋出異常DbUpdateConcurrencyException ,執行catch塊的代碼,ex.Entries.Single().Reload()這行代碼作用是從數據庫取出對應的一條記錄然后用這條記錄對當前實體賦值,又由於saveFailed = true,do語句塊又執行一次,調用context.SaveChanges();將數據保存到數據庫中,若這次執行do語句塊,不拋出異常,由於 saveFailed = false,所以循環結束。
2.2使用當前實體數據(客戶端勝)
使用當前實體數據覆蓋數據庫中的數據。
例:
using (CustomDbContext context = new CustomDbContext()) { var user = context.Users.Find(1); user.Email = "eftxt8326@163.com"; bool saveFailed; do { saveFailed = false; try { context.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { saveFailed = true; var entry = ex.Entries.Single(); entry.OriginalValues.SetValues(entry.GetDatabaseValues()); } } while (saveFailed); }
分析:
當發生並發沖突時,拋出DbUpdateConcurrencyException 異常,執行catch 塊,ex.Entries.Single()這條語句的作用是從當前實體集中取出唯一的一個實體,然后調用DbEntityEntry.GetDatabaseValues,在數據庫中查找這條記錄,若能夠找到這條記錄,返回當前值的屬性值集合。
entry.OriginalValues.SetValues這條語句的作用是:DbEntityEntry.OriginalValues指的是最后一次訪問數據庫時獲得那條記錄,調用DbPropertyValues.SetValues方法用一個詞典給另一個詞典賦值,entry.OriginalValues.SetValues(entry.GetDatabaseValues());是將當前數據庫中的值賦給從數據庫最后一次查出的值。由於saveFailed = true所以再次執行do語句塊,將當前實體值寫入數據庫。
2.3結合當前實體值和數據庫中的值
using (CustomDbContext context = new CustomDbContext()) { var user = context.Users.Find(1); user.Email = "eftxt8326@163.com"; bool saveFailed; do { saveFailed = false; try { context.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { saveFailed = true; var entry = ex.Entries.Single(); //獲得當前實體值 var currentValues = entry.CurrentValues; //獲得數據庫中的值 var databaseValues = entry.GetDatabaseValues(); //拷貝一份 var resolvedValues = databaseValues.Clone(); //對數據加工處理 HaveUserResolveConcurrency(currentValues, databaseValues, resolvedValues); entry.OriginalValues.SetValues(databaseValues); entry.CurrentValues.SetValues(resolvedValues); } } while (saveFailed); } public void HaveUserResolveConcurrency(DbPropertyValues currentValues, DbPropertyValues databaseValues, DbPropertyValues resolvedValues) { //對數據加工處理 }
也可以使用DbPropertyValues的public object ToObject()方法
using (CustomDbContext context = new CustomDbContext()) { var user = context.Users.Find(1); user.Email = "eftxt8326@163.com"; bool saveFailed; do { saveFailed = false; try { context.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { saveFailed = true; //獲得當前實體值 var entry = ex.Entries.Single(); //獲得數據庫中的值 var databaseValues = entry.GetDatabaseValues(); var databaseValuesAsBlog = (User)databaseValues.ToObject(); //拷貝一份 var resolvedValuesAsBlog = (User)databaseValues.ToObject(); //對數據加工處理 HaveUserResolveConcurrency((User)entry.Entity, databaseValuesAsBlog, resolvedValuesAsBlog); entry.OriginalValues.SetValues(databaseValues); entry.CurrentValues.SetValues(resolvedValuesAsBlog); } } while (saveFailed); } public void HaveUserResolveConcurrency(User entity, User databaseValues, User resolvedValues) { //對數據加工處理 }
3觀察並發現象
本次實驗選擇觀察“客戶端勝”這種策略,選取這種策略的原因在不但可以通過試驗觀察到並發檢測的情況,還可以觀察到調用DbEntityEntry.GetDatabaseValues()、DbEntityEntry.OriginalValues、DbEntityEntry.CurrentValues的返回值,有助於深入理解這些概念
實體:使用ConcurrencyCheck特性標記實體屬性
public class User { public int Id { get; set; } /// <summary> /// 賬號 /// </summary> public string Account { get; set; } /// <summary> /// 郵箱 /// </summary> [ConcurrencyCheck] public string Email { get; set; } /// <summary> /// 昵稱 /// </summary> public string Nickname { get; set; } /// <summary> /// 頭像 /// </summary> public string AvatarId { get; set; } /// </summary> /// 收藏 /// </summary> public virtual ICollection<CollectionUser> CollectionUsers { get; set; } /// <summary> /// 記錄插入時間 /// </summary> public DateTime InsertTime { get; set; } /// <summary> /// 記錄修改時間 /// </summary> public DateTime UpdateTime { get; set; } }
更新表users的Email字段:
為了可以觀察到並發現象,采用多線程,測試發現,雙核四線程處理器,兩個並行任務,很難捕捉到並發現象;當並行任務數為三個以上時,可以很輕易地發現並發現象。同時我們會打印執行的SQL,來說明並發檢測所依賴的基本原理。
public void ConALL() { var p = new ParallelOptions(); p.MaxDegreeOfParallelism = 4; Parallel.Invoke(p,() => { ConM("1@163.com"); }, () => { ConM("2@163.com"); }, () => { ConM("3@163.com"); }); } public void ConM(string s) { using (CustomDbContext context = new CustomDbContext()) { var user = context.Users.Find(1); user.Email = s; bool saveFailed; do { saveFailed = false; try { context.SaveChanges(); Trace.WriteLine(string.Format("【正常線程{1}】數據庫中原值:{0}", user.Email, s)); Trace.WriteLine(string.Format("【正常線程{1}】客戶端傳值:{0}", s, s)); } catch (DbUpdateConcurrencyException ex) { saveFailed = true; var entry = ex.Entries.Single(); var databaseValues = entry.GetDatabaseValues(); string em = databaseValues["Email"].ToString(); string or = entry.OriginalValues["Email"].ToString(); Trace.WriteLine(string.Format("【線程{1}】數據庫中原值:{0}", user.Email, s)); Trace.WriteLine(string.Format("【線程{1}】客戶端傳值:{0}", s, s)); Trace.WriteLine(string.Format("【線程{1}】DbEntityEntry.GetDatabaseValues:{0}", em, s)); Trace.WriteLine(string.Format("【線程{1}】DbEntityEntry.OriginalValues:{0}", or, s)); entry.OriginalValues.SetValues(databaseValues); } } while (saveFailed); } }
查看當前Mysql中的users表Email字段值為:1@163.com
執行程序,並記錄結果:
執行的SQL
SELECT
`Extent1`.`Id`
`Extent1`.`Account`
`Extent1`.`Email`
`Extent1`.`Nickname`
`Extent1`.`AvatarId`
`Extent1`.`InsertTime`
`Extent1`.`UpdateTime`
FROM `Users` AS `Extent1`
WHERE `Extent1`.`Id` = @p0 LIMIT 2
-- p0: '1' (Type = Int32)
-- Executing at 2018/3/30 17:04:20 +08:00
-- Completed in 9 ms with result: EFMySqlDataReader
UPDATE `Users` SET `Email`=@gp1 WHERE (`Id` = 1) AND (`Email` = @gp2)
-- @gp1: '2@163.com' (Type = String IsNullable = false Size = 9)
-- @gp2: '1@163.com' (Type = String IsNullable = false Size = 9)
-- Executing at 2018/3/30 17:04:21 +08:00
-- Completed in 3 ms with result: 1
SELECT
`Extent1`.`Id`
`Extent1`.`Account`
`Extent1`.`Email`
`Extent1`.`Nickname`
`Extent1`.`AvatarId`
`Extent1`.`InsertTime`
`Extent1`.`UpdateTime`
FROM `Users` AS `Extent1`
WHERE `Extent1`.`Id` = @p0 LIMIT 2
SELECT
`Extent1`.`Id`
`Extent1`.`Account`
`Extent1`.`Email`
`Extent1`.`Nickname`
`Extent1`.`AvatarId`
`Extent1`.`InsertTime`
`Extent1`.`UpdateTime`
FROM `Users` AS `Extent1`
WHERE `Extent1`.`Id` = @p0 LIMIT 2
SELECT
`Extent1`.`Id`
`Extent1`.`Account`
`Extent1`.`Email`
`Extent1`.`Nickname`
`Extent1`.`AvatarId`
`Extent1`.`InsertTime`
`Extent1`.`UpdateTime`
FROM `Users` AS `Extent1`
WHERE `Extent1`.`Id` = @p0 LIMIT 2
-- p0: '1' (Type = Int32)
-- p0: '1' (Type = Int32)
-- p0: '1' (Type = Int32)
-- Executing at 2018/3/30 17:06:12 +08:00
-- Executing at 2018/3/30 17:06:12 +08:00
-- Executing at 2018/3/30 17:06:12 +08:00
-- Completed in 8 ms with result: EFMySqlDataReader
-- Completed in 8 ms with result: EFMySqlDataReader
-- Completed in 8 ms with result: EFMySqlDataReader
UPDATE `Users` SET `Email`=@gp1 WHERE (`Id` = 1) AND (`Email` = @gp2)
UPDATE `Users` SET `Email`=@gp1 WHERE (`Id` = 1) AND (`Email` = @gp2)
-- @gp1: '3@163.com' (Type = String IsNullable = false Size = 9)
-- @gp1: '1@163.com' (Type = String IsNullable = false Size = 9)
-- @gp2: '2@163.com' (Type = String IsNullable = false Size = 9)
-- Executing at 2018/3/30 17:06:12 +08:00
-- @gp2: '2@163.com' (Type = String IsNullable = false Size = 9)
-- Executing at 2018/3/30 17:06:12 +08:00
-- Completed in 3 ms with result: 1
-- Completed in 3 ms with result: 0
SELECT
`Limit1`.`Id`
`Limit1`.`Account`
`Limit1`.`Email`
`Limit1`.`Nickname`
`Limit1`.`AvatarId`
`Limit1`.`InsertTime`
`Limit1`.`UpdateTime`
FROM (SELECT
`Extent1`.`Id`
`Extent1`.`Account`
`Extent1`.`Email`
`Extent1`.`Nickname`
`Extent1`.`AvatarId`
`Extent1`.`InsertTime`
`Extent1`.`UpdateTime`
FROM `Users` AS `Extent1`
WHERE `Extent1`.`Id` = @p0 LIMIT 2) AS `Limit1`
-- p0: '1' (Type = Int32)
-- Executing at 2018/3/30 17:06:12 +08:00
-- Completed in 1 ms with result: EFMySqlDataReader
UPDATE `Users` SET `Email`=@gp1 WHERE (`Id` = 1) AND (`Email` = @gp2)
-- @gp1: '1@163.com' (Type = String IsNullable = false Size = 9)
-- @gp2: '3@163.com' (Type = String IsNullable = false Size = 9)
-- Executing at 2018/3/30 17:06:14 +08:00
-- Completed in 0 ms with result: 1
分析SQL
日志中出現Completed in 0 ms with result: 0,這說明某一次更新任務是失敗的,這應該就出現並發更新的那一次,由於創建了三個並行的任務,所以從打印的日志中比較難以分辨是哪兩次更新時發生並發,但是可以通過后面觀察打印變量值來判斷。這里的日志信息還展示了每條SQL執行的時。
觀察上面的SQL語句,發現每個UPDATE 語句都有一個WHERE條件,尤為特別的是`Email` = @gp2,並發檢測就是依賴這條語句實現的。當兩個線程同時向數據庫提交更新任務時,由於其中一個線程已將Email字段值更改,那么另一個線程執行的SQL由於不滿足Email字段的匹配條件而修改失敗,進而拋出OptimisticConcurrencyException異常。如果查看未配置並發檢測生成的UPDATE 語句會更清楚這一點。
未配置並發檢測生成的UPDATE 語句:
UPDATE `Users` SET `Email`=@gp1 WHERE `Id` = 1
各個變量的值
【正常線程2@163.com】數據庫中原值:2@163.com
【正常線程2@163.com】客戶端傳值:2@163.com
“System.Data.Entity.Core.OptimisticConcurrencyException”類型的第一次機會異常在 EntityFramework.dll 中發生
“System.Data.Entity.Core.OptimisticConcurrencyException”類型的第一次機會異常在 EntityFramework.dll 中發生
“System.Data.Entity.Core.OptimisticConcurrencyException”類型的第一次機會異常在 EntityFramework.dll 中發生
“System.Data.Entity.Infrastructure.DbUpdateConcurrencyException”類型的第一次機會異常在 EntityFramework.dll 中發生
【正常線程3@163.com】數據庫中原值:3@163.com
【正常線程3@163.com】客戶端傳值:3@163.com
【線程1@163.com】數據庫中原值:1@163.com
【線程1@163.com】客戶端傳值:1@163.com
【線程1@163.com】DbEntityEntry.GetDatabaseValues:3@163.com
【線程1@163.com】DbEntityEntry.OriginalValues:2@163.com
【正常線程1@163.com】數據庫中原值:1@163.com
【正常線程1@163.com】客戶端傳值:1@163.com
分析各個變量值
打印【正常線程】這行文本的代碼在context.SaveChanges();這行代碼之后,這說明如果能夠打印出這行代碼,那么就沒有發生並發異常,所以上面在發生並發異常之前2@163.com和3@163.com這兩個值都成功更新了Email字段,當要使用值1@163.com更新Email字段時,發生了並發異常。使用值2@163.com更新字段發生在使用3@163.com更新字段之前,所以發生並發異常時,數據庫中的Email字段值為3@163.com,因此DbEntityEntry.GetDatabaseValues值為3@163.com,而DbEntityEntry.OriginalValues的值為2@163.com。
參考:
https://docs.microsoft.com/en-us/ef/
轉載與引用請注明出處。
時間倉促,水平有限,如有不當之處,歡迎指正。
