EF Core Change Tracking:你不小心构建的 Bug 工厂
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 为它负责的实体维护一个内部图。
被跟踪的实体拥有:
- 已知的标识(主键)
- 已知的状态(
Unchanged、Modified、Added、Deleted) - 基于快照或代理的更改检测
当你调用 SaveChanges() 时,EF:
- 将已跟踪实体与其原始状态进行比较
- 只为发生变化的部分生成 SQL
- 自动保持关系的一致性
当实体 未被跟踪 时,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 生成:
- 对
Order的INSERT - 对
Customer的INSERT
如果该客户已经存在,数据库会拒绝插入。这就是为什么全局禁用跟踪常常“提升性能”,但随后又会破坏关系的原因。
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 将其视为已存在且不在乎变更检测时使用
要点
- 对写操作保持跟踪开启。
- 仅在真正的只读场景下使用
AsNoTracking()。 - 当需要处理脱离上下文的数据时,显式
Attach或Update实体,切勿两者同时使用。 - 除非确信整个应用都是只读的,否则避免使用“全局不跟踪”设置。
了解 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 就会突然变得有道理。