c# 在高并发下使用 EF Core 的 SaveChangesAsync 和并发冲突

来源:这里教程网 时间:2026-02-21 17:39:09 作者:

EF Core 的 SaveChangesAsync 默认不处理并发冲突

调用

SaveChangesAsync
时,EF Core 只是把待提交的变更翻译成 SQL 执行,不会主动检测或重试并发写入。如果两个请求同时读取同一行、各自修改后都调用
SaveChangesAsync
,后提交者会直接覆盖前者的修改——除非你显式启用并发控制。

用 IsConcurrencyToken 标记字段才能触发乐观并发检查

EF Core 的并发冲突检测依赖数据库层面的“版本戳”(如

rowversion
timestamp
字段),不是靠应用层锁或时间戳比对。必须在实体中声明一个属性并标记为并发令牌:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    1771666208 // SQL Server 专用,生成 rowversion 列
    public byte[] RowVersion { get; set; }
}

或者用 Fluent API(更通用):

modelBuilder.Entity<Product>()
    .Property(p => p.RowVersion)
    .IsConcurrencyToken();
没有这个标记,
SaveChangesAsync
即使遇到数据库行已被修改,也不会抛出
DbUpdateConcurrencyException
1771666208
仅适用于 SQL Server;PostgreSQL/MySQL 需用
int
datetime
类型 +
IsConcurrencyToken()
配合手动更新逻辑
字段值必须由数据库自动生成(如
rowversion
DEFAULT NEXTVAL
),不能由应用赋值

捕获 DbUpdateConcurrencyException 后必须手动处理冲突

EF Core 不会自动重试或合并。抛出

DbUpdateConcurrencyException
表示:当前实体的并发令牌值与数据库中不一致,即该行已被其他事务修改过。

异常对象的
Entries
属性包含所有冲突的
EntityEntry
,可用来获取原始值、数据库当前值和当前修改值
典型做法是:读取数据库最新值 → 合并业务逻辑 → 更新实体 → 再次调用
SaveChangesAsync
不要简单地
entry.OriginalValues.SetValues(entry.GetDatabaseValues())
就重试,这会丢失用户本次修改的语义(比如“库存减1”变成“设为当前库存值”)
try
{
    await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    foreach (var entry in ex.Entries)
    {
        var databaseValues = await entry.GetDatabaseValuesAsync();
        if (databaseValues == null)
        {
            throw new InvalidOperationException("数据库中已不存在该记录");
        }
        // 比如只允许更新 Price,保留 Name 和 RowVersion 来自数据库
        entry.OriginalValues["Price"] = databaseValues["Price"];
        entry.OriginalValues["RowVersion"] = databaseValues["RowVersion"];
    }
    // 重试(注意:需确保业务逻辑幂等,否则可能重复扣款等)
    await context.SaveChangesAsync();
}

高并发下单纯重试容易引发雪崩,要加限流和退避

大量请求同时撞上同一行(如秒杀商品库存),反复重试会导致数据库压力陡增、响应延迟飙升,甚至线程池耗尽。

避免无限制循环重试,应设置最大重试次数(如 3 次)和指数退避(如 10ms → 30ms → 100ms) 对热点数据(如库存、账户余额),考虑改用数据库原生命令(
UPDATE ... WHERE version = @old AND stock >= @needed
)+ 返回影响行数判断成败,绕过 EF Core 的跟踪开销
更彻底的解法是分离读写:用 Redis 做库存预占,再异步落库,把 EF Core 的并发压力转移到缓存层

真正难的不是捕获异常,而是判断“这次冲突要不要让用户重试”“哪些字段允许被覆盖”“失败后该提示什么”,这些都得贴着业务规则来设计,而不是套个

RetryAttribute
就完事。

相关推荐