使用 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.OriginalValuesDbEntityEntry.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.comDbEntityEntry.GetDatabaseValues:3@163.com
线程 1@163.comDbEntityEntry.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/
- -----------------------------------------------------------------------------------------
来源: https://www.cnblogs.com/hdwgxz/p/8678370.html