将EntityFrameworkCore从5.0升级到6.0.14遇到的问题
我们团队接手了一个连接SQLite数据库的C#应用,需要将其从EntityFrameworkCore 5.0升级到6.0.14,升级后出现了两处异常问题,以下是简化后的相关代码及疑问。
仓储类
public async Task AddAsync<T>(T entity, CancellationToken cancellationToken = default) where T : class { if (entity == null) throw new ArgumentNullException(nameof(entity)); await _dbContext.Set<T>().AddAsync(entity, cancellationToken); } public async Task<List<T>> GetListAsync<T>(Expression<Func<T, bool>> condition = null, IEnumerable<Expression<Func<T, object>>> includes = null, bool asNoTracking = false, CancellationToken cancellationToken = default) where T : class { IQueryable<T> query = _dbContext.Set<T>(); if (condition != null) query = query.Where(condition); if (includes != null) foreach (var expression in includes) query = query.Include(expression); if (asNoTracking) query = query.AsNoTracking(); return await query.AsSingleQuery().ToListAsync(cancellationToken); } public void Update<T>(T entity) where T : class { if (entity == null) throw new ArgumentNullException(nameof(entity)); _dbContext.Set<T>().Update(entity); }
模型类
public record MainEntity { [Key] public string Id { get; protected set; } public ICollection<RelatedEntity> OtherEntities { get; set; } public MainEntity() { } public MainEntity(string id) { this.Id = id; } } public record RelatedEntity { public string Id { get; set; } // 该属性实际是指向MainEntity.Id的外键,同时与CoId构成表的主键 public string CoId { get; set; } }
管理器代码
public async Task SaveStateDB(CancellationToken cancellation) { using (var scope = scopeFactory.CreateScope()) { var repository = scope.ServiceProvider.GetService<IRepository>(); lock (lockToSave) { try { var includes = new List<Expression<Func<MainEntity, object>>> { x => x.OtherEntities }; var Storage = repository.GetAsync(x => x.Id == id, includes, true, cancellation).Result; bool isNew = false; if (Storage == default) { Storage = new MainEntity(id); isNew = true; } var OtherEntities = GetOtherEntities(); if (Storage.OtherEntities == null) Storage.OtherEntities = OtherEntities; else { Storage.OtherEntities.Except(IoVariables).ForEach(x => repository.Delete(x)); OtherEntities.Except(Storage.OtherEntities).ForEach(x => Storage.OtherEntities.Add(x)); } if (isNew) repository.AddAsync(Storage, cancellation).Wait(); else repository.Update(Storage); repository.SaveAsync(cancellation); } catch (Exception ex) { try { repository.SaveAsync(cancellation); } catch (Exception) { logger.LogError("--------------------" + ex.Message + " " + ex.StackTrace + "--------------------"); } } } } }
升级后出现的异常
- 数据库为空时调用
AddAsync可正常存储,但已有MainEntity后调用Update时,抛出异常:
Exception has occurred: CLR/System.InvalidOperationException
Exception thrown: 'System.InvalidOperationException' in Microsoft.EntityFrameworkCore.dll: 'Unable to track an entity of type 'RelatedEntity' because its primary key property 'Id' is null.'
- 解决上述问题后,调用
SaveAsync(内部为SaveChangesAsync)时抛出并发异常:
Exception thrown: 'Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException' in System.Private.CoreLib.dll: 'The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded.'
具体疑问
- 为何EF Core 5中
RelatedEntity无Id时可正常存储? - 为何
AddAsync可正常工作,Update却不行? - 为何启用
AsNoTracking会引发并发异常,如何保留该设置解决问题?
问题解答
1. EF Core 5中RelatedEntity无Id可正常存储的原因
EF Core 5对复合主键的处理逻辑更宽松:当RelatedEntity的Id作为外键关联MainEntity.Id,且和CoId组成复合主键时,EF Core 5会在插入阶段自动从关联的MainEntity中填充Id值,即使代码未显式设置。而EF Core 6.0强化了实体跟踪的校验规则,要求被跟踪的实体必须明确设置主键属性的值,否则直接抛出主键为空的异常。
2. AddAsync正常但Update异常的原因
- 调用
AddAsync时,整个MainEntity及其关联的RelatedEntity都处于新增状态,EF Core会自动处理关联外键的填充逻辑,RelatedEntity的Id会同步MainEntity.Id,此时不会触发主键非空校验。 - 调用
Update时,MainEntity是通过AsNoTracking加载的,关联的RelatedEntity处于未被上下文跟踪的状态。当调用Update(Storage)时,EF Core会尝试将整个实体图标记为修改状态,此时未设置Id的RelatedEntity会触发EF Core 6的主键校验,导致异常。
3. 保留AsNoTracking解决并发异常的方法
并发异常的本质是:AsNoTracking加载的实体没有被上下文跟踪,调用Update时EF Core会基于实体当前属性生成更新语句,但由于没有实体的原始值,EF Core默认会检查更新行数是否匹配(乐观并发机制),最终因无法确认实体原始状态导致误判。
方案一:手动附加实体并设置状态
在调用Update前,将实体附加到上下文,并明确设置每个实体的状态:
// 替换原有的repository.Update(Storage); var mainDbSet = _dbContext.Set<MainEntity>(); mainDbSet.Attach(Storage); _dbContext.Entry(Storage).State = EntityState.Modified; // 处理关联的RelatedEntity,确保Id已设置 foreach (var related in Storage.OtherEntities) { related.Id = Storage.Id; // 显式关联主实体Id var relatedEntry = _dbContext.Entry(related); if (relatedEntry.State == EntityState.Detached) { // 判断是否已存在,设置对应状态 bool exists = await _dbContext.Set<RelatedEntity>() .AnyAsync(x => x.Id == related.Id && x.CoId == related.CoId, cancellation); relatedEntry.State = exists ? EntityState.Modified : EntityState.Added; } }
方案二:仅更新需要修改的属性
避免全量更新,只标记需要修改的属性,减少并发校验的影响:
// 重新加载无跟踪的原始实体 var original = await repository.GetAsync(x => x.Id == id, includes: null, asNoTracking: true, cancellation); // 复制需要更新的属性 original.OtherEntities = Storage.OtherEntities; // 附加并标记需要修改的集合属性 _dbContext.Attach(original); _dbContext.Entry(original).Collection(x => x.OtherEntities).IsModified = true;
方案三:临时关闭乐观并发校验(不推荐)
在上下文配置中关闭全局乐观并发校验,或移除实体的并发令牌,但会失去并发保护能力:
// 在DbContext的OnModelCreating方法中 modelBuilder.Entity<MainEntity>() .Property(x => x.Id) .IsConcurrencyToken(false);
内容的提问来源于stack exchange,提问作者Thal

