mcpField
O
optimizing-ef-core-queries

optimizing-ef-core-queries

github.com/dotnet开发5.8k5.9k 次安装1 天前更新
代码仓库

关于 optimizing-ef-core-queries

用于从源码、SQL 日志和查询计划诊断 EF Core 数据访问缓慢或错误的审查技能。它帮助开发者按证据优先处理问题,并保留查询行为、指出仍需运行时验证的结论。

主要功能

  1. 01追踪 EF Core 查询至执行
  2. 02检查往返次数与结果范围
  3. 03识别不利于索引的谓词
  4. 04审查跟踪行为与查询形态
  5. 05区分证据与假设

适用场景

  1. 01调查重复查询和 N+1 行为
  2. 02检查慢速分页或批量更新
  3. 03评估查询计划与 SQL 日志

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dotnet/skills --skill optimizing-ef-core-queries

安装后在 Claude Code 中输入/optimizing-ef-core-queries确认已加载。

详细介绍

优化 EF Core 查询

诊断并修复基于 EF Core 的应用数据访问缓慢或不正确的问题。在仅审查源代码时,不执行代码,而是追踪代码,并区分已证实的代码行为与运行时假设。如果有 SQL、日志或执行计划,请用它们确认瓶颈。优先选择能减少往返次数、重复行、扫描或每次调用的转换开销的改动,而不是微优化。

适用场景

  • EF Core 查询缓慢,或生成的 SQL 语句远多于预期
  • 同一查询对每一行重复执行(N+1/延迟加载)
  • 多个集合 Include 导致数据量激增或行重复
  • 随着 Skip 增大,深分页变慢;或批量更新时仅为修改数据而加载行
  • 已筛选/排序的查询发生扫描,尽管该列已建立索引;或已筛选/排序的列缺少支持索引
  • 高频执行的热点查询每次调用都要承担 EF Core 的 LINQ 转换开销

不适用场景

  • 代码使用的是 Dapper 或原始 ADO.NET,而不是 EF Core。 直接回答 SQL/索引/查询计划相关问题;不要引入 DbContext,也不要建议使用 AsNoTracking、Include、AsSplitQuery 或其他 EF Core API。

源代码审查工作流

  1. 找到 EF 边界:DbContext、DbSet、存储库实现、工作单元、查询辅助程序或模型构建代码。追踪具体实现,不要臆测接口方法的行为。
  2. 追踪每个查询,直到执行或枚举。检查运算符绑定到 Queryable 还是 Enumerable,包括辅助程序内部的情况;IEnumerable 也可能仍是延迟执行的数据库序列。
  3. 统计调用路径上的数据库操作和实际事务边界,包括立即写入和重复枚举。还要追踪哪些操作共用同一个 DbContext 实例
  4. 针对每条执行路径,分别检查谓词、表达式构造、投影内容、结果范围、排序、Include、跟踪、SQL 参数化和模型构建代码。
  5. 保留原有契约:结果成员和顺序、随机分页访问、生成的键、重试、事务和部分失败行为、并发处理,以及领域/拦截器副作用。
  6. 按优先级报告发现的问题,注明位置、受影响的调用路径、具体影响和安全的修复方法。简要指出实现良好的对应部分,并说明哪些内容尚未验证。不要把基于源代码的推断当作已测量的执行计划或性能提升。

对于仅审查源代码的请求,应建议将运行时验证作为下一步。

对于原始 SQL,优先使用 EF Core 的插值重载(FromSql/FromSqlInterpolated 和 ExecuteSql[Async]),以便将值作为参数发送。

仅在证据充分时报告源代码审查发现

当用户要求仅审查源代码时,请区分已证实的缺陷与仍需工作负载或部署证据支持的优化建议:

  • 加载并修改数据的循环后只调用一次 SaveChanges[Async],不等于在循环中调用 SaveChanges。不要将其称为逐行往返缺陷;只有在操作规模确实很大,且绕过跟踪语义是安全的情况下,才建议使用批量 API。
  • 在某个提供的文件中未找到 HasIndex 调用,并不能证明已部署的数据库缺少索引。请以相关的模型构建代码、迁移或查询计划为依据。缺少此类证据时,不要用泛泛的条件式索引建议充斥源代码审查结果。
  • 只有已确认结果为只读实体时,缺少 AsNoTracking 才值得采取行动。仅包含标量的投影无需跟踪,但 DTO/record 中包含的实体默认仍会被跟踪。
  • 如果审查的所有模式都没有问题,请说明无需采取任何改动,而不要凭空制造问题。

捕获 SQL 和查询次数

优先使用已有日志和执行计划。若要进行可执行的调查,请启用命令日志记录,以验证变更前后的查询形态和次数:

optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
// or set "Microsoft.EntityFrameworkCore.Database.Command": "Information" in appsettings.json

使用 .TagWith("...") 为查询添加标记,以便在日志中找到它。统计慢速操作在每次变更前后执行的语句数,以及每条语句返回的行数。

修复

跟踪物化过程并限制结果数量

在辅助方法中进行物化,同样意味着到达了执行边界。列表或数组已经加载了行;之后的归约操作会在内存中运行。IEnumerable<T> 则可能隐藏延迟执行的 EF 查询:其 Enumerable 运算符会在本地处理行,而且每次枚举都可能再次执行数据库查询。应跟踪实现和静态类型,而不只是方法名。将可转换的筛选、投影和聚合保留在 IQueryable<T> 上,直到执行:

// Loads rows, then filters in memory.
var candidates = await repository.LoadCandidatesAsync(cancellationToken);
return candidates.Where(o => o.CreatedAt < cutoff).ToList();

// Composes and filters in SQL.
return await repository.QueryCandidates()
    .Where(o => o.CreatedAt < cutoff)
    .ToListAsync(cancellationToken);

布尔值存在性检查使用 AnyAsync;若需要数值总数,则保留 CountAsync。将重复的全序列汇总计算合并为服务端聚合,不要缓存无上限的列表,也不要在同一上下文上并行执行查询。

还应单独检查结果基数。对交互式结果进行分页或限制;对于完整导出,则通过流式处理或分块来减少缓冲,同时不截断数据约定。

使谓词兼容索引

对列应用函数或算术运算,或使用无锚点的子串匹配,通常会导致普通列索引无法进行索引查找。优先使用不对列做处理的比较,通常可采用左闭右开的范围。不要仅凭 LINQ 就断言会进行全表扫描:提供程序翻译、计算列索引/表达式索引以及其他谓词都可能改变执行计划。

// Computes a year expression instead of comparing the indexed date.
db.Logs.Where(l => l.CreatedAt.Year == year);

// Range compatible with an ordinary date index.
var start = new DateTime(year, 1, 1);
db.Logs.Where(l => l.CreatedAt >= start && l.CreatedAt < start.AddYears(1));

同一规则也适用于几种常见情况:

  • 不区分大小写的文本 — 使用已有的带索引规范化值或兼容的列排序规则,而不是对列进行包装。特定查询的排序规则变更,也可能导致无法使用排序规则不同的索引。保留规范化和比较语义。关系型提供程序通常不会翻译 StringComparison 重载;当前 EF Core 遇到无法翻译的谓词时会抛出异常,而不会静默地在本地筛选。
  • 计算表达式 — 与预先计算的常量进行比较,而不是使用 column * k > x。
  • 将列转换为其他类型 — 对 column.ToString() 的谓词(例如匹配数字或日期的文本形式,total.ToString().StartsWith(p))会对每一行应用函数,而且可能无法翻译。当前 EF Core 遇到无法翻译的谓词时会报告翻译失败,而不会静默地在客户端执行。应改为对类型化列使用实际比较或范围进行筛选。
  • 子串搜索 — 使用普通 B 树索引时,name.Contains(term) 通常无法进行索引查找;前缀匹配则可能可以。前缀匹配和基于词元的全文搜索都不能直接替代任意子串匹配。应保留所需的搜索模式,或明确说明变更;可以考虑适合相应提供程序的子串索引/搜索设计。

**验证:**如需运行时验证,请检查生成的谓词和执行计划。额外添加一个普通索引本身并不能修复不兼容的表达式。

编译频繁执行的热点查询

对于形状相匹配的查询,EF Core 已经会缓存翻译结果。对于非常热门且本身开销很小的查询,表达式处理和缓存查找仍可能造成影响。EF.CompileQuery / EF.CompileAsyncQuery 会复用可绕过该查找的委托;它们不会降低数据库 I/O 开销:

private static readonly Func<AppDbContext, int, ProductListItem> GetProduct =
    EF.CompileQuery((AppDbContext db, int id) =>
        db.Products.Where(p => p.Id == id)
                   .Select(p => new ProductListItem(p.Id, p.Name, p.Price))
                   .First());

public ProductListItem Lookup(AppDbContext db, int id) => GetProduct(db, id);

该委托是 static(仅编译一次),并将 DbContext 和每个参数作为参数接收。可用于以极高频率执行同一查询形状的端点或循环;对于只执行一次的查询则毫无作用。

**验证:**热点循环的平均耗时有所降低,且结果保持不变。

控制 SQL 参数化与数据库计划复用

区分 EF 的查询翻译缓存与数据库的执行计划缓存。捕获的 LINQ 值通常会成为 SQL 参数,而固定字面值仍会作为常量。参数会让不同值对应的 SQL 文本保持稳定,通常有助于复用数据库计划;字面值会向优化器暴露具体值,可能提高选择性估算的准确性,但大量不同的字面值可能导致服务器计划缓存碎片化。除非工作负载证据支持覆盖默认行为,否则请保留默认设置:

覆盖方式SQL 效果性能决策
EF.Parameter(value)(EF Core 9+)强制使用参数,包括字面值当需要在不同值之间复用 SQL 时使用;已捕获的参数通常无需覆盖
EF.Constant(value)(EF Core 8.0.2+)强制内联常量,包括已捕获的值仅在有证据表明特定值带来的计划收益超过额外 SQL 变体的成本时使用
db.Orders.Where(order => order.Status == EF.Parameter("Pending"));
db.Orders.Where(order => order.CustomerId == EF.Constant(customerId));

第一个查询会将固定状态参数化;第二个查询会针对每个客户 ID 生成专用 SQL。两者都不一定更快。不要将每个结构性字面值都参数化,也不要在没有证据的情况下内联高基数请求值。提供程序行为、参数敏感型计划、数据倾斜以及集合转换方式都会影响权衡。

在 EF Core 9 中,这些覆盖会在查询形状缓存之后处理:EF.Constant 可以复用 EF 的翻译,同时仍生成不同的 SQL 字面值。它并不等同于在动态树中使用 Expression.Constant 嵌入每个请求值。已编译委托无法解决服务器计划缓存碎片化问题;EF Core 9 也不支持在 EF.CompileQuery / EF.CompileAsyncQuery 中使用这些覆盖。

**验证:**在可以执行查询时,比较生成的 SQL、服务器编译次数/计划复用情况、估算值和有代表性的延迟。源码审查可以识别覆盖方式及其可能的权衡,但无法证明计划更快。

稳定动态构造的查询形状

沿着请求值追踪到表达式构造器。在比较表达式中使用 Expression.Constant(requestValue),可能导致每个值都产生一个新的 EF 缓存项和不同的 SQL。应改用类型正确的捕获值表达式来表示变化的值:

Expression<Func<string>> valueAccess = () => filterValue;
var comparison = Expression.Equal(propertyExpression, valueAccess.Body);

创建实体的 ParameterExpression 并不会将其比较值参数化。普通的已捕获 LINQ 参数本来就可以参数化;固定的结构性常量并不一定有问题。保留空值/转换语义,并且只有在安全时才规范化等价筛选条件的顺序。将构造器设为静态、按值缓存委托,或为每个新的动态表达式都进行编译,都无法稳定查询形状。应报告潜在的编译/缓存频繁变动,而不是断言存在已证实的无界内存泄漏。

针对实际结果图选择跟踪方式
结果和使用方决策
仅包含标量/值的投影无需移除实体跟踪;不要将添加 AsNoTracking 列为问题
后续需要编辑的实体或包含实体的 DTO保留跟踪及其身份语义
只读实体图考虑使用无跟踪;如果不需要完整实体,先缩小投影范围
包含大量共享实体或使用引用身份的只读图考虑使用 AsNoTrackingWithIdentityResolution,或按键处理 DTO

普通的 AsNoTracking 不会将重复键解析为同一个对象。身份解析模式会使用查询本地映射,而不会将结果附加到上下文;这会产生额外的簿记开销,且不会减少联接 SQL 的负载,也不会跨独立查询去重。

对于筛选后的 Include,请检查同一跟踪上下文中先前或后续加载的数据。导航修复可能会加入已跟踪但不符合 Include 谓词的子项;筛选后的导航也会被标记为已加载。如果子集很重要,请使用带独立子项筛选的分离投影、适当的无跟踪读取,或全新的读取上下文。不要仅为清理读取而清除仍有待处理工作的写入跟踪器。

消除 N+1 并避免延迟加载

同一个 SELECT 按行重复执行(在循环中访问导航)就是 N+1。通过一次往返加载相关数据——使用 Select 投影聚合数据,或使用 Include 预先加载:

var summaries = await db.Orders
    .Select(o => new OrderSummary(o.Id, o.Items.Count, o.Items.Sum(i => i.Price)))
    .ToListAsync();

优先使用投影或 Include,而非延迟加载:延迟加载是导致 N+1 的主要原因之一,并且会强制执行同步 I/O。在服务器应用中,不要启用 Microsoft.EntityFrameworkCore.Proxies,也不要将导航标记为 virtual 来实现延迟加载。

验证: 无论行数多少,查询次数都保持固定且较少。

区分联接导致的行数膨胀与宽行重复

并列的集合 Include 可能会相互乘增行数;嵌套集合链并非同样的笛卡尔积,引用不会算作额外集合。即使只有一个集合,也可能让宽大的父项 blob/文本列重复多次。如果使用方不需要该列或对象图,请优先使用窄投影。否则可考虑使用 AsSplitQuery 或定向加载:

db.Blogs.Include(b => b.Posts).Include(b => b.Contributors).AsSplitQuery();

拆分查询会增加语句数和往返次数,也可能缓冲结果。保留有意使用的 AsSingleQuery 和一致性要求;要确保多语句读取稳定,可能需要合适的快照/可串行化事务。分页结果需要唯一排序(尤其是 EF Core 10 之前的拆分分页);排序并不能确立实体身份。不要仅凭 Include 数量就建议拆分查询。

验证: 降低负载/行重复所带来的收益,大于额外往返的成本,并满足所需的一致性要求。

筛选和分页;优先使用键集分页,而不是偏移分页

使用 Where 限制大型结果集,并使用**键集(seek)**分页,而不是 Skip/Take;后者在深页时仍会扫描并丢弃跳过的行:

db.Orders.Where(o => o.Id > lastSeenId).OrderBy(o => o.Id).Take(pageSize);

按唯一且稳定的键排序,并确保有适当的索引覆盖。对于降序 (CreatedAt, Id) 游标,条件应为 CreatedAt < lastDate || (CreatedAt == lastDate && Id < lastId),而不是分别对两个字段设置边界。包含所有排序键及其排序方向;ID 不必遵循业务时间戳。

跟踪查询辅助方法和执行边界中的排序过程:

  • 对 IQueryable 再调用一次 OrderBy/OrderByDescending 会替换之前的主排序;次级键应使用 ThenBy。
  • OrderBy(...).Skip(...).Take(...).AsEnumerable().OrderBy(...) 会先选取稳定的数据库窗口,然后只在内存中重新排序这个有界窗口。这可能是有意为之,并不等于无序分页。
  • 不带稳定唯一排序的 Skip/Take 具有非确定性。深度偏移分页是另一个扩展性问题;对于随机页码访问,键集分页不能保持原有行为。
  • 即使偏移分页的排序完全确定,在并发插入/删除时,页面仍可能发生偏移;添加排序只能解决非确定性,不能保证跨请求的快照一致性。

**验证:**从前面的页面到深页,页面延迟大致保持恒定。

添加缺失的索引

将特定工作负载的索引覆盖审计与将筛选条件移入 SQL 分开处理。检查已应用的模型配置和约定。通过 HasForeignKey 推导每个相关外键的有序属性;除非已有索引/键覆盖了相同的有序前缀和所需的唯一性,或相关约定已被移除,否则 EF 通常会为该属性元组提供索引。不要只统计 HasIndex 调用,也不要把主体键误认为依赖表的索引覆盖。状态、时间戳和其他非键列可能需要显式覆盖。只针对有证据表明存在缺口的高频查询提出建议,不要在每次审查中都泛泛建议添加索引。

如果数据库架构由 EF Core 管理,请在模型中添加索引并创建迁移:

modelBuilder.Entity<Order>()
    .HasIndex(o => new { o.CustomerId, o.CreatedAt }); // equality column first, then range/sort

然后使用 dotnet ef migrations add ... 创建迁移。未经用户明确批准,不要使用 dotnet ef database update(或任何会写入数据库的等效命令)应用迁移——应用迁移会修改数据库,因此应先添加迁移并展示给用户,让用户审阅后自行运行更新。如果数据库架构不由 EF Core 管理,请向负责管理数据库的人建议添加相同的索引。不要过度添加索引——每个索引都会拖慢写入。

先匹配等值列,再匹配范围/排序键。中间存在未受约束的键时,即使前面的等值列已覆盖,也可能妨碍所需的范围筛选或排序。当所有前置键都使用等值条件时,多种顺序都可能有效;不要仅凭关系元数据就认定反向复合索引有问题。外键索引可能覆盖关系查询,但不一定覆盖按不同日期排序的工作负载。考虑额外添加工作负载索引,而不是替换支持其他查询路径的索引。模型代码证明的是预期元数据,而非当前物理索引;还需权衡写入成本和特定数据库提供程序的覆盖能力。

**验证:**执行计划使用查找/索引,而不是扫描。

通过仓储追踪持久化过程

服务循环可能会通过仓储、审计日志或进度钩子隐藏 SaveChanges。一次最终的工作单元提交不会撤销之前的写入;在声称每个项目都会单独提交之前,先确认是否存在外层事务。一次保存本身也可能需要多个 SQL 批次。

如果契约允许,可暂存有界批次并一起保存。保留审计/outbox 记录、生成的键、关系修复、顺序、并发控制和领域事件。保存后,从按输入顺序排列的实体引用中获取 ID;不要假设 ID 按数值顺序分配。

对于重试的 Feed,应考虑哪些内容已经提交,以及调用方可以重放什么。保留或设计持久化的行/Feed 标识,并实现唯一性和重放处理,或使用已确认的检查点。分块本身不具备幂等性,且缺乏保护的“先检查是否存在,再插入”操作无法避免竞态。

限制不断增长的导入中的变更检测

成功保存的实体仍会处于跟踪状态。启用自动检测后,ChangeTracker.Entries<T>()、HasChanges 和 SaveChanges 都会检查跟踪状态;按类型筛选条目并不会限制之前的扫描。即使按块保存,对不断增长的图逐行反复检查也可能导致超线性的 CPU 开销。

使用导入专属的进度计数器,避免反复枚举跟踪器;只有在该上下文由批次所有时,才轮换上下文或清除已完成的工作。保留待处理的变更和引用。EF Core 的 Add 和 AddRange 不会自动调用 DetectChanges;用其中一个替换另一个并不能解决检测问题。只有在确认存在多余扫描时,才抑制自动检测;在 finally 中恢复其原有设置,并在持久化/审计前按需显式检测变更。

基于集合的批量更新和删除

对于确实规模较大且简单、无需逐实体处理的更新/删除,可考虑使用 ExecuteUpdateAsync/ExecuteDeleteAsync(EF Core 7+,且需要受支持的提供程序)。它们无需实体化即可立即执行:

await db.Products.Where(p => p.LastSoldDate < cutoff)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.IsActive, false));

检查整个写入工作流:

  • 立即写入不会更新已加载实体的值或快照。之后对相同属性的变更可能覆盖 SQL 变更,即使是在单个顺序请求中。使用一致的跟踪保存,或显式同步/不跟踪的基于集合的路径;单靠事务不会刷新跟踪状态。
  • 这些 API 不会自动检查已配置的并发令牌。需要乐观并发控制时,应保留令牌谓词、令牌更新和受影响行数处理。
  • 每次调用都会单独执行;之后的 SaveChanges 既不会将它们批处理,也不会使其与审计/outbox 写入保持原子性。需要时,应使用一次保存操作或显式共享事务。
  • 保留客户端级联和基于 SaveChanges 的钩子。命令拦截器则不同,仍可观测到 SQL。不要仅为了减少查询次数而绕过领域行为。

**验证:**单条带有 WHERE 且之前没有 SELECT 的 UPDATE/DELETE。

避免重叠执行 DbContext 操作

EF Core 不支持在同一个 DbContext 上重叠执行操作,包括通过使用该上下文构造的不同仓储对象执行操作。只要在等待任一调用前同时启动两个调用就足够造成问题;Task.WhenAll 并不能让共享变得安全。应依次等待,或为每个并发操作创建并释放一个独立上下文。通过工厂创建的独立上下文可以安全地并行执行。由静态成员或单例持有的上下文也会带来生命周期/线程安全隐患。

常见陷阱

陷阱解决方法
函数包装的谓词与普通索引不兼容使用范围查询,或使用已有的索引规范化值/计算值
修正了查询组合,却忽略了工作负载的索引覆盖情况根据实际的等值/范围/排序前缀匹配索引,包括按约定创建的索引
编译只偶尔运行的查询仅编译真正高频、热点的查询形态
混淆 EF 转换复用与数据库执行计划复用检查 SQL 参数/字面值的选择,并用工作负载证据证明覆盖设置合理
DTO 包含实体,却被误认为不会跟踪检查已物化的对象图,而不只是外层返回类型
批量写入后,对过时值进行跟踪的算术操作统一写入并保留并发性/原子性;不要只是添加事务
对仓储延迟执行的 IEnumerable 进行重复枚举在转为本地运算符之前,先组合数据库聚合操作

参考资料

评论

支持 Markdown