资讯动态

使用 dotnet-data 插件的 optimizing-ef-core-queries 技能系统化诊断与优化 EF Core 慢查询

发布时间:2026/9/18 8:46:18 来源:尧图企业网站定制
使用 dotnet-data 插件的 optimizing-ef-core-queries 技能系统化诊断与优化 EF Core 慢查询【免费下载链接】skillsRepository for skills to assist AI coding agents with .NET and C#项目地址: https://gitcode.com/GitHub_Trending/skills17/skills本篇技术指南围绕本仓库dotnet-data插件中的optimizing-ef-core-queries技能展开系统讲解如何为 Entity Framework CoreEF Core应用诊断并修复慢查询从捕获生成的 SQL 与查询计数入手覆盖可搜索sargable谓词、编译查询、N1 消除、拆分集合 Include、Keyset 分页、索引补齐与基于集合的批量更新等完整优化手段。读者学完后可以对照本仓库配套的审计场景与微基准测试micro-benchmark工程形成先测量、单点修改、再复测的 EF Core 性能优化实战能力。技能定位何时该用、何时不该用optimizing-ef-core-queries是plugins/dotnet-data插件版本0.1.5见 plugin.json提供的一项代码生成/重构类技能其定义位于 SKILL.md 的 frontmatter它面向 EF Core 查询或数据访问路径变慢、或应当变得更快 的场景无论 EF Core 是否拥有数据库 schema 的所有权。其能力边界非常明确——只针对 EF Core不适用于 Dapper 或原生 ADO.NET。何时使用EF Core 查询很慢或产生的 SQL 语句数量远超预期同一查询在循环里逐行重复执行N1 / 延迟加载多个集合Include导致结果集膨胀或行数重复笛卡尔爆炸深分页时随着Skip增大性能持续劣化或批量更新为了改几行而先把整批实体加载进内存过滤/排序查询即使列上已有索引仍在扫描或过滤/排序列根本没有支撑索引热路径上的高频查询每次调用都在支付 EF Core 的 LINQ 翻译成本。何时不该用代码使用的是Dapper 或原生 ADO.NET此时应直接回答 SQL / 索引 / 执行计划问题不要引入DbContext也不应推荐AsNoTracking、Include、AsSplitQuery等 EF Core 专属 API。这一边界在本仓库的评估eval配置中得到了印证eval.yaml 将技能置于一个开箱即用的后台运营服务审计场景中要求 Agent 原地改写一个刻意堆满各类反模式的SalesOperationsService.cs并逐方法解释改动原因。第一步先捕获生成的 SQL再谈优化你看不见的东西就无法优化。技能要求在任何改动之前先打开命令日志读取 SQL 与查询计数optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information); // 或在 appsettings.json 中设置 Microsoft.EntityFrameworkCore.Database.Command: Information同时建议用.TagWith(...)为查询打标签便于在日志中定位每次改动前后都要统计一次慢操作执行的语句数量以及每条语句返回的行数形成可对比的量化基线。这与技能的核心方法论一致先看 SQL 与查询数量用最小改动消除瓶颈再重新读 SQL 与计数来确认修复生效优先选择能减少往返次数、重复行、全表扫描或单次翻译成本的改动而非微优化一次只改一处并重新测量。让谓词可搜索永远不要把索引列包进函数索引只有在索引列裸出现在比较符一侧时才能被使用。把列包进函数或算术运算——CreatedAt.Year y、CreatedAt.Date d、ToLower(Name) n、Price * 1.1 x或前导通配的LIKE %foo——会强制对每一行做计算索引无法满足于是查询即使存在索引也全表扫描。此时再加索引也无济于事。正确写法是把谓词改写为列保持裸形式的半开区间// 非可搜索每一行都要计算函数 → 全表扫描 db.Logs.Where(l l.CreatedAt.Year year); // 可搜索裸列与常量比较 → 索引查找index seek var start new DateTime(year, 1, 1); db.Logs.Where(l l.CreatedAt start l.CreatedAt start.AddYears(1));同一规则覆盖几种常见形态大小写不敏感的文本比较存储好的规范化列而不是ToLower(...)/ToUpper(...)计算表达式与预计算的常量比较而不是column * k x把列转成其他类型对column.ToString()的谓词例如匹配数字或日期的文本形式total.ToString().StartsWith(p)会对每一行应用函数且常常根本无法翻译为 SQL被迫在客户端求值把整张表拉进内存——应在有类型的列上用真正的比较或范围过滤子串搜索name.Contains(term)会变成无法利用索引的LIKE %term%而扫描全表尾随通配的前缀匹配name.StartsWith(term)→term%则可以 seek。能接受前缀匹配时就把搜索锚定注意这改变了匹配的行的集合需先确认行为大表上的真正子串或模糊搜索应交给全文索引。验证方法执行计划显示 seek/index 而非 scan且耗时下降。若该列确实没有索引再考虑补索引见下文——但前提是谓词已经可搜索。本仓库的审计夹具把这一反模式直接放进了代码SalesOperationsService.cs 中GetCustomerSales使用o.CreatedAt.Year year包住日期列同时每个客户循环发一次订单查询N1 非可搜索谓词双重问题eval.yaml 的 rubric 明确要求识别把列包进函数导致无法索引查找应改为可搜索的日期范围比较。编译热路径查询消除每次调用的 LINQ 翻译成本在非常热、且在同一复用上下文中以同一查询形态执行数千次的路径上EF Core 每次调用都要重新解析 LINQ 表达式树并探测查询缓存。当查询本身已经最小化索引查找或小投影且AsNoTracking等只读微调已无收益时每次调用的翻译成本就是剩余瓶颈。此时用EF.CompileQuery/EF.CompileAsyncQuery把查询编译一次并复用委托private static readonly FuncAppDbContext, 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与各查询参数。它只适用于以极高频率执行同一查询形态的端点或循环对一次性查询毫无帮助。验证方法热循环的平均耗时下降且结果完全一致。本仓库的基准设施专门验证了这一优化Program.cs 中BenchGetProductForCard用一个长期复用、跨 3000 次查询的上下文模拟池化热路径注释明确说明每次调用的查询构建开销占主导这正是编译查询要消除的并要求改写版结果等价且至少快 10%improvedTrue数据由 Seed.cs 确定性生成。而审计夹具中的GetProductForCard就是这样一个全应用最热路径、每秒执行数千次的最小查询。消除 N1避免延迟加载循环内访问导航属性导致同一条SELECT每行重复执行就是 N1。应该用一次往返加载关联数据——用Select投影聚合或用Include预加载var summaries await db.Orders .Select(o new OrderSummary(o.Id, o.Items.Count, o.Items.Sum(i i.Price))) .ToListAsync();投影或Include都优于延迟加载延迟加载是 N1 的头号原因还会强制同步 I/O。服务端应用不应启用Microsoft.EntityFrameworkCore.Proxies也不应把导航属性标记为virtual以便延迟加载。验证方法查询数量固定且很小不随行数增长。这在本仓库审计场景中有完整的对照样本GetCustomerSales先_db.Customers.ToList()再逐客户查询订单300 个客户就是 301 次往返eval.yaml的 rubric 要求识别为每个客户各发一次订单查询N1并合并为单个基于集合的查询同时要求把订单数与总额的聚合交给数据库计算而非加载到内存后再算此外HasOrders的Count() 0应改为短路的存在性检查如Any()GetOrdersOverTotal用AsEnumerable()把过滤拉进内存也是同一类问题。拆分多个集合 Include避免笛卡尔爆炸一次查询Include两个及以上集合导航会成倍放大行数笛卡尔爆炸并重复父数据。改用AsSplitQuery()让每个集合各自一条语句加载同时按唯一键OrderBy保证行能正确拼接db.Blogs.Include(b b.Posts).Include(b b.Contributors).AsSplitQuery();验证方法每条语句的行数大幅下降总耗时改善。审计夹具的GetCustomerDetail正是这一反模式的教科书示例一次查询同时Include了Orders及其Lines与Invoices两个独立集合。基准种子Seed.cs 的CustomerDetail为单个客户准备了 150 个订单 × 2 行项目与 100 张发票单条组合查询会让结果行数成倍相乘eval.yamlrubric 明确要求识别一次查询加载两个独立相关集合导致的笛卡尔行爆炸并建议拆分为独立查询。过滤与分页优先 Keyset游标而非 Offset用Where约束大结果集分页优先使用keysetseek分页而不是Skip/Take——后者在深分页时仍会扫描并丢弃被跳过的行db.Orders.Where(o o.Id lastSeenId).OrderBy(o o.Id).Take(pageSize);按唯一、稳定、有索引的键排序排序列不唯一时补充并列键 tie-breaker。Keyset 按上一页看到的最后一个键翻页而非页码因此会改变方法签名当固定签名不允许原地切换时仍应指出深偏移扫描的问题并推荐 keyset。验证方法从浅页到深页页面延迟基本恒定。审计夹具的GetOrderPage是刻意保留的反例它接受pageIndex与pageSize参数、按Id排序后Skip(pageIndex * pageSize).Take(pageSize)。由于在冻结的页码签名下没有可原地替换的更快形式共 12 个方法中它是唯一的例外eval.yaml 明确说明该方法的唯一真正修复是 keyset 分页因此它不做基准计时改由 rubric 评分——要求识别offset 分页让数据库每页都遍历并丢弃之前的所有行深页越来越慢应改为从已显示的最后一行继续的 keyset/seek 分页。补齐缺失的索引与查询改写相互独立的第二项修复把过滤移进 SQL、或让谓词可搜索只消除了客户端的浪费但WHERE或ORDER BY的列没有索引时数据库内部仍会全表扫描——高频查询每次调用都会重新扫描。因此索引覆盖要单独审计逐个查询检查过滤列与排序列是否由索引支撑。实体主键与外键按约定自动建索引但其他列——状态标志、状态/枚举字段、时间戳、名称——通常不会除非模型显式配置。当热查询在这些无索引列上过滤或排序时即使改写后的查询已返回正确结果也要明确指出并建议加索引索引是与代码改动相互独立的另一项修复仅改代码不会带来它。若谓词不可搜索则先修谓词——新索引帮不了由列上函数引起的扫描。若 EF Core 拥有 schema则在模型中加索引并迁移modelBuilder.EntityOrder() .HasIndex(o new { o.CustomerId, o.CreatedAt }); // 等值列在前范围/排序列在后然后用dotnet ef migrations add ...创建迁移。不要未经用户明确同意就执行dotnet ef database update或任何会写库的等价操作——应用迁移会变更数据库所以应先生成迁移、展示给用户由用户审阅后再自行执行更新。若 EF Core 不拥有 schema则把同样的索引建议转交给数据库管理方。同时注意不要过度建索引——每个索引都会拖慢写入。验证方法执行计划使用 seek/index 而非 scan。本仓库的审计场景明确测试了代码无法单独完成的索引建议GetPendingOrders在Status无索引上过滤并按CreatedAt排序eval.yaml 的 rubric 要求建议为频繁的 pending 状态过滤与日期排序添加索引同时混合评分中prompt 裁判会为仅靠代码无法添加的索引建议加分——这正是技能系统性覆盖能捕捉到、而盲审容易遗漏的隐性点。基于集合的批量更新与删除用ExecuteUpdateAsync/ExecuteDeleteAsyncEF Core 7替换加载-修改-SaveChanges循环——一条语句完成不物化任何实体await db.Products.Where(p p.LastSoldDate cutoff) .ExecuteUpdateAsync(s s.SetProperty(p p.IsActive, false));这些 API 绕过变更跟踪器change tracker与 EF 侧的级联行为——相关变更需显式处理。验证方法只有一条带WHERE的UPDATE/DELETE且前面没有SELECT。审计夹具的ApplyCategoryDiscount是逐行往返的典型循环内product.Price * factor; product.UpdatedAt ...; _db.SaveChanges();每个产品一次往返还要先把整批产品实体加载进内存。eval.yaml 的 rubric 要求识别循环内每产品一次SaveChanges、每行一次往返应改为单条基于集合的更新其基准在每次迭代用全新种子数据库验证改写版结果一致且更快Program.cs 的BenchApplyCategoryDiscount。同一思想还适用于CountActiveProducts的_db.Products.ToList().Count(...)——应让数据库执行计数。常见陷阱速查陷阱修复把索引列包进.Year/.Date/ToLower/算术运算改写成对裸列的可搜索范围/比较为非可搜索谓词导致的扫描加索引先修谓词列保持裸形式之前索引帮不上忙把过滤改写进 SQL却让热查询留在无索引列上服务端扫描仍是扫描——同时也应为过滤/排序列建议索引编译只偶尔执行的查询只编译真正热、高频的查询形态延迟加载proxies /virtual导航引发 N1 与强制同步 I/O预加载Include或投影保持查询异步在Where/Select前调用ToList()/AsEnumerable()保持查询为IQueryable让过滤/投影在 SQL 中完成配套评估与基准如何验证优化是否真实生效本仓库为这个技能配套了一整套可复现的评估工程位于 tests/dotnet-data/optimizing-ef-core-queries/可以作为练习与验证环境审计夹具audit-fixtures/SalesOperationsService.cs把实体模型、DbContext与 12 个方法仪表盘、订单/产品查询、夜间维护任务集中在一个自包含文件里刻意塞入上文的全部反模式要求 Agent 原地改写并逐方法解释微基准工程benchmark-fixtures/其中 Baseline.cs 保留了每个方法的原始实现作为改写前对照Program.cs 在同一份种子数据上分别运行基线版与改写版要求结果完全一致且至少快 10%才判定improvedTrueTiming.cs 通过预热、双臂各自独立连接避免 SQLite 页缓存互相影响、交替计时取中位数等方式防御测量偏差Seed.cs 用固定随机种子确定性生成 300 个客户 × 12 个订单、150 行/单的订单明细、2 万条目标客户订单、1.5 万张发票、5000 个带 1500 字符Description与 2000 字节Image宽列的产品等数据量级保证原始实现明显慢于正确改写运行方式每个可基准方法一条命令例如dotnet run -c Release --project perf -- --method GetCustomerSales见 eval.yaml 的 graders 配置11 个可基准方法均要求stdout_contains: improvedTrueGetOrderPage因签名冻结改由 rubric 评判。这套工程生动体现了技能的方法论闭环先捕获 SQL 与计数 → 应用最小改动 → 用同一数据复测结果等价性与耗时——与 SKILL.md 开头一次只改一处并重新测量的要求完全一致。进一步阅读技能文档末尾引用了 EF Core 官方主题SKILL.md 的 References 一节高效查询Efficient querying、编译查询Compiled queries、高效更新ExecuteUpdate/ExecuteDelete、单查询与拆分查询Single vs. split queries、分页Pagination与索引Indexes。结合本仓库的 eval.yaml 与审计/基准夹具即可把文档原则转化为可测量、可验证的 EF Core 性能优化实战。【免费下载链接】skillsRepository for skills to assist AI coding agents with .NET and C#项目地址: https://gitcode.com/GitHub_Trending/skills17/skills创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价