通过父实体导航属性添加 Element,为何保存时出现并发异常?
结论
可以通过父实体的 Elements 导航属性添加新元素。遇到问题时,先检查新 Element 在保存前的实体状态和主键生成配置。
改用 AddRangeAsync 后成功,一种可能的原因是:EF Core 检测导航属性变化时,将新对象判定为已有实体并准备执行 UPDATE。如果数据库中没有对应记录,就会出现预期影响 1 行、实际影响 0 行的异常。显式调用 AddRangeAsync 会将新增实体标记为 Added,安排插入。
不过,现有代码没有提供 Element 的主键定义、映射配置或实际 SQL,不能直接断定 Guid 就是主键,也不能认定异常一定由它引起。
为什么加入导航集合后不一定执行 INSERT?
questionnaire.Elements.Add(...) 首先改变的是对象之间的关系。对于普通集合,这个调用不会像 DbSet.Add(...) 一样直接声明实体需要插入。
在通常的跟踪查询和自动变更检测配置下,EF Core 会在检测变化时发现新加入的对象,再根据键值和映射规则确定其状态。
当以下条件同时出现时,需要特别留意:
Element使用配置为自动生成值的主键。- 创建对象时,该主键已经被赋予非默认值。
- 对象通过已跟踪实体的导航属性被发现。
在这种情况下,新发现的实体可能被标记为 Modified。即使对象由 new Element() 创建,也不能仅凭这一点判断它会被插入。
原代码中的 Guid = elementDto.Guid 值得检查,但只有确认 Guid 是主键或参与主键后,才能判断它是否与这个机制有关。如果实际主键是另一个属性,应检查该属性的值和生成配置。
这属于实体跟踪和保存行为,与 LINQ 查询语法没有直接关系。
先检查保存前的状态
添加元素之后,调用 SaveChangesAsync 之前,可以显式触发变更检测并查看状态:
context.ChangeTracker.DetectChanges();
foreach (var entry in context.ChangeTracker.Entries<Element>())
{
Console.WriteLine($"Guid: {entry.Entity.Guid}, State: {entry.State}");
}
重点观察本次新建的对象:
Added:EF Core 准备插入。Modified:EF Core 准备更新。Unchanged:当前没有安排插入或更新。
还可以查看更完整的跟踪信息:
Console.WriteLine(context.ChangeTracker.DebugView.LongView);
如果新元素显示为 Modified,可以解释它为什么可能触发更新行数为零的异常。再通过数据库命令日志确认实际发出的是 INSERT 还是 UPDATE。
如果新元素已经是 Added,应继续检查同一次保存中的其他实体。一次 SaveChangesAsync 可能同时包含新增、更新和软删除,报错的操作不一定来自新元素。
如何明确添加新元素?
对于已经确认需要新建的元素,可以继续通过导航属性建立关系,同时显式调用 DbSet.Add:
// 前提:questionnaire 已查询到,且 request.Elements 仅包含待新增元素。
foreach (var elementDto in request.Elements)
{
var element = new Element
{
Guid = elementDto.Guid,
Name = elementDto.Name,
};
questionnaire.Elements.Add(element);
context.Elements.Add(element);
}
await context.SaveChangesAsync(cancellationToken);
也可以先收集待新增对象,再使用已经验证有效的方式:
var elementsToAdd = new List<Element>();
// 前提:request.Elements 仅包含待新增元素。
foreach (var elementDto in request.Elements)
{
var element = new Element
{
Guid = elementDto.Guid,
Name = elementDto.Name,
};
questionnaire.Elements.Add(element);
elementsToAdd.Add(element);
}
await context.Elements.AddRangeAsync(elementsToAdd, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
这里起作用的是显式声明新增状态。AddRangeAsync 的异步形式不是解决异常的关键,数据库写入仍发生在 SaveChangesAsync 时。
如果同一个请求同时包含新增和已有元素,需要先区分两类数据。已有元素应修改查询得到的受跟踪对象;只有确认需要插入的对象才调用 Add 或 AddRangeAsync。
主键配置和其他排查点
检查 Element 的主键究竟是哪一个属性,以及它是否配置了值生成规则。
如果主键由应用提供,映射配置应与这一约定一致;如果主键由 EF Core 或数据库生成,就应按对应配置创建实体。尚未确认映射方式时,不要直接删除 Guid 赋值或修改值生成设置。
同时确认问卷查询是否启用了跟踪。Include 负责加载关联数据,跟踪行为还会受到 AsNoTracking 和上下文默认配置的影响。对未跟踪对象修改导航集合,通常不能直接依赖当前上下文自动保存。
这条异常消息也可能对应真实的并发问题,例如已有记录被其他操作删除,或者并发令牌不再匹配。只有确认报错实体、实体状态和实际 SQL,才能区分这些情况。
备注:内容仅供参考。
