关于 optimizing-ef-core-queries
用于从源码、SQL 日志和查询计划诊断 EF Core 数据访问缓慢或错误的审查技能。它帮助开发者按证据优先处理问题,并保留查询行为、指出仍需运行时验证的结论。
主要功能
- 01追踪 EF Core 查询至执行
- 02检查往返次数与结果范围
- 03识别不利于索引的谓词
- 04审查跟踪行为与查询形态
- 05区分证据与假设
适用场景
- 01调查重复查询和 N+1 行为
- 02检查慢速分页或批量更新
- 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。
源代码审查工作流
- 找到 EF 边界:
DbContext、DbSet、存储库实现、工作单元、查询辅助程序或模型构建代码。追踪具体实现,不要臆测接口方法的行为。 - 追踪每个查询,直到执行或枚举。检查运算符绑定到
Queryable还是Enumerable,包括辅助程序内部的情况;IEnumerable也可能仍是延迟执行的数据库序列。 - 统计调用路径上的数据库操作和实际事务边界,包括立即写入和重复枚举。还要追踪哪些操作共用同一个
DbContext实例 - 针对每条执行路径,分别检查谓词、表达式构造、投影内容、结果范围、排序、Include、跟踪、SQL 参数化和模型构建代码。
- 保留原有契约:结果成员和顺序、随机分页访问、生成的键、重试、事务和部分失败行为、并发处理,以及领域/拦截器副作用。
- 按优先级报告发现的问题,注明位置、受影响的调用路径、具体影响和安全的修复方法。简要指出实现良好的对应部分,并说明哪些内容尚未验证。不要把基于源代码的推断当作已测量的执行计划或性能提升。
对于仅审查源代码的请求,应建议将运行时验证作为下一步。
对于原始 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 进行重复枚举 | 在转为本地运算符之前,先组合数据库聚合操作 |