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 慢查询排查顺序
- 先看 SQL 形态(是否放大)
- 再看索引命中
- 最后看 EF 物化和跟踪开销
这个顺序能避免在错误方向上“调 ORM 参数”。
4.4 建立基线
对核心查询做固定数据量压测,记录:
- 平均耗时
- P95
- 每请求分配内存
- 数据库逻辑读
没有基线,优化结果只能靠感觉。
5. 总结
EF Core 查询优化的关键,不是“把 ORM 用得更花”,而是把查询职责拆清楚:
- 读取就投影
- 更新才跟踪
- 复杂对象图按需拆分
真正的工程收益来自可预测性。你能解释每一个查询为什么快,团队才敢在业务增长时持续演进。
本文来自博客园,作者:ryan-deng,转载请注明原文链接:https://www.cnblogs.com/denglei1024/p/19688983
浙公网安备 33010602011771号