深入理解EF Core更新机制(开发中因为省事遇到的问题)

深入理解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更新机制的理解不足。通过这次调试,我们深刻理解了:

  1. EF Core的更新必须基于被跟踪的实体
  2. "先查后改"不是可选的最佳实践,而是必须遵循的规则
  3. 聚合根的设计会直接影响业务逻辑的实现方式
  4. 合理的架构设计可以减少这类问题的发生

在实际开发中,我们应该:

  • 深入理解所使用的ORM框架的工作原理
  • 遵循框架的设计哲学和最佳实践
  • 在领域层和应用层之间建立清晰的边界
  • 针对特殊设计(如不接受ID的构造函数)制定相应的处理策略

只有这样,我们才能编写出既正确又高效的代码,避免类似的"坑"再次出现。

相关推荐
用户4488466710601 小时前
.NET进阶——深入理解委托(3)事件入门
c#·.net
梁萌2 小时前
MySQL索引的使用技巧
数据库·mysql·索引·b+tree
x10n92 小时前
OceanBase 参数对比工具 附源码
数据库·vscode·oceanbase·腾讯云ai代码助手
暴风游侠2 小时前
linux知识点-服务相关
linux·服务器·笔记
总有刁民想爱朕ha2 小时前
.NET 8 和 .NET 6 性能对比的测试
.net·性能测试·.net6·.net8
RestCloud2 小时前
如何用ETL做实时风控?从交易日志到告警系统的实现
数据库·数据仓库·kafka·数据安全·etl·数据处理·数据集成
JANG10242 小时前
【Linux】常用指令
linux·服务器·javascript
feng_blog66882 小时前
cursor通过ssh连接远程服务器
运维·服务器·ssh
蓝天~白云2 小时前
ESXI虚拟机启动卡住在0%,无法关闭
linux·运维·服务器