EF Core 查询性能黑洞:Include、投影与跟踪策略的边界

很多团队把 EF Core 的性能问题归因于“ORM 天生慢”,但线上真实情况通常是:

  • 查询写法对 SQL 形态不敏感
  • 默认跟踪被滥用
  • 图省事一次 Include 到底

结果是接口能跑,但高峰时段 P95 持续抬高,数据库 CPU 和网络带宽一起被拖上去。

这篇文章聚焦一个目标:把 EF Core 查询从“能查到数据”升级到“可预测、可解释、可优化”。

1. 问题背景:列表页为什么越改越慢

一个典型场景:订单列表页需要展示订单、客户、明细、商品。

最直觉的写法是连续 Include

var orders = await db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
    .Where(o => o.CreatedAt >= from && o.CreatedAt < to)
    .OrderByDescending(o => o.CreatedAt)
    .Take(50)
    .ToListAsync();

这段代码看起来“很完整”,但常见后果是:

  • 结果集行数被笛卡尔放大
  • 应用端做了大量重复对象反序列化和跟踪
  • 实际只显示 6 个字段,却把整个对象图都拉回来了

2. 原理解析:EF Core 查询成本主要花在哪里

2.1 翻译成本

LINQ 先被转换为表达式树,再翻译成 SQL。复杂投影、方法调用、局部函数容易导致翻译退化或直接失败。

2.2 执行与网络成本

Include 深度越深,JOIN 越复杂,网络回包越大。很多慢查询不是数据库“算得慢”,而是“传得多”。

2.3 跟踪成本

默认跟踪模式会创建实体快照,便于更新,但纯读场景这部分是额外开销。

2.4 物化成本

即使 SQL 很快,应用层物化大量实体和导航属性也会抬高 CPU 与内存分配。

3. 示例代码:从全量实体到精准投影

先给出推荐的读模型查询写法:

public sealed record OrderListItemDto(
    long Id,
    string OrderNo,
    string CustomerName,
    decimal TotalAmount,
    int ItemCount,
    DateTime CreatedAt);

var query = db.Orders
    .AsNoTracking()
    .Where(o => o.CreatedAt >= from && o.CreatedAt < to)
    .OrderByDescending(o => o.CreatedAt)
    .Select(o => new OrderListItemDto(
        o.Id,
        o.OrderNo,
        o.Customer.Name,
        o.Items.Sum(i => i.Quantity * i.UnitPrice),
        o.Items.Count,
        o.CreatedAt));

var page = await query.Take(50).ToListAsync();

对于高频、形态固定的查询,可以进一步使用编译查询:

private static readonly Func<AppDbContext, DateTime, DateTime, int, IAsyncEnumerable<OrderListItemDto>>
    QueryOrderPage = EF.CompileAsyncQuery(
        (AppDbContext db, DateTime from, DateTime to, int take) =>
            db.Orders
              .AsNoTracking()
              .Where(o => o.CreatedAt >= from && o.CreatedAt < to)
              .OrderByDescending(o => o.CreatedAt)
              .Select(o => new OrderListItemDto(
                  o.Id,
                  o.OrderNo,
                  o.Customer.Name,
                  o.Items.Sum(i => i.Quantity * i.UnitPrice),
                  o.Items.Count,
                  o.CreatedAt))
              .Take(take));

var result = new List<OrderListItemDto>();
await foreach (var item in QueryOrderPage(db, from, to, 50))
{
    result.Add(item);
}

如果确实需要多个集合导航,优先评估 AsSplitQuery(),避免单 SQL 被放大成巨型 JOIN。

4. 工程实践建议

4.1 明确“查询分层”

  • 写模型:实体 + 跟踪,用于更新
  • 读模型:DTO 投影 + AsNoTracking()

不要让一个查询同时承担“展示”和“更新”职责。

4.2 评审清单必须落地

每个新查询上线前至少确认:

  • 是否只取了页面真正需要的字段
  • 是否误用了默认跟踪
  • 是否出现多集合 Include
  • ToQueryString() 生成 SQL 是否可读、可控

4.3 慢查询排查顺序

  1. 先看 SQL 形态(是否放大)
  2. 再看索引命中
  3. 最后看 EF 物化和跟踪开销

这个顺序能避免在错误方向上“调 ORM 参数”。

4.4 建立基线

对核心查询做固定数据量压测,记录:

  • 平均耗时
  • P95
  • 每请求分配内存
  • 数据库逻辑读

没有基线,优化结果只能靠感觉。

5. 总结

EF Core 查询优化的关键,不是“把 ORM 用得更花”,而是把查询职责拆清楚:

  • 读取就投影
  • 更新才跟踪
  • 复杂对象图按需拆分

真正的工程收益来自可预测性。你能解释每一个查询为什么快,团队才敢在业务增长时持续演进。

posted @ 2026-03-09 11:21  ryan-deng  阅读(8)  评论(0)    收藏  举报