EF Core Change Tracking:你不小心构建的 Bug 工厂

发布: (2026年1月19日 GMT+8 21:25)
12 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的具体文本内容,我将为您翻译成简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!

大多数生产环境中的 EF Core 问题并非源于代码错误

它们始于能够正常工作的代码。

查询能返回数据。更新能够成功。性能看起来也还可以。随后流量增长,内存激增,SQL 日志爆炸,甚至更糟——随机出现的主键冲突错误出现在没有人触碰的地方。有人打开或关闭 AsNoTracking(),系统随即恢复稳定,团队继续前进。

直到它再次在其他地方发生。

本文将讨论我们是如何走到这一步的,为什么会出现这些 bug,以及如何设计数据访问,以免像按下紧急按钮一样随意切换跟踪。


故事通常是这样开始的

EF Core 默认启用 更改跟踪。

这听起来很合理。你查询实体,修改它们,然后调用 SaveChanges()。EF 会判断哪些发生了变化并生成相应的 SQL。很简单。

这个默认设置悄悄地进入了生产环境。

  • 每个查询都会跟踪实体。
  • 长生命周期的 DbContext 会累计这些实体。
  • 内存使用量上升。
  • 垃圾回收压力增大。
  • 延迟逐渐增加。

有人对应用进行分析,注意到内存中有成千上万的已跟踪实体却什么也不做。

解决办法显而易见。

services.AddDbContext(options =>
{
    options.UseSqlServer(conn);
    // 在所有地方生效
    options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});

性能立刻提升,内存下降。大家松了一口气。

随后第一次关系更新因重复键异常而失败。


你已经看到并忽略的 Bug

在更改之前,这段代码可以正常工作:

var order = await context.Orders
    .Include(o => o.Customer)
    .FirstAsync(o => o.Id == orderId);

order.Customer = existingCustomer;

await context.SaveChangesAsync();

全局禁用跟踪后,同样的代码抛出:

Cannot insert duplicate key row in object 'Customers'
The duplicate key value is (...)

代码本身没有变化,数据库也没有变化,唯一改变的是跟踪行为。

不是 EF Core 的 bug,而是 EF Core 正在按你的要求工作,只是与你的预期不符。


更改跟踪到底是什么

更改跟踪是 EF Core 为它负责的实体维护一个内部图。

被跟踪的实体拥有:

  • 已知的标识(主键)
  • 已知的状态(UnchangedModifiedAddedDeleted
  • 基于快照或代理的更改检测

当你调用 SaveChanges() 时,EF:

  1. 将已跟踪实体与其原始状态进行比较
  2. 只为发生变化的部分生成 SQL
  3. 自动保持关系的一致性

当实体 未被跟踪 时,EF 不会执行上述任何操作。没有状态,没有标识映射,也没有关系感知。


为什么会有 AsNoTracking()

AsNoTracking() 告诉 EF:

“这些数据是只读的。不要在内存或 CPU 上浪费资源去跟踪它们。”

这在以下场景下是正确且有价值的:

  • 大型结果集
  • 读取密集型端点
  • 报表
  • 永不需要保存的投影

示例:

var orders = await context.Orders
    .AsNoTracking()
    .Where(o => o.Status == Status.Open)
    .ToListAsync();

这比默认跟踪更快、更轻量,也更安全。没有任何数据会被保留在内存中以供后续使用(如使用跟踪时的情况)。

错误在于在不了解后果的情况下把它用在所有地方。


命令与查询并非同一件事

查询关注的点命令关注的点
速度状态感知
低内存占用关系处理
无副作用正确的更新

对两者使用相同的跟踪策略是大多数系统崩溃的根源。


为什么 SaveChanges() 对已分离的实体无所作为

var user = await context.Users
    .AsNoTracking()
    .FirstAsync(u => u.Id == id);

user.Name = "New Name";

await context.SaveChangesAsync();

没有错误。没有更新。什么也没发生。

为什么? 因为 EF 没有跟踪 user。从 EF 的

Source:

从视角来看,因为没有任何东西被监视,所以没有任何变化。这是一种沉默的失败——最糟糕的那种。


导致重复键的关系陷阱

var order = await context.Orders
    .AsNoTracking()
    .FirstAsync(o => o.Id == orderId);

order.Customer = existingCustomer;

context.Orders.Update(order);
await context.SaveChangesAsync();

EF 看到:

  • 一个未被跟踪的 Order
  • 一个被引用的 Customer 对象
  • 两者都没有跟踪信息

于是它假设两者都是新建的。

EF 生成:

  • OrderINSERT
  • CustomerINSERT

如果该客户已经存在,数据库会拒绝插入。这就是为什么全局禁用跟踪常常“提升性能”,但随后又会破坏关系的原因。


Attach 与 Update 与 已跟踪 的区别

这三者不可互换。

已跟踪实体(最佳情况)

var product = await context.Products.FirstAsync(p => p.Id == id);
product.Price += 10;

await context.SaveChangesAsync();

// Generated SQL:
// UPDATE [Products] SET [Price] = @p0 WHERE [Id] = @p1;
  • EF 跟踪更改
  • 只更新被修改的列
  • SQL 最小化

Attach

var product = await context.Products.AsNoTracking().FirstAsync(p => p.Id == id);
context.Attach(product);

product.Price += 10;

await context.SaveChangesAsync();

// Generated SQL:
// UPDATE [Products] SET [Price] = @p0 WHERE [Id] = @p1;
  • EF 假设实体已经存在
  • 只更新被修改的属性
  • 当你确定实体不是新建时安全使用

Update

var product = await context.Products.AsNoTracking().FirstAsync(p => p.Id == id);
product.Price += 10;

context.Update(product);
await context.SaveChangesAsync();

// Generated SQL:
// UPDATE [Products] SET [Price] = @p0 WHERE [Id] = @p1;
  • EF 将整个实体标记为 Modified
  • 所有标量属性都会发送到数据库(即使未改变)
  • 当你拥有一个脱离上下文的实体并希望 EF 将其视为已存在且不在乎变更检测时使用

要点

  1. 对写操作保持跟踪开启。
  2. 仅在真正的只读场景下使用 AsNoTracking()
  3. 当需要处理脱离上下文的数据时,显式 AttachUpdate 实体,切勿两者同时使用。
  4. 除非确信整个应用都是只读的,否则避免使用“全局不跟踪”设置。

了解 EF Core 在底层的工作方式后,你就可以停止把 AsNoTracking() 当作紧急按钮,而是把它当作设计之初就用于提升性能的工具来使用。

context.Update(product); // This forces all columns to be updated
await context.SaveChangesAsync();

调用 Update() 会发生什么?

  • EF 将所有属性标记为已修改
  • 生成 整行 UPDATE 语句
  • 产生 庞大的 SQL 语句
  • 覆盖你没有触碰的列
UPDATE [Products]
SET [Price] = @p0,
    [Name] = @p1,
    [Description] = @p2,
    [Stock] = @p3,
    [CategoryId] = @p4
WHERE [Id] = @p5;

对已经被跟踪的实体调用 Update() 是多余且浪费的——EF 已经知道哪些字段发生了变化。

实际演示场景

1. 已跟踪的更新有效

var user = await context.Users.FirstAsync(u => u.Id == id);
user.Email = "new@email.com";
await context.SaveChangesAsync();

结果: 正确更新,生成的 SQL 最小。


2. AsNoTracking 导致静默无操作

var user = await context.Users
    .AsNoTracking()
    .FirstAsync(u => u.Id == id);

user.Email = "new@email.com";
await context.SaveChangesAsync();

结果: 没有更新,也没有错误或警告。


3. AsNoTracking + Add 引发重复键错误

var role = await context.Roles
    .AsNoTracking()
    .FirstAsync(r => r.Id == roleId);

context.Roles.Add(role);
await context.SaveChangesAsync();

结果: EF 尝试插入已存在的角色 → 出现重复键错误。


4. Attach 只更新已修改的列

context.Attach(user);
user.IsActive = false;
await context.SaveChangesAsync();

结果: 在有意使用时,操作干净且安全。


5. Update 强制全行更新

context.Update(user);
await context.SaveChangesAsync();

结果: 所有列都被标记为已修改 → 生成更大的 SQL,风险更高。


真正的解决方案:停止切换跟踪

问题不在 EF Core 本身,而是 在同一数据访问路径中混合了读写意图
解决方案是结构性的:按意图拆分仓库


只读仓库

public class OrderReadRepository
{
    private readonly DbContext _context;

    public OrderReadRepository(DbContext context) => _context = context;

    public Task<OrderDto> GetById(Guid id) =>
        _context.Orders
                .AsNoTracking()
                .Where(o => o.Id == id)
                .Select(o => new OrderDto(/* … */))
                .FirstAsync();
}

特性

  • 始终 不跟踪
  • 设计上安全
  • 易于缓存
  • 为以后使用只读副本做好准备

读写仓库

public class OrderWriteRepository
{
    private readonly DbContext _context;

    public OrderWriteRepository(DbContext context) => _context = context;

    public Task<Order> GetTracked(Guid id) =>
        _context.Orders.FirstAsync(o => o.Id == id);

    public Task Save() => _context.SaveChangesAsync();
}

特性

  • 跟踪是 预期的
  • 关系正常工作
  • 更新准确
  • 没有标记,也不需要猜测

集中式基础仓储(可选)

只读基类

public abstract class ReadOnlyRepository<TEntity> where TEntity : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<TEntity> _dbSet;

    protected ReadOnlyRepository(DbContext context)
    {
        _context = context;
        _dbSet = context.Set<TEntity>();
        _context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
    }

    public virtual IQueryable<TEntity> GetAll() => _dbSet;
    public virtual Task<TEntity> GetByIdAsync(object id) => _dbSet.FindAsync(id).AsTask();
}

读写基类

public abstract class ReadWriteRepository<TEntity> where TEntity : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<TEntity> _dbSet;

    protected ReadWriteRepository(DbContext context)
    {
        _context = context;
        _dbSet = context.Set<TEntity>();
        _context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.TrackAll;
    }

    public virtual IQueryable<TEntity> GetAll() => _dbSet;
    public virtual Task<TEntity> GetByIdAsync(object id) => _dbSet.FindAsync(id).AsTask();

    public virtual void Add(TEntity entity) => _dbSet.Add(entity);
    public virtual void Update(TEntity entity) => _dbSet.Update(entity);
    public virtual void Remove(TEntity entity) => _dbSet.Remove(entity);
    public virtual Task SaveChangesAsync() => _context.SaveChangesAsync();
}

为什么这不仅仅是“干净代码的开销”

  • 消除意外追踪
  • 防止静默的无操作
  • 避免键重复错误
  • 使性能可预测
  • 使意图明确

应用层使用

  • GET 接口注入 只读 仓库。
  • Command 处理器注入 写入 仓库。

这种模式自然可扩展:

  • 以后可以启用读副本、缓存层或独立数据库。
  • 你不需要完整的 CQRS;只需区分读写意图。

经验教训

  • 跟踪功能强大 但代价高昂
  • AsNoTracking 仅在 读取 时安全。
  • 已分离 实体,SaveChanges 什么也不做。
  • Update 是一种 笨重的工具
  • 正确使用时,Attach 精确
  • 仓储设计必须体现 查询与命令意图 的区别。

你不需要完整的 CQRS 才能这样思考——只要停止把读取和写入当作同一件事。EF Core 本身不是问题,问题在于默认设置。理解了这一点,你多年来忽视的 bug 就会突然变得有道理。

Back to Blog

相关文章

阅读更多 »

数据库事务泄漏

介绍 我们经常谈论 memory leaks,但在 backend development 中还有另一个沉默的性能杀手:Database Transaction Leaks。我最近…