ASP.NET Core 5中C# 9 records配合EF Core的追踪冲突解决
问题根因
这个异常和C# record类型本身没有兼容问题,完全是EF Core实体追踪机制的逻辑导致的:
- 你调用
FindAsync获取实体时,EF Core默认开启变更追踪,已经把返回的Product实例纳入内部追踪缓存,缓存以实体主键为键,不允许同一个DbContext实例下存在两个同主键的被追踪实体。 - 你使用
with表达式修改record时,会生成一个全新的内存实例,原来被追踪的旧实例依然留在缓存中,没有被释放。这时候你调用db.Update(新实例),EF Core会发现缓存中已经存在同主键的追踪实体,直接抛出冲突异常。
可落地的解决方案
根据你对record不可变性的设计要求,可以选下面任意一种方案,不需要强制改回普通类作为实体:
方案1:直接利用现有追踪逻辑,减少不必要的实例创建
绝大多数常规更新场景,你根本不需要生成新的record实例,也不需要手动调用db.Update()——EF Core会自动识别被追踪实体的属性变更,在SaveChanges时生成对应的更新SQL。
如果你的record定义允许修改属性,直接调整写法即可:
[HttpGet("test/{id}")] public async Task<Product> ExampleControllerAction(int id, CancellationToken cancellationToken) { string newName = "test new name!!"; // 拿到的实体是被追踪状态 Product product = await db.Products.FindAsync(new object[] { id }, cancellationToken); // 直接修改属性,不需要手动调用Update product.Name = newName; await db.SaveChangesAsync(cancellationToken); return product; }
*注意:位置语法定义的record默认属性是init只读的,如果要支持直接修改属性,可以把record的属性改为可写,调整record的定义方式:
// 调整record定义,开放set访问器,不破坏record的值相等语义 public record Product { public int ID { get; set; } public string Name { get; set; } }
方案2:保持record不可变设计,使用Entry API同步值
如果你要严格保持record的不可变性(属性只留init访问器,不开放直接修改),不需要生成新实例再去走追踪逻辑,可以把新生成的record实例作为值的载体,同步到已经被追踪的旧实例上:
[HttpGet("test/{id}")] public async Task<Product> ExampleControllerAction(int id, CancellationToken cancellationToken) { string newName = "test new name!!"; // 拿到被追踪的原始实体 Product trackedProduct = await db.Products.FindAsync(new object[] { id }, cancellationToken); // 生成全新的不可变实例作为更新后的值载体 Product updatedProduct = trackedProduct with { Name = newName }; // 把新实例的属性值同步到被追踪的实体上,不触发追踪冲突 db.Entry(trackedProduct).CurrentValues.SetValues(updatedProduct); await db.SaveChangesAsync(cancellationToken); // 直接返回新的不可变实例,符合record的使用习惯 return updatedProduct; }
这种方式既完全保留了record的不可变语义,也不会因为新实例和旧实例同主键导致追踪冲突,不需要手动调用Update方法。
方案3:查询时关闭追踪,适配手动Update的场景
如果你确实需要拿到无追踪的实体实例,再通过with生成新实例走手动更新逻辑,可以在查询时明确关闭实体追踪,让上下文不会缓存原始的实体实例:
[HttpGet("test/{id}")] public async Task<Product> ExampleControllerAction(int id, CancellationToken cancellationToken) { string newName = "test new name!!"; // 查询时关闭追踪,上下文不会缓存返回的实体实例 Product product = await db.Products.AsNoTracking().FirstAsync(p => p.ID == id, cancellationToken); // 生成新实例不会有追踪冲突,因为上下文里没有同主键的追踪项 product = product with { Name = newName }; // 这时候手动调用Update是完全安全的 db.Update(product); await db.SaveChangesAsync(cancellationToken); return product; }
常见误区澄清
- 不存在“record类型不能当EF Core实体”的说法:EF Core 6开始已经原生支持record类型作为实体,EF Core 5虽然没有内置的record构造函数绑定优化,但只要处理好追踪逻辑,完全可以正常使用。
- 不要在同一个DbContext生命周期内,把两个同主键的实体实例同时塞进追踪缓存,这和实体是普通类还是record没有关系——哪怕你用普通类,只要搞两个同主键的实例一个被追踪、一个手动Update,一样会抛一模一样的异常。
内容的提问来源于stack exchange,提问作者Phil K
相关产品推荐
相关产品推荐

