在某些保存数据比较频繁的时候,加上又是使用异步的时候,加上编写SQL不严谨就很容易产生并发冲突的问题。首先在产生这种问题的时候我们第一时间检查自己的代码,有的时候我们只要理顺了代码把一些不合理的EfCode代码做出修改就可以解决很多的问题。另外在一些频率比较高的查询上建议New 一个DbContext,这样也会好很多。
另外我们就是使用锁来避免并发问题,EF Core 主要提供了乐观并发和悲观并发两种策略来应对并发问题。下面是一个对比表格,帮助你快速了解这两种策略的核心区别:
特性 | 乐观并发控制 | 悲观并发控制 |
---|---|---|
基本原理 | 假设冲突不常发生,更新时检查数据是否被修改过 | 假设冲突经常发生,操作时直接锁定资源 |
实现方式 | 使用并发令牌(如时间戳/RowVersion)或属性标记 | 使用数据库锁(如 UPDLOCK ) |
性能影响 | 冲突较少时性能较好,冲突较多时重试或异常处理开销大 | 保证数据强一致性,但可能引发线程阻塞和性能下降 |
适用场景 | 读多写少,数据冲突概率较低的应用 | 写操作非常频繁,且数据竞争激烈的场景 |
EF Core支持 | 原生支持 | 需通过原生SQL或事务手动实现 |
乐观并发的核心在于,它在执行更新操作时,会检查要更新的记录自从被读取后,是否已被其他操作修改过(通过并发令牌比对)。如果已被修改,则意味着发生了冲突,EF Core 会抛出 DbUpdateConcurrencyException
异常。
如何实现乐观并发
实现乐观并发,主要方式是使用并发令牌(Concurrency Token)。EF Core 提供了两种主要方式:
[ConcurrencyCheck] 特性:在模型类的属性上标注
[ConcurrencyCheck]
,该属性就会被用作并发检查的依据。IsRowVersion 方法:配置一个字节数组 (
byte[]
) 属性作为 行版本(RowVersion) 或时间戳。这是推荐的方式,因为数据库会在每次更新行时自动生成新的行版本值。
下面是配置并发令牌的示例:
// 在模型类中定义 RowVersion 属性 public class House { public int Id { get; set; } public string Owner { get; set; } [Timestamp] // 使用 Data Annotations 标记 public byte[] RowVersion { get; set; } } // 或者在 DbContext 的 OnModelCreating 中配置(Fluent Api 方式) protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<House>(b => { b.ToTable("T_Houses"); b.Property(b => b.RowVersion).IsRowVersion(); // 设置为行版本 // 或者对普通字段进行并发令牌标记 // b.Property(p => p.Owner).IsConcurrencyToken(); }); }
配置后,EF Core 在更新实体时会自动在 WHERE
子句中包含并发令牌的旧值。SQL 语句类似于:
UPDATE [T_Houses] SET [Owner] = @p0 WHERE [Id] = @p1 AND [RowVersion] = @p2;
如果此 UPDATE 语句影响的行数为 0(因为 RowVersion
已变),EF Core 就会抛出 DbUpdateConcurrencyException
异常。
处理并发冲突
发生并发冲突时,你需要捕获异常并决定如何处理
try { Await _context.SaveChangesAsync(); // 保存成功处理 } catch (DbUpdateConcurrencyException ex) { // 1. 获取引发异常的实体条目 Var entry = ex.Entries.First(); // 2. 获取当前数据库中的值(其他人已提交的值) var databaseValues = await entry.GetDatabaseValuesAsync(); if (databaseValues == null) { // 记录已被删除 Console.WriteLine("该记录已被删除。"); } else { // 3. 获取数据库中的当前值 string currentOwnerInDb = databaseValues.GetValue<string>(nameof(House.Owner)); // 4. 可选择的方式:刷新实体,提示用户,或自定义合并逻辑 // 方式A:用数据库的新值覆盖当前实体 entry.OriginalValues.SetValues(databaseValues); // 或者方式B:重新加载实体 // entry.Reload(); Console.WriteLine($"并发冲突!记录已被 {currentOwnerInDb} 抢先修改。"); // 这里可以根据业务逻辑决定是重试、报错还是合并数据 } }
悲观并发通过直接在数据库层面加锁(如 UPDLOCK
)来防止其他操作修改数据,适用于数据竞争非常激烈的场景,但可能带来性能问题和死锁风险。EF Core 本身不直接支持悲观并发,通常需要通过执行原始 SQL 语句来实现
// 示例:在事务中使用 UPDLOCK 查询并更新:cite[1] using (var transaction = _context.Database.BeginTransaction()) { // 使用原生 SQL 查询并加锁 var house = _context.Houses .FromSqlRaw("SELECT * FROM T_Houses WITH (UPDLOCK) WHERE Id = {0}", id) .FirstOrDefault(); if (house != null) { // ... 执行你的业务逻辑和更新 ... house.Owner = newOwner; _context.SaveChanges(); } transaction.Commit(); }
其他优化建议
及时处理 DbContext:DbContext 非线程安全。在多线程或异步环境中(如 ASP.NET Core Web API),务必确保每个操作使用独立的 DbContext 实例。通常通过依赖注入(AddDbContextPool)容器注册为 Scoped 生命周期即可,每个 HTTP 请求都会获得一个自己的实例。
考虑使用异步操作:使用
SaveChangesAsync()
等方法可以提高高并发场景下的应用响应能力和吞吐量。评估批量操作:对于大量数据的更新或插入,考虑使用批量操作来减少数据库往返次数,提升性能。
总结
选择哪种并发控制策略,主要取决于你的具体业务场景和对数据一致性的要求程度。
首选乐观并发:对于大多数 Web 应用,乐观并发通常是更合适的选择。它扩展性更好,避免了长期持有数据库锁,适合低冲突概率的场景。
慎用悲观并发:仅在数据争用异常激烈、且乐观并发的重试/冲突处理逻辑无法满足严格的业务一致性要求时才考虑。使用时需注意其性能开销和死锁风险。
希望这些信息能帮助你更好地在 ASP.NET Core 应用中处理并发问题。