我先说一个印象很深的排查场景。某个订单后台系统的导出接口某天突然被运营同事投诉点击导出之后页面转圈接近十秒才出结果。我打开监控面板一查这个接口的平均响应时间已经从一周前的120ms涨到了4.7s。第一反应是数据库出问题了可数据库监控显示CPU、IO都平稳。那问题八成不在数据库而在于EF Core生成的查询本身。这个标题在很多人看来有点“老生常谈”但实际踩过坑的人都知道Include、投影、跟踪策略这三个东西单拎出来谁都认识组合在一起就是性能黑洞高发区。这篇文章不打算讲基础语法而是沿着一次真实的性能劣化过程把Include的代价、投影的边界、AsNoTracking的隐藏开销一次说透最后附上可复用的排查链路。1. 一次线上事故引发的思考从120ms到4.7s的查询劣化现场1.1 事故表象一个导出接口的响应曲线那个接口本身不复杂就是一个订单列表导出功能根据时间范围筛选订单连同订单明细、商品编码、发票信息一起查出来然后组装成Excel。最初版本数据量小用户一次最多导几百条响应时间稳定在120ms左右。后来业务增长数据量翻了十几倍接口开始明显变慢。我当时的第一反应是“是不是索引失效了”。结果数据库侧的索引使用情况正常逻辑读也不算离谱。真正让我警觉的是应用服务器CPU飙升到了85%以上而数据库CPU基本没动。这个信号很明确慢的不是数据库是EF Core在客户端做的事太多。1.2 第一眼五个LEFT JOIN和乱码一样的结果集我直接在开发环境拉了一下这个接口生成的SQL看到的是五个LEFT JOIN。代码大概长这样var orders context.Orders .Include(o o.Items) .ThenInclude(i i.Sku) .Include(o o.Invoices) .Where(o o.CreateTime start o.CreateTime end) .OrderByDescending(o o.CreateTime) .ToList();这条查询在EF Core里执行时默认是单个SQL语句把所有Include的导航属性全部用JOIN拼在一个结果集里返回。听着没什么问题但“一个结果集”的前提是行数可控。实际情况是100个订单每个订单平均30条明细每个明细对应1个SKU每个订单还有2张发票。单单Items的JOIN就把结果集拉到了3000行再叠加Invoices的JOIN结果集行数变成了6000行左右。为什么因为单查询模式下EF Core为了同时加载两个集合导航属性会在SQL层面做类似笛卡尔积的关联——每个主表行会和第一个集合的所有行配对再和第二个集合的所有行配对。结果集行数不是简单的加法而是乘法级别的膨胀。这还没到最坏的情况。如果Include三个集合导航属性行数会进一步爆炸。EF Core拿到这些扁平行之后还要在客户端做实体拆分把6000行数据重新组装回100个订单、3000个明细、200个发票实体。这一个查询下来应用服务器CPU不高才怪。2. Include到底“贵”在哪里JOIN膨胀、实体拆分与客户端计算2.1 集合导航属性引发的行数乘法Include加载引用导航属性比如订单的所属客户是安全的因为多对一JOIN不会增加行数一条订单还是对应一行。真正危险的是集合导航属性一对多比如订单带明细、明细带SKU、订单带发票。当Include了多个集合导航属性时EF Core的单查询模式会让所有集合做组合。举例一个订单有30条明细、2张发票那么SQL返回的订单行数是多少按照JOIN顺序先是订单 LEFT JOIN 明细30行再 LEFT JOIN 发票这30行每行再匹配2张发票最终60行。100个订单就是6000行。也就是说集合导航属性每多一个结果集行数就会“乘”一个新的倍数。三个集合在数据量大时的膨胀程度远远超出大多数人直觉。这是一个很容易被忽略的点你以为Include只是在SQL层面多JOIN两张表实际上它在传输层和应用层都放大了数据量。6000行数据从数据库传到应用服务器再被EF Core解析成实体对象这个过程网络IO、内存分配、CPU消耗全部被放大。2.2 实体拆分与客户端组装EF Core客户端要做的工作比很多人想象得多。扁平的结果集里同一行会包含订单、明细、SKU、发票的多个列EF Core需要根据列前缀通常B1、B2这种别名把每一行拆开归入对应的实体实例。更重要的一点默认跟踪模式下这些实体实例会被加入ChangeTracker。每个实体都要在状态管理器里注册保存快照信息记录原始值。6000行扁平数据解析完ChangeTracker里可能躺着100个订单、3000个明细、200个发票实体内存里同时维护着字典索引和关系修复用的引用关系。结果就是数据库返回6000行也许只要几十毫秒但EF Core把它们变成可用的实体图可能要花掉几百毫秒甚至更久。应用服务器CPU飙到85%就是这么来的。注意只要你的查询是只读展示场景就完全没有必要让实体进ChangeTracker。但很多人写代码时根本不会主动加AsNoTracking默认跟踪策略就这么白白消耗着内存和CPU。2.3 AsSplitQuery的引入与适用前提EF Core 5.0之后提供了AsSplitQuery()它的思路很简单把原来一个包含多次JOIN的查询拆成多个独立查询主表查一次每个集合导航属性单独查一次最后在客户端按外键组装。var orders context.Orders .Include(o o.Items) .ThenInclude(i i.Sku) .Include(o o.Invoices) .AsSplitQuery() .Where(o o.CreateTime start o.CreateTime end) .OrderByDescending(o o.CreateTime) .ToList();拆分之后每个查询返回的行数只和当前集合相关不再和其他集合组合。100个订单3000明细200发票拆成三个查询第一个查询返回100行第二个返回3000行第三个返回200行。总传输行数从6000下降到了3300而且客户端解析时不需要做笛卡尔积层面的重复组装压力小了很多。但是AsSplitQuery不是银弹。它用“多次数据库往返”换“减少行数膨胀”。在远程数据库、高延迟、主表数据量大的场景下拆分查询反而可能更慢。它最适用的场景是主表记录数不多但每个主记录下面的集合行数较大且应用服务器和数据库之间的网络延迟不太夸张。3. 投影并非万能Select的隐式行为、翻译边界与杂交写法3.1 Select投影为什么能逆天改命在排查那个接口的时候我做过一个实验给同一个查询加上投影只Select需要的字段其他代码不动性能立刻好了很多。原因是投影改变了EF Core的整个查询策略。var orders context.Orders .Where(o o.CreateTime start o.CreateTime end) .OrderByDescending(o o.CreateTime) .Select(o new OrderExportDto { OrderId o.Id, CreateTime o.CreateTime, TotalAmount o.TotalAmount, Items o.Items.Select(i new OrderItemExportDto { SkuCode i.Sku.Code, Quantity i.Quantity }).ToList() }) .AsNoTracking() .ToListAsync();这段代码比之前的Include链看起来更复杂但实际生成的SQL反而更克制。EF Core在投影模式下会为顶层和集合子查询生成SQL只取你需要的列。SkuCode、Quantity、OrderId这些字段出现在SELECT列表中不会像Include那样把订单、明细、SKU的所有列全部塞进结果集。同时由于投影出来的目标类型不是实体类型EF Core不会为这些DTO维护ChangeTracker状态。内存里只有你真正要展示的数据快照、原始值、关系修复全部不参与。我在本地环境把那段代码改完之后接口响应时间从4.7s降到了210ms左右。注意这个数字并不是优化技巧本身的结果而是因为查询从“加载完整实体图”变成了“加载精确数据形状”。3.2 三个容易被忽略的投影陷阱投影看起来很美好但有几个坑必须知道。第一个坑Include和Select杂交时Include会被忽略。如果你写了.Include(o o.Items).Select(o new SomeDto { ... })EF Core会直接忽略Include——因为投影目标是非实体类型不需要构建实体图。这个行为经常导致一种错觉开发者以为两个加载方式叠加了实际上Include白写了查询结果完全由Select决定。代码里同时存在Include和Select容易误导别人建议直接删掉Include。第二个坑投影里调用自定义方法会触发客户端评估。比如.Select(o MyHelper.Convert(o))EF Core无法把自定义CLR方法翻译成SQL早期版本可能在客户端执行数据库会把整张表的数据拉过来。EF Core 3.0以后虽然严格了很多该报错就报错但个别场景下仍会有意外。投影表达式里尽量只使用可翻译的字段访问和标准转换不要塞业务方法。第三个坑嵌套集合投影在不同EF Core版本下有差异。上面的Items o.Items.Select(...).ToList()这种写法在EF Core 5.0有专门的翻译路径但如果你的项目还在老版本可能生成不了预期的SQL。最稳妥的做法是实际拉一下SQL日志看清楚或者干脆分层查询先查订单DTO再按订单ID集合查明细DTO内存中合并。后者代码多几行但SQL简单可控性强。投影的另一个优势是可组合性。你可以把投影DTO定义成只包含页面展示字段配合分页、筛选、排序都很自然。相比之下Include完整实体图再手动映射DTO不仅性能差还容易出现“该展示的字段没映射、不该展示的字段全查了”这种尴尬局面。4. 跟踪状态管理是性能的隐形开关AsNoTracking的得与失4.1 默认跟踪在批量查询场景的额外开销很多项目在查询列表时没有加AsNoTracking因为它不影响查询结果——返回的数据一模一样。但结果一样不代表开销一样。默认跟踪模式下EF Core会把查询返回的实体注册到ChangeTracker中。这件事包含三块成本实体条目和状态快照的内存占用主键字典的维护用于Identity Resolution保证同一主键在同一上下文里只有一个实例关系修复的逻辑实体间导航属性的相互赋值对于单条查询这些成本几乎可以忽略。但对于导出、列表、报表这类批量查询实体动辄成千上万ChangeTracker的内存开销可能比业务数据本身还大。更极端的情况如果数据量到了几十万行默认跟踪查询可以直接把内存打爆。常见的性能误区是只关注SQL执行时间忽略了应用层组装和跟踪的成本。实际上在EF Core的慢查询里很多根本不是数据库慢而是数据回来的那一刻才开始慢。4.2 NoTracking的组合拳与回写坑只读查询直接加AsNoTracking是性价比最高的一行优化。但它有一个著名副作用返回的实体处于Detached状态如果对实体做修改再调用SaveChangesEF Core不会生成任何UPDATE语句因为你改的东西它根本不知道。实际项目里我见过不少在这种坑里栽跟头的案例查询用了AsNoTracking用户在前端改了某个字段提交到后台代码拿到实体改属性然后SaveChanges结果数据库纹丝不动。排查了半天发现问题出在“没跟踪”。提示判断一个查询该不该加AsNoTracking最简单的标准就是看这条数据后续会不会被修改并保存。只读场景用AsNoTracking读写场景必须用默认跟踪或AsTracking。如果需要在NoTracking之后做更新可以先将实体Attach到上下文再设置属性但这样绕来绕去很容易出错不如直接保留跟踪。另外EF Core还提供了AsNoTrackingWithIdentityResolution()它在不跟踪状态快照的前提下仍然维护主键字典保证同一个实体的多个引用是同一个实例这对关系修复场景有意义。但它仍有一定额外开销比纯AsNoTracking贵比默认跟踪便宜。一般的查询建议先用AsNoTracking除非你发现内存里出现了大量重复实例再考虑升级成AsNoTrackingWithIdentityResolution。4.3 AsNoTracking的位置敏感性问题早几个版本里AsNoTracking放在Include前面还是后面行为会不一样。一些人会把AsNoTracking放在查询最后一些人放在最前面结果可能导致警告或意外行为。EF Core近几个版本的行为趋于一致只要查询链路上调用一次就会作用于整个查询。我习惯把AsNoTracking放在整个查询形状确定之后、执行之前比如最后一行调用。逻辑上也通顺先定义“查什么、怎么组装”再告诉上下文“不要跟踪这些数据”。但如果你是在表达式的最开始调用效果也成立不会因此出问题。关键是要形成统一规范别在同一个项目里两边乱放。5. 边界在哪不同查询策略的适用场景与实测数据5.1 一张实测对比表我在自己机器上用MySQL 8.0、EF Core 8.0做过一轮对比测试表结构和数据量模拟那个订单导出场景1000个订单每个订单平均30条明细明细表共3万条。数据预热后分别用四种查询方式执行结果很有参考价值。查询策略数据库往返传输行数内存压力实测耗时趋势Include 默认跟踪1次与集合数量相关多集合膨胀明显高实体进ChangeTracker最慢膨胀时直接劣化Include AsSplitQuery AsNoTracking1N次各集合行数之和无膨胀中实体在内存组装不跟踪中等网络延迟高时劣化投影 AsNoTracking1次或N次取决于嵌套集合仅查询所需列行数可控低DTO无跟踪快照快且稳定投影 AsNoTracking AsSplitQueryN次各段数据独立低集合字段多时可控性好补充说明由于本地环境与线上配置不同绝对耗时意义不大但相对关系是稳定的。Include单查询在多个集合导航属性下始终表现最差投影NoTracking始终最稳定。测试里Include子查询结果集行数膨胀到了约9万行而投影查询只传输了需要的约7000行差距是数量级的。5.2 组合策略与选型判断经过这个案例我总结出一套简单可执行的策略判断逻辑适合绝大多数业务查询。先问第一个问题返回的数据后续需要被修改保存吗需要修改走默认跟踪实体需要被ChangeTracker管理。此时尽量用Include因为你最终要拿到完整实体图并且查询数量通常不大。不需要修改加AsNoTracking这是底线优化。再问第二个问题返回的数据形状是完整的实体图还是页面展示所需的部分字段完整实体图Include或AsSplitQuery看主表和集合的数据量决定是否拆分。部分字段走投影。投影是只读场景的默认选择。再问第三个问题主表记录数量级大吗集合行数会膨胀吗如果主表可能上万行且下面每个集合都有几十行Include无论是单查询还是SplitQuery都不会太好。这时候必须投影并且直接针对需要的嵌套DTO构造查询。如果主表只有几十条每条集合几百行且后续需要操作实体IncludeAsSplitQuery是最合适的。你会发现Include的合理边界其实很窄主要是详情编辑页、少量主记录关联多个集合的场景。而投影NoTracking才是列表、导出、报表这类查询的日常主力。5.3 一个反直觉的结论分页不能拯救一切很多场景里大家会想既然集合涨行数那我先分页查主表再查明细会不会好一些分页确实能缓解主表数据量大的问题但不能解决Include膨胀。因为即便你Limit了100条主记录Include集合导航属性后结果集行数仍然可能是100×每个集合的行数。如果你的集合行数较多分页后的结果集依然很膨胀。正确做法是先分页查询出主表ID列表再基于ID集合查询明细和关联数据最后在内存中按ID分组组装DTO。这样主表分页只影响主表查询明细查询用IN条件返回精确数据行数和主表数量保持线性关系不会出现乘法膨胀。这种“分页二次查询内存组装”的模式代码量多了一些但可控性最出色。尤其适合真正的列表导出、数据大屏这类查询。6. 诊断链路复盘从SQL日志到ChangeTracker的全套排查工具6.1 第一板斧把生成的SQL摊开看排查EF Core性能问题第一步永远是看SQL。不用猜不用推理直接看EF Core生成了什么。最简单的方式是在DbContext配置里启用SQL日志protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder .UseMySql(your-connection-string, ServerVersion.AutoDetect(your-connection-string)) .LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging(); }或者对一个查询实例直接调用ToQueryString()查看生成但未执行的SQLvar query context.Orders .Include(o o.Items) .Where(o o.Id 100); var sql query.ToQueryString(); Console.WriteLine(sql);看到SQL之后重点检查两件事JOIN的数量和顺序以及SELECT列表里有没有出现大量用不到的列。五个LEFT JOIN、结果集行数乘法、SELECT列表包含整张表的列基本就是Include默认跟踪的味道。6.2 第二板斧ChangeTracker到底装了多少东西SQL正常不代表内存正常。如果一个查询返回的数据量不大但CPU持续高你需要检查ChangeTracker里到底有多少实体。在查询执行后加入诊断代码var entries context.ChangeTracker.Entries().ToList(); Console.WriteLine($Tracking entities: {entries.Count}); foreach (var group in entries.GroupBy(e e.Metadata.Name)) { Console.WriteLine(${group.Key}: {group.Count()}); }这段代码能直接告诉你一次查询之后Memory里到底堆了多少实体。如果订单100条结果明细组里有3000条那每个明细都在被跟踪这些对象的快照都在内存里。这比看SQL更直接地揭示了性能瓶颈在应用层。我在这轮诊断里还做过一个测试同样的投影查询ChangeTracker.Entries()里始终是0因为都是DTO且NoTracking而Include链路的Entries数量等于所有实体数之和。差距一目了然。6.3 第三板斧数据库侧的等待与执行计划EF Core的日志能反映生成的SQL但SQL本身优化得够不够要看数据库执行计划。这里有个常见误区数据库CPU低不代表SQL没问题也可能SQL只是把大量数据返回给了客户端数据库侧本身工作不多。看执行计划时重点观察三个指标逻辑读值过高说明SQL访问的数据页太多可能存在大表扫描或缺失索引。返回行数和预估行数差距大说明统计信息过期。物理读/等待类型如果大量时间花在网络传输数据库侧很干净问题就在客户端。诊断这类问题我经常用数据库自带的分析工具观察查询计划或者打开SQL监控跟踪工具把EF Core生成的SQL拿到数据库客户端里执行一下对比耗时。这个方法能快速区分慢在数据库、慢在传输、还是慢在EF Core客户端组装。6.4 一套固定的排查流程综合几次实践我整理了一个五步排查流程。遇到EF Core查询性能问题按顺序执行基本不会漏第一步用ToQueryString或日志导出EF Core实际执行的SQL。第二步检查SQL里有没有多集合JOIN导致的膨胀以及SELECT列是否包含大量无关字段。第三步检查查询是否明确用了AsNoTracking。如果没有添加后先观察内存和耗时变化。第四步用ChangeTracker.Entries().Count()确认客户端到底组装了多少实体。第五步根据业务场景选择Include、AsSplitQuery、投影、分页组装等策略中最合适的一种重新对比耗时和内存。这个流程看起来简单但很多人卡在了第一步——根本没看过EF Core生成了什么SQL连方向都没找对就乱调索引、改数据库配置结果越调越偏。写在最后的经验之谈这个订单导出案例之后我给自己定了几条很朴素的规矩列表和导出类接口默认投影AsNoTracking禁止不加思考地使用Include只有详情页、需要写回实体时才用Include默认跟踪任何查询上线前先看一眼生成的SQL长什么样。这套习惯让我少踩了很多坑。现在每次看到有人因为EF Core性能问题挠头我都会先问一句你确定慢的是数据库还是你压根就没看过SQL和ChangeTracker大多数时候对方沉默几秒之后就会开始查这些东西。性能优化不是堆技巧而是先建立“感知”——知道一条查询从发起到返回每一步的代价是什么。Include、投影、跟踪策略的边界说到底就是这条感知线的三个锚点。把这三个锚点打牢绝大多数EF Core查询性能问题都能在五分钟内定位。
阅读完成 · 觉得有帮助?