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就完事。
