深入理解EF Core更新机制:为什么"先查后改"是必须的?
引言
最近在开发一个佣金管理系统时,遇到了一个典型的EF Core并发异常。错误信息是:"Database operation expected to affect 1 row(s) but actually affected 0 row(s)"。这个问题看似简单,却涉及EF Core的核心工作机制。经过排查,我发现这不是普通的并发问题,而是对EF Core更新机制的误解。
问题重现
我们有一个保存佣金记录的方法,支持同时保存关联的业务员数据:
csharp
public async Task<CommissionDto> SaveWithSalesmenAsync(CommissionEditDto input)
{
var commissionEntity = ObjectMapper.Map<CommissionEditDto, CommissionAggregate>(input);
CommissionAggregate savedCommission;
if (input.Id == Guid.Empty)
{
savedCommission = await _commissionService.CreateAsync(commissionEntity);
}
else
{
savedCommission = await _commissionService.UpdateAsync(commissionEntity);
}
// ... 处理业务员数据
}
当更新现有记录时,代码直接创建了一个新的CommissionAggregate实例,设置ID后调用Update方法。结果就是上面提到的并发异常。
深入分析:EF Core的工作机制
1. 状态跟踪(Change Tracking)
EF Core通过上下文(DbContext)跟踪实体的状态变化。当一个实体被查询出来时,它会被上下文自动跟踪。EF Core会记录这个实体的原始值,并与当前值进行比较,以确定哪些属性发生了变化。
csharp
// 实体被上下文跟踪
var entity = await context.Set<Commission>().FindAsync(id);
// EF Core开始跟踪这个entity的状态变化
2. 实体状态(Entity States)
EF Core中的实体有5种状态:
- Detached:实体不被上下文跟踪
- Unchanged:实体被跟踪,但属性值未改变
- Added:新实体,将被插入数据库
- Modified:实体被跟踪且属性已改变
- Deleted:实体将被删除
3. 更新操作的本质
当我们调用DbContext.Update()或DbSet.Update()时,实际上是在告诉EF Core:"请将这个实体的状态标记为Modified"。但关键是:只有被上下文跟踪的实体才能被标记为Modified。
csharp
// ❌ 错误做法
var newEntity = new Commission { Id = existingId, Name = "New Name" };
context.Update(newEntity); // newEntity是Detached状态,更新会失败
// ✅ 正确做法
var trackedEntity = await context.Commissions.FindAsync(existingId);
trackedEntity.Name = "New Name"; // 状态自动变为Modified
context.SaveChanges(); // 成功更新
为什么"先查后改"是必须的?
1. 乐观并发控制
EF Core默认使用乐观并发控制。当更新一个实体时,它会检查实体的版本号或时间戳,确保在查询和更新之间数据没有被其他进程修改。
sql
-- EF Core生成的SQL类似这样
UPDATE Commissions
SET Name = @p1
WHERE Id = @p2 AND ConcurrencyStamp = @p3;
如果直接创建新实体,EF Core无法获取正确的并发标记,导致WHERE条件不匹配,影响0行。
2. 变更检测机制
EF Core需要知道哪些属性发生了变化,以便生成最优化的SQL语句。只有被跟踪的实体,EF Core才能比较原始值和当前值。
csharp
// 查询时,EF Core记录原始值
var entity = await context.Commissions.AsNoTracking().FirstAsync();
// 如果没有AsNoTracking(),EF Core会跟踪这个实体
// 后续修改会被自动检测
3. 关联数据处理
当更新一个实体时,可能还需要处理它的关联实体(导航属性)。只有被跟踪的实体,EF Core才能正确处理这些关联。
正确的更新模式
基于以上理解,我们可以总结出EF Core更新的正确模式:
模式1:标准更新流程
csharp
public async Task UpdateCommissionAsync(Guid id, CommissionUpdateDto dto)
{
// 1. 查询获取被跟踪的实体
var entity = await _context.Commissions
.FirstOrDefaultAsync(c => c.Id == id);
if (entity == null)
throw new NotFoundException();
// 2. 修改属性(自动标记为Modified)
entity.Name = dto.Name;
entity.Amount = dto.Amount;
// 3. 处理关联实体
await HandleAssociatedEntities(entity, dto);
// 4. 保存更改
await _context.SaveChangesAsync();
}
模式2:使用Attach方法
csharp
public async Task UpdateCommissionAsync(Guid id, CommissionUpdateDto dto)
{
var entity = new Commission { Id = id };
// 手动附加实体到上下文
_context.Attach(entity);
// 设置修改的属性
entity.Name = dto.Name;
entity.Amount = dto.Amount;
// 明确标记哪些属性已修改
_context.Entry(entity).Property(e => e.Name).IsModified = true;
_context.Entry(entity).Property(e => e.Amount).IsModified = true;
await _context.SaveChangesAsync();
}
模式3:在应用服务层处理
csharp
public class CommissionAppService
{
public async Task<CommissionDto> UpdateAsync(Guid id, CommissionEditDto input)
{
// 先查询
var entity = await _commissionService.GetByIdAsync(id);
if (entity == null)
throw new BusinessException("记录不存在");
// 映射更新
ObjectMapper.Map(input, entity);
// 保存
var updated = await _commissionService.UpdateAsync(entity);
return ObjectMapper.Map<CommissionDto>(updated);
}
}
聚合根的特殊考虑
在我们的案例中,业务员聚合根有一个特殊设计:构造函数不接受ID参数。这影响了我们的更新逻辑:
csharp
// 业务员聚合根
public class SalesmanAggregate : AggregateRoot<Guid>
{
// 构造函数不接受ID
public SalesmanAggregate(string name, decimal proportion, decimal money, Guid commissionId)
{
// ID由基类自动生成
SalesmanName = name;
SalesmanProportion = proportion;
SalesmanMoney = money;
CommissionId = commissionId;
}
// 更新时需要先查询
public void UpdateInfo(string name, decimal proportion, decimal money)
{
SalesmanName = name;
SalesmanProportion = proportion;
SalesmanMoney = money;
}
}
这种设计意味着我们不能通过ID直接构造业务员实体,更新时必须:
csharp
private async Task HandleSalesmenForCommission(Guid commissionId, List<SalesmanDto> salesmen)
{
foreach (var dto in salesmen)
{
if (dto.Id != Guid.Empty)
{
// 必须先查询
var existing = await _salesmanService.GetByIdAsync(dto.Id);
if (existing != null)
{
// 调用实体的更新方法
existing.UpdateInfo(dto.SalesmanName, dto.SalesmanProportion, dto.SalesmanMoney);
await _salesmanService.UpdateAsync(existing);
continue;
}
}
// 新增:使用构造函数
var newEntity = new SalesmanAggregate(
dto.SalesmanName,
dto.SalesmanProportion,
dto.SalesmanMoney,
commissionId
);
await _salesmanService.CreateAsync(newEntity);
}
}
性能优化建议
虽然"先查后改"是必须的,但我们可以优化这个过程:
1. 选择性查询
csharp
// 只查询需要的字段
var entity = await _context.Commissions
.Where(c => c.Id == id)
.Select(c => new { c.Id, c.ConcurrencyStamp })
.FirstOrDefaultAsync();
2. 使用AsNoTracking配合Attach
csharp
public async Task QuickUpdateAsync(Guid id, string newName)
{
// 轻量级查询
var entity = await _context.Commissions
.AsNoTracking()
.Where(c => c.Id == id)
.Select(c => new Commission { Id = c.Id, ConcurrencyStamp = c.ConcurrencyStamp })
.FirstOrDefaultAsync();
if (entity != null)
{
entity.Name = newName;
_context.Attach(entity);
_context.Entry(entity).Property(e => e.Name).IsModified = true;
await _context.SaveChangesAsync();
}
}
3. 批量更新优化
csharp
public async Task BatchUpdateAsync(List<CommissionUpdateDto> dtos)
{
var ids = dtos.Select(d => d.Id).ToList();
// 一次性查询所有需要更新的实体
var entities = await _context.Commissions
.Where(c => ids.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
foreach (var dto in dtos)
{
if (entities.TryGetValue(dto.Id, out var entity))
{
entity.Name = dto.Name;
entity.Amount = dto.Amount;
}
}
await _context.SaveChangesAsync();
}
结论
这次遇到的问题虽然表面上是并发异常,但根本原因是对EF Core更新机制的理解不足。通过这次调试,我们深刻理解了:
- EF Core的更新必须基于被跟踪的实体
- "先查后改"不是可选的最佳实践,而是必须遵循的规则
- 聚合根的设计会直接影响业务逻辑的实现方式
- 合理的架构设计可以减少这类问题的发生
在实际开发中,我们应该:
- 深入理解所使用的ORM框架的工作原理
- 遵循框架的设计哲学和最佳实践
- 在领域层和应用层之间建立清晰的边界
- 针对特殊设计(如不接受ID的构造函数)制定相应的处理策略
只有这样,我们才能编写出既正确又高效的代码,避免类似的"坑"再次出现。