Skip to content
Go back

EF Core 10 追踪与非追踪查询:基准测试与决策指南

每次通过 Entity Framework Core 执行查询时,后台都会发生一件你可能从未留意的事:EF Core 会对返回的每一个实体拍一张”快照”,存入内部的 Change Tracker。调用 SaveChanges() 时,它通过逐一比对当前值与快照来生成正确的 SQL。这就是 EF Core 能”神奇地”知道哪些行需要更新的原因。

这份魔法有代价。对每个被追踪的实体,EF Core 都需要分配快照内存、维护标识解析(identity resolution)的内部数据结构,并在 SaveChanges() 时执行比较逻辑。加载 10 条记录时开销可以忽略不计;加载 10,000 条记录时,内存与 CPU 的消耗就相当可观了。

修复方法很直接:如果不打算修改数据,就告诉 EF Core 不要追踪。但知道在哪里该这么做——以及理解其中的权衡——才是大多数教程没讲清楚的地方。

封面

Change Tracking 是什么

Change Tracking 是 EF Core 用于检测实体修改、从而生成正确 SQL 的机制。查询实体时,EF Core 将其原始属性值的快照存入 ChangeTracker;调用 SaveChanges() 时,它把当前值与快照对比,只生成真正发生变化的列的 UPDATE 语句

每个被追踪实体都处于以下五个状态之一:

状态含义SaveChanges 行为
Unchanged从数据库加载后无修改跳过
Modified加载后有属性变更生成 UPDATE
Added新附加到上下文生成 INSERT
Deleted标记为删除生成 DELETE
Detached未被上下文追踪不可见

一个最小示例:

var movie = await context.Movies.FirstOrDefaultAsync(m => m.Id == id);
// 此时 EF Core 持有 movie 的原始快照,状态为 Unchanged

movie.Title = "New Title";
// 状态自动变为 Modified

await context.SaveChangesAsync();
// EF Core 比对快照,只生成针对 Title 列的 UPDATE

Change Tracker 还承担两项额外职责:标识解析(同一主键只返回一个对象实例)和导航属性自动填充(加载关联实体时自动连接引用)。关掉追踪,这两项也一并关掉。

追踪查询(默认行为)

默认情况下,所有返回实体类型的查询都是追踪查询:

// 追踪查询(默认行为)
var movies = await context.Movies.ToListAsync();
// EF Core 现在追踪所有返回的 Movie 实体
// 任何修改都会被 SaveChanges() 检测到

追踪查询对 CRUD 操作至关重要。典型更新端点示例:

app.MapPut("/api/movies/{id}", async (int id, UpdateMovieRequest req, MovieDbContext db, CancellationToken ct) =>
{
    var movie = await db.Movies.FirstOrDefaultAsync(m => m.Id == id, ct);
    // 追踪查询——EF Core 快照原始值
    if (movie is null) return Results.NotFound();

    movie.Title = req.Title;
    // 状态从 Unchanged 变为 Modified

    await db.SaveChangesAsync(ct);
    // EF Core 比对快照,生成精确的 UPDATE(只更新变化的列)
    return Results.NoContent();
});

标识解析与导航属性

同一上下文中多次查询同一主键时,追踪查询返回同一对象引用

var m1 = await context.Movies.FirstOrDefaultAsync(m => m.Id == 1);
var m2 = await context.Movies.FirstOrDefaultAsync(m => m.Id == 1);
ReferenceEquals(m1, m2); // true — 同一对象

EF Core 还会自动填充导航属性:加载 Review 时,若 Movie 已被追踪,Review.Movie 引用会自动指向对应实体。

非追踪查询(AsNoTracking)

非追踪查询完全绕过 Change Tracker——不创建快照、不做标识解析、不填充导航属性。实体以普通对象形式返回,快速、轻量、完全独立:

// 非追踪查询
var movies = await context.Movies
    .AsNoTracking()
    .ToListAsync();

这是 EF Core 只读查询中影响最大的单项性能优化。在 Web API 场景中,它适用于绝大多数端点:

// 只读列表端点——适合 AsNoTracking
app.MapGet("/api/movies", async (MovieDbContext db, CancellationToken ct) =>
{
    return await db.Movies
        .AsNoTracking()
        .OrderByDescending(m => m.ReleaseDate)
        .ToListAsync(ct);
});

非追踪时的标识解析问题

不使用追踪时,相同实体出现多次会生成多个独立对象。比如 50 部电影共享 5 位导演,带 .Include(m => m.Director) 的非追踪查询会产生 50 个 Director 对象(而非 5 个):

var m1 = await context.Movies.AsNoTracking().FirstOrDefaultAsync(m => m.Id == 1);
var m2 = await context.Movies.AsNoTracking().FirstOrDefaultAsync(m => m.Id == 1);
ReferenceEquals(m1, m2); // false — 不同对象

对于大多数 API 场景(直接序列化为 JSON),这不重要。但如果需要内存中的对象标识一致性,请注意这一点。

AsNoTrackingWithIdentityResolution:中间选项

EF Core 提供了第三个选项——兼顾非追踪的性能与追踪的标识解析:

var movies = await context.Movies
    .AsNoTrackingWithIdentityResolution()
    .Include(m => m.Director)
    .ToListAsync();

使用条件(需同时满足):

  1. 只读数据(不修改)
  2. 使用 .Include() 加载关联实体
  3. 关联实体被多个父实体共享(如多部电影同一导演、多个用户同一角色)

注意AsNoTrackingWithIdentityResolution() 不支持 Include 路径中存在循环引用,否则会触发运行时错误。

投影(Select)自动跳过追踪

这一点经常被忽视:当投影结果不是实体类型时,自动绕过追踪

// 不被追踪——结果是匿名类型
var summaries = await context.Movies
    .Select(m => new { m.Id, m.Title, m.ReleaseDate })
    .ToListAsync();

最佳实践:列表端点优先使用投影而非 AsNoTracking()——只获取需要的列,更少的数据传输,更少的内存分配,且没有追踪开销。

但如果投影包含实体实例本身,那些实体仍然会被追踪:

// m (Movie) 被追踪;DirectorName (string) 不被追踪
var results = await context.Movies
    .Select(m => new { Movie = m, DirectorName = m.Director!.Name })
    .ToListAsync();

BenchmarkDotNet 基准测试

用真实数据来验证。以下是三种追踪模式在不同数据集大小下的对比(PostgreSQL 17 + EF Core 10 + .NET 10):

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net100)]
public class TrackingBenchmarks
{
    private MovieDbContext _context;

    [Params(100, 1000, 10000)]
    public int RowCount { get; set; }

    [GlobalSetup]
    public void GlobalSetup()
    {
        var options = new DbContextOptionsBuilder<MovieDbContext>()
            .UseNpgsql(connectionString)
            .Options;
        _context = new MovieDbContext(options);
        // 数据库已预置 RowCount 条 Movie + Director 数据
    }

    [Benchmark(Baseline = true)]
    public Task Tracking() =>
        _context.Movies.ToListAsync();

    [Benchmark]
    public Task NoTracking() =>
        _context.Movies.AsNoTracking().ToListAsync();

    [Benchmark]
    public Task NoTrackingWithIdentityResolution() =>
        _context.Movies.AsNoTrackingWithIdentityResolution().ToListAsync();
}

测试结论:

指标结论
速度AsNoTracking 在大数据集下约比追踪查询快 2 倍(10K 行:31ms vs 68ms)
内存内存分配降低约 50%——快照存储是最大的开销
标识解析AsNoTrackingWithIdentityResolution 比普通 AsNoTracking 有额外开销,但仍快于全追踪
小数据集100 行时差异很小(1.8ms vs 1.2ms)——不要过度优化

决策指南

场景推荐模式原因
GET 列表端点(分页/过滤)AsNoTracking()只读,通常返回多行,性能收益最大
GET 详情端点(纯展示)AsNoTracking()只读,单实体,不需要修改
GET 详情 → 然后更新(同请求)默认(追踪)SaveChanges() 需要变更检测
Include + 共享关联实体AsNoTrackingWithIdentityResolution()防止内存中出现重复关联对象
Select 投影为 DTO/匿名类型无需额外标注匿名类型和 DTO 天然不被追踪
循环批量导入/大数据集处理AsNoTracking() + ChangeTracker.Clear()防止 Change Tracker 跨批次积累实体
后台作业读取大数据集AsNoTracking()降低长时间运行操作的内存压力

批量处理时使用 ChangeTracker.Clear()

在循环处理大数据集时,被追踪的实体会不断积累在 ChangeTracker 中,逐渐消耗更多内存,并拖慢每次 SaveChanges() 的速度:

for (int page = 0; page < totalPages; page++)
{
    var movies = await context.Movies
        .Skip(page * 500)
        .Take(500)
        .ToListAsync();

    foreach (var movie in movies)
        RecalculateRating(movie);

    await context.SaveChangesAsync();

    // 清空 Change Tracker——释放所有已追踪实体
    context.ChangeTracker.Clear();
}

不调用 ChangeTracker.Clear() 的话,处理到第 10 批时,上下文已持有 5,000 个被追踪实体,每次后续 SaveChanges() 都要扫描它们。

全局设为非追踪默认值

如果你的大多数查询都是只读的(这对 Web API 来说很典型),可以在 DbContext 级别将默认追踪行为改为非追踪:

// 注册时设置默认值
builder.Services.AddDbContext<MovieDbContext>(options =>
    options
        .UseNpgsql(connectionString)
        .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

此后所有查询默认非追踪,需要追踪时显式使用 AsTracking()

// 默认非追踪,自动生效
var movies = await context.Movies.ToListAsync();

// 需要追踪时显式启用
var movie = await context.Movies.AsTracking().FirstOrDefaultAsync(m => m.Id == id);
movie.Title = "Updated";
await context.SaveChangesAsync();

推荐方案:对大多数 Web API,将非追踪设为默认值是正确的选择。绝大多数端点是只读的,少数更新端点可以显式使用 AsTracking()。这样新端点默认快速,开发者必须有意识地为真正需要追踪的场景选择追踪。

常见陷阱

陷阱 1:用 AsNoTracking 后修改实体

// BUG:这个更新会被静默忽略!
var movie = await context.Movies.AsNoTracking().FirstOrDefaultAsync(m => m.Id == id);
movie.Title = "Updated";
await context.SaveChangesAsync(); // 什么都不做——movie 未被追踪

修复:要么去掉 AsNoTracking(),要么显式附加实体:

// 方案 1:使用追踪查询(推荐)
var movie = await context.Movies.FirstOrDefaultAsync(m => m.Id == id);

// 方案 2:附加游离实体并标记为已修改
context.Movies.Attach(movie);
context.Entry(movie).State = EntityState.Modified;
await context.SaveChangesAsync();

陷阱 2:全局设为非追踪后,更新端点忘记 AsTracking()

// BUG:默认非追踪时,此更新静默失败!
var movie = await context.Movies.FirstOrDefaultAsync(m => m.Id == id);
movie.Title = "Updated";
await context.SaveChangesAsync(); // 静默失败——默认非追踪

修复:更新端点始终显式使用 AsTracking()

var movie = await context.Movies.AsTracking().FirstOrDefaultAsync(m => m.Id == id);

陷阱 3:DbContext 生命周期过长

如果 DbContext 的生命周期过长(如错误地注册为 Singleton),Change Tracker 会随时间积累实体,持续消耗内存。

修复:始终将 DbContext 注册为 Scoped(每次 HTTP 请求一个实例,这也是 AddDbContext 的默认行为):

// 正确——Scoped 生命周期(AddDbContext 的默认值)
builder.Services.AddDbContext<MovieDbContext>(options =>
    options.UseNpgsql(connectionString));

总结

特性追踪查询AsNoTrackingAsNoTrackingWithIdentityResolution
Change Tracker✅ 启用❌ 跳过❌ 跳过
快照存储✅ 是❌ 否❌ 否
标识解析✅ 是❌ 否(重复对象)✅ 是
导航属性填充✅ 自动❌ 否❌ 否
内存用量~50% 更低中等
查询速度(大数据集)基准~2x 更快比追踪快,比 AsNoTracking 慢
适用场景CRUD 操作所有只读端点Include + 共享关联实体

核心原则:读多写少的 Web API,把非追踪设为默认值;真正需要写操作的端点,有意识地用 AsTracking() 选择追踪。这样性能默认最优,写操作也不会意外静默失败。

参考


Tags


Previous

用 Background Responses 处理 AI Agent 的长时运行操作

Next

用 Microsoft.Extensions.Options.Contextual 按运行时上下文动态配置选项