首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
EF Core 查询性能优化:Include、投影与跟踪策略的边界
📅 2026/10/10 6:39:08
✍️ 爱科研究院
👁 阅读 3,247
EF Core 的查询性能问题尤其是 Include、投影和跟踪策略这三样东西组合在一起的时候常常让很多人在上线后才发现“跑不动了”。我见过不少项目一开始数据量几千条的时候Include 随便用投影随便写查询一点毛病没有。等数据涨到几十万甚至上百万接口瞬间变成 3 秒、5 秒、甚至直接超时。这里面的核心矛盾在于ORM 把数据库操作翻译成对象操作给人很方便的幻觉但底层执行的还是 SQL数据量和查询形态一变翻译出来的 SQL 如果不合理性能就是断崖式下跌。这篇文章想聊的不是 EF Core 的用法入门而是想厘清几个边界Include 用在什么情况是有利的什么情况它是黑洞投影是不是永远比 Include 好AsNoTracking 到底什么时候该用、什么时候用了反而出 bug。我会结合几个真实踩过的坑来讲希望能帮你在写查询的时候多一分判断少一分“上线后再救火”。1. 为什么 Include 会成为最常见的性能黑洞1.1 Include 的本意与误用起点Include 的本意是解决“懒加载带来的 N1 查询”它通过一条 JOIN 或者多条查询把当前实体关联的数据一口气取回来。这个出发点是好的但很多人用着用着就把它当成了“万能加载器”——只要遇到需要关联数据的界面就把所有能想到的导航属性全 Include 一遍甚至 Include 里面再嵌套 ThenInclude。Include 之所以经常变成性能黑洞根本原因在于它把“需要什么样的数据”这个问题完全交给了数据库去拼装拼装出来的数据往往比实际用到的多得多。你界面上一屏只要显示 10 行订单、每行订单只要一个客户名称和一个联系人电话但代码里写的是Include(o o.Customer).ThenInclude(c c.Orders)这就等于让数据库把每个客户的订单明细也全部查出来哪怕界面上根本不会展示这些明细。从 SQL 层面看Include 集合导航属性时EF Core 会生成包含多个 FROM 项的查询或者是基于查询拆分的多条独立语句。无论是哪种数据量的放大效应都很明显。尤其是你 Include 了两层、三层集合属性的时候中间结果行数是“乘法级”膨胀的——这在第 1.2 节会具体讲。1.2 笛卡尔爆炸——最典型的 Include 灾难笛卡尔爆炸这个词听起来很学术实际表现却很简单A 表 100 条数据每条对应 B 表 50 条子记录又对应 C 表 200 条孙记录。你写Include(a a.Bs).ThenInclude(b b.Cs)数据库先把 A、B、C 三张表 JOIN 到一起得到的中间行数是 100 × 50 × 200 100 万行。这 100 万行通过网络传到应用服务器EF Core 再根据主键和导航关系把它们“粘”回对象图。这个过程中最容易被忽略的问题是实际需要的数据也许只有几千行但传输和内存消耗是按百万行走的。再叠加数据库做这次大 JOIN 时要执行的磁盘扫描、内存排序、哈希匹配你会看到一个单表只有几万行的系统查询耗时甚至比跨表聚合还夸张。EF Core 其实意识到了这个问题从 EF Core 5 开始提供了拆分查询Split Query。它的思路很直接与其用一条超大 JOIN 查出笛卡尔积不如分两条、三条查询每条查询独立取一个层级的数据然后在客户端按主键组装。这个方案对“集合 Include 层级较深、数据量较大”的情况改善很明显。但拆分查询也有代价——每条额外查询都会增加一次数据库往返还会引入数据一致性问题两条查询中间如果数据被修改可能导致客户端组装出来的对象图和数据源不一致。所以不要一遇到 Include 慢就无脑上拆分查询要结合数据量级和场景来判断。1.3 集合 Include 的深层问题大结果集与内存压力除了 SQL 层的膨胀Include 集合属性还会在客户端造成内存压力。EF Core 在组装对象图时要为每一行中间结果做实体实例化、关系匹配、去重。你去重之后可能只有 1 万个订单对象但构造函数、属性赋值、关系配对这些操作是发生在“中间结果行数”级别上的。我实际遇到过一个案例某报表功能需要查某个月的订单附带订单明细和商品信息。月订单量大约 2 万条明细 12 万条商品 4 万条。开发同学写的是三段式 Include查询天然生成了 12 万行中间结果。功能跑起来之后内存涨了快 800 MB接口耗时 6 秒多。后来改成投影只取需要的字段把 12 万行压缩到界面真正用到的 2 万行投影结果内存直接掉到 200 MB 以内耗时降到 800 毫秒左右。这个案例说明一个关键点Include 的效率取决于“中间结果行数”和“实际需要行数”的比值。比值接近 1Include 是合理的比值是 10 倍、100 倍它就是典型性能黑洞。所以写 Include 之前先想想你要的数据形状和数据量再做决定。注意Include 本身不是错误错的是不加区分地用它来加载所有导航属性。判断标准就一句话查出来的对象图和你界面上真正需要的数据形状是不是基本一致如果差太多就该考虑投影或者其他方式。2. 投影Select优化什么时候用、什么时候不能盲用2.1 投影为什么通常比 Include 性能好投影Select 出新对象被很多 EF Core 优化文章推荐原因其实不神秘——它直接把“只取需要的字段”这件事落实到了生成的 SQL 层面。你写Select(o new OrderDto { Id o.Id, CustomerName o.Customer.Name })EF Core 生成的 SQL 就是只查订单表的 ID 字段和关联客户表的 Name 字段不会把订单表的所有字段捞回来也不会把客户表的全字段捞回来。这意味着两个直接好处一是 I/O 和网络传输的数据量大幅减少二是不需要额外维护对象图组装的状态。加索引也会更精准因为查询涉及的列少索引覆盖的可能性就大。在实际项目里很多列表页、下拉框、统计报表都适用投影来优化改动成本很低收益却很直接。但投影也有它自己的边界它不是银弹。特别要注意的是投影之后 EF Core 会在客户端做数据拼接如果投影对象里包含多个子集合其背后往往对应一次完整查询的执行。你用了投影不代表底层不会做笛卡尔积式的查询只是有时候把膨胀逻辑藏在了对象构造里。关键要看生成了什么样的 SQL。2.2 投影的正确姿势DTO 与导航属性的取舍在写投影的时候最常见的姿势是定义一个 DTO 类专门用来承载查询结果。这个 DTO 可以是一个专门为列表页设计的 ViewModel也可以是一个更通用的 QueryModel。我通常建议按界面划分 DTO因为一个接口关心的字段集合往往和另一个接口关心的字段集合是不同的逼着你在写投影时明确指定需要的列而不是定义一个万能大对象然后到处复用。导航属性的取舍方面投影里可以安全地使用导航属性只要它是在服务器端被解析成 JOIN 的。比如o.Customer.Name这种访问方式EF Core 会把它翻译成 LEFT JOIN 或 INNER JOIN然后只取 Name 列。这比 Include 整个客户实体再访问o.Customer.Name要轻量得多——Include 会把客户实体的所有映射字段查出来投影只取 Name 一个字段。但这里有个容易踩的坑如果你在投影中访问的是“一对多”关系的集合比如o.OrderItems.Select(oi new OrderItemDto { ... })EF Core 依然会生成一个 JOIN 或一个独立的查询。集合投影对性能的影响仍然存在只是你可以精确地挑选集合里每个元素的字段避免把整行捞回来。不过如果你的投影对象里又是集合套集合那数据膨胀的问题依然存在唯一能缓解的方式是检查生成的 SQL必要时拆分成多次查询而不是一次投影完成。2.3 投影不是万能药哪些场景投影反而更糟虽然投影在很多场景下是优化利器但它也有不适合的时候。第一个不适合的场景是你需要更新数据并保存回去。投影默认返回的是匿名类型或 DTOEF Core 无法跟踪它们的变更。如果你先投影出来改了 DTO 的字段然后想调用SaveChanges直接落库是做不到的。这时候必须走“先查到跟踪实体修改字段再 SaveChanges”的流程。第二个不适合的场景是数据量特别大但字段非常少的时候投影不一定比直接查询整个实体快多少。比如你只需要 ID 列那 SELECT 一个 ID 和 SELECT 全字段的差距主要花在传输和对象实例化上数据库层面的差异反而没那么明显。当然如果你有覆盖索引差距还是有的但实践中的收益主要来自减少传输而不是减少数据库扫描。第三个场景是当你有复杂的嵌套条件判断、类型转换、函数调用时投影表达式可能生成很复杂的 SQL甚至最终退化成一个巨大的子查询。我遇到过投影里包含多个DateTime相关函数比较的情况生成的 SQL 嵌套了三层子查询性能反而比拆成两次查询慢。这时候不如先查主实体再对每个子列表单独查询用“客户端组合”的方式来替代。提示投影不是一种“写了就快”的魔法。真正管用的做法是写完投影后用日志看生成的 SQL确认列裁剪和 JOIN 都符合预期而不是想当然。3. 跟踪策略的边界AsTracking 与 AsNoTracking 怎么选3.1 跟踪查询到底做了什么“额外”的事EF Core 默认所有查询都是跟踪查询AsTracking。它的核心机制是把每次查询返回的实体对象放进上下文的一级缓存ChangeTracker并维护每个实体的状态Unchanged、Modified、Added、Deleted、原始值快照和当前值。当你调用SaveChanges时它对比当前值和原始快照生成对应的 UPDATE、INSERT、DELETE 语句。这套机制对“查询后修改并保存”的场景非常关键但它的成本也是实打实的每个实体都要做一次状态注册都要保存一份原始值快照DbContext 的生命周期里这些对象会一直被引用直到它被释放。如果你只是做展示型查询根本不需要这些“额外工作”——查询完直接扔掉数据不做任何修改那么跟踪机制就纯粹是开销。我实测过一个纯查询接口订单列表分页返回 20 条订单每条关联客户名。使用默认跟踪接口耗时约 180 毫秒改成AsNoTracking后降到 120 毫秒。20 条数据的场景下差距不算大但你把它放大到几千条、上万条的报表场景差别就非常可观了。如果涉及大量实体且生命周期较长AsNoTracking 的内存节省尤其明显。3.2 该用 AsNoTracking 的场景展示型查询与避免缓存不一致展示型查询是 AsNoTracking 最典型的应用场景。列表页、详情页、统计报表、导出功能基本都是“查出来展示给用户”用户在界面上不会直接修改这些数据也没有“改完立刻保存回数据库”的需求。这些场景用 AsNoTracking 既能减少跟踪开销也能避免因为长生命周期 DbContext 导致的缓存混乱。还有一个容易被忽略的点如果你在同一个 DbContext 中有多个跟踪查询并且都涉及同一张表的数据EF Core 会尝试在缓存中复用已有实体。如果你之前查出来的实体状态是旧值而另一个查询重新从数据库读到新值EF Core 默认会保留缓存里的旧值这会造成“看起来查到了数据但值不对”的诡异问题。用 AsNoTracking 可以避免这种一致性问题因为它不走缓存每次都直接从数据库拿最新值。注意AsNoTracking 查询返回的实体没有状态跟踪所以你不能对这些实体做修改后调用SaveChanges期望数据库变更。如果你不小心这样做了EF Core 会忽略这些实体的变更或者抛异常取决于你访问的是不是被跟踪的实体。这是一个非常隐蔽的坑。3.3 该用 AsTracking 的场景实体修改、级联更新与显式更新反过来什么时候应该保持跟踪核心判断标准就是“你是否要在同一上下文中修改这些实体并保存”。典型场景一编辑页面。你先查出订单实体展示给用户用户提交修改后的数据你直接修改订单实体的属性然后调用SaveChanges。这个流程下必须跟踪而且跟踪查询会帮你自动生成只包含变更字段的 UPDATE 语句性能反而更好。典型场景二批量状态变更。比如批量将一批订单标记为“完成”你需要先查出这批订单跟踪循环修改每个订单的 Status再调用一次SaveChanges。这里跟踪查询的价值在于EF Core 能精确得知哪些订单的 Status 发生了变更从而生成针对这些行的 UPDATE避免全表更新。典型场景三级联操作。删除一个父实体时希望删除它的子记录EF Core 的级联行为依赖于跟踪的导航关系。如果你用 AsNoTracking 查询父实体再尝试删除它可能会因为导航属性没有被跟踪导致子记录无法正确级联处理。说直白点跟踪和 AsNoTracking 的边界就是把“要改”和“只看”分开。要改的用跟踪只看的不跟踪。这条规则简单有效大多数性能问题和更新问题都是因为打破了这条规则才出现的。4. 实战排查设置正确的性能边界并定位黑洞4.1 先用日志和 ToQueryString “看” SQL很多 EF Core 性能问题本质上不是 EF Core 本身的问题而是生成的 SQL 不合理的问题。所以我首先要说任何性能优化第一步都是先看 SQL。EF Core 提供了几个很方便的方式让你看 SQL。第一种是配置日志在 DbContext 的配置里启用对 SQL 语句级别的输出你可以用LogTo(Console.WriteLine)或者配置到日志框架。我这里建议在开发环境开启生产环境就算了日志太多会影响性能。第二种方式是ToQueryString()它可以直接输出一个查询表达式对应的 SQL 文本不用真的执行。这个在调表达式的时候特别好用你可以一边改查询一边看 SQL 变化快速定位是哪个写法导致了超预期的 JOIN 或子查询。我经常见的排查方式是先拿一个慢查询的表达式用 ToQueryString 输出 SQL再直接在数据库客户端执行这个 SQL看执行计划。看看它是不是走了索引是不是产生了大 hash join是不是有隐式类型转换。很多时候问题并不是 EF Core 的写法而是数据库表索引缺失、统计信息过期这类问题靠改 EF Core 代码是解决不了的。4.2 一套实用的查询四步法我总结了一套写 EF Core 查询时用的“四步法”可以帮你避免很多性能问题第一步明确数据形状。先问自己这个接口/页面到底需要哪些字段需要几条记录有没有分页分页后每页多少条这些决定了你要用 Include、投影还是普通查询。第二步确定追踪策略。这个结果是用来展示还是要修改展示用 AsNoTracking修改用跟踪。如果不太确定默认先按展示处理等真的有修改需求再用跟踪。第三步选查询载体。需要全部字段、且要不修改导航关系时考虑 Include只需要部分字段投影需要关联聚合、统计直接写聚合查询而不是先取实体再内存计算。第四步用日志验证。把最终的查询转换成 SQL跑一下执行计划确认没有意外的大 JOIN、明显的类型转换、缺少索引。这一步一定要做别嫌麻烦。这套方法听起来简单实际用的时候能挡住一大批问题。我见过太多人上来就写Include().Where().OrderBy().Skip().Take()看起来好像没什么问题实际 SQL 里先把整张表 JOIN 完再分页数据量大点就直接卡死。4.3 真实案例列表页从 6 秒到 800 毫秒的优化过程讲一个我实际处理过的案例帮助你把前面几个知识点串起来。某管理后台需要一个订单列表页分页展示订单号、客户名称、客户电话、下单时间、订单状态、订单金额并且每行可以展开查看订单条目名称和数量。开发同学的原始代码是var orders await ctx.Orders .Include(o o.Customer) .Include(o o.OrderItems) .Where(o o.CreateTime start o.CreateTime end) .OrderByDescending(o o.CreateTime) .Skip(pageIndex * pageSize).Take(pageSize) .ToListAsync();表面看起来没问题先过滤时间范围、再排序分页、再 Include 关联数据。但问题出在哪呢第一Include 生成了一个大 JOINOrderItems 是集合导航Include 后中间结果行数变成了“订单数 × 平均条目数”哪怕最终分页只取 20 条订单JOIN 本身是在整张表范围内先执行的尤其是 CreateTime 字段还没建索引时扫描范围非常大。第二把分页放在 Include 后面虽然结果只返回 20 条订单但数据库为了确定这 20 条的位置可能要先对全时间段内的订单排序、JOIN最后才做分页截取。第三Include(o o.OrderItems) 会把明细表全字段查回来但界面只需要条目名称和数量。优化后我把查询拆成两步第一步用投影查出分页所需的订单基础信息包括订单 ID、客户名称、客户电话、下单时间、状态、金额还有订单条目数量和名称摘要。这一步用 Select 投影确保只 SELECT 需要的列。第二步如果需要展开某一条订单的明细再单独按订单 ID 查 OrderItems并投影成精简 DTO。核心 SQL 从原来的一条大 JOIN变成了两条比较轻量的查询。最终接口从 6 秒降到 800 毫秒内存占用也降了非常多。这个案例最值得记的是当“列表显示 明细展开”这个数据形状出现的时候Include 看起来最省事但恰恰是最不该用它的时候。因为列表本身只需要聚合和摘要明细只在展开时才需要拆成两步反而是最符合数据访问模式的。5. 常见问题速查与独家避坑心得5.1 常见问题速查表问题可能原因解决方向查询数据量不大但很慢缺少索引或者查询条件列有隐式类型转换看执行计划补充索引检查类型匹配Include 后笛卡尔积膨胀集合导航属性多级叠加改用拆分查询或者改为投影/多次查询同一个 DbContext 反复查询数据不更新跟踪查询使用了旧缓存实体改用 AsNoTracking或者短生命周期 DbContext修改实体后 SaveChanges 无效查询用了 AsNoTracking实体未被跟踪改用跟踪查询或显式附加后再修改投影后无法更新投影返回 DTO/匿名类型不参与跟踪更新操作需要查询跟踪实体再改分页查询排序很慢排序字段没有索引或者排序发生在 JOIN 之后确保 OrderBy 字段有索引拆分查询减少不需要的 JOIN查询中访问多个导航属性SQL 出现多个 LEFT JOIN表达式写法导致 EF 生成了显式 Left Join检查是否要使用 Inner Join或调整投影结构AsNoTracking 后导航属性为 null没有显式 Include 或通过投影加载导航属性按需加载避免隐式懒加载带来的 N15.2 几条亲测有效的建议第一尽量避免在循环里执行 EF Core 查询。最常见的写法是循环内根据某个 ID 查另一张表N 条外层数据就产生 N 次数据库往返。正确做法是先把外层数据的 ID 集合收集起来用Contains一次性查出关联数据再用内存组装。这个方法简单粗暴但极其有效绝大多数性能问题都绕不开这个坑。第二不要迷信“EF Core 会帮我优化”。它会帮你翻译 SQL但不会帮你做索引更不会帮你想数据形状。它翻译出来的 SQL和你自己手写 SQL 一样要面对数据库执行计划的约束。所以写每个查询时默认心态应该是“我要手工验证 SQL 是否合理”。第三AsNoTracking 不是万能的也不是越用越好。它解决的是跟踪开销的问题不是数据形状的问题。数据形状不合理AsNoTracking 只是让黑洞慢得稍微少一点。真正要解决的是查询写法本身。第四注意 DbContext 的生命周期。一个长生命周期的 DbContext 会让跟踪查询缓存的实体越来越多内存自然不断上涨。如果项目里是请求级 DbContext那还好如果是单例级AsNoTracking 几乎成了必选否则内存问题会越积累越严重。第五当你在 Include 里需要过滤集合子项时比如只想加载某个状态下的明细EF Core 的过滤包含Filtered Include在较老版本里不支持新版本虽然支持了但对生成的 SQL 复杂度影响比较大需要仔细验证。如果过滤逻辑很复杂建议拆成单独查询来做可维护性和性能都更好。我也遇到过一些比较微妙的问题比如某个查询只有几百行数据但耗时好几秒最后发现是数据库的统计信息过期导致执行计划走了严重偏差。这种问题靠优化 EF Core 代码是解决不了的只能从数据库层面去刷新统计信息、更新计划缓存。遇到查询慢第一反应先看数据库执行计划而不是怀疑 EF Core。在使用了更短的生命周期 DbContext 后另一个明显的体会是代码的可维护性变好了。以前长上下文导致的各种“幽灵缓存”“数据不一致”问题基本都消失了。只要记住一个原则——要改数据的查询用跟踪只是看的查询用 NoTracking大多数边界问题都会自己消解。这几点是我在多次数据量膨胀、接口变慢、线上救火的过程里逐渐摸索出来的。现在每写一个新查询我都会下意识先过一遍四步法数据形状是什么、是否要更新、用投影还是 Include、SQL 验证过没。这些习惯养成之后你会发现 EF Core 其实没那么容易出性能黑洞大多数坑都是踩在“没有想清楚边界”上面。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/10 6:39:08
网络安全赚不了大钱?真相:它只发长期主义的财
2026/10/10 6:39:08
OpenClaw智能体框架实战:云端与本地部署及聊天机器人接入指南
2026/10/10 6:34:08
Codex CLI接入OpenAI兼容接口:config.toml字段拆解与高频报错排查实战
2026/10/10 7:34:12
e稿论文辅助工具贵不贵 不同版本收费与性价比解析
2026/10/10 7:34:12
新疆悬浮门源头生产厂家筛选名录 富强门业合作实力参考
2026/10/10 7:34:12
PyCharm安装配置全指南:从环境搭建到高效开发
2026/10/10 7:34:12
呼伦贝尔夏季亲子游路线推荐 探北旅行社亲子领队全程陪同 含驯鹿部落探访 可拼团可独立
2026/10/10 7:34:12
长沙奥数机构排名,从“3-6年级体系”看赏识培训的课程设计
2026/10/10 7:29:11
大模型价格战下开发者指南:新模型接入、成本优化与多模型混用策略
2026/10/10 0:03:38
工业软件标准化路线图:国产替代的落地施工图
2026/10/10 0:03:38
VCMI安卓版实操指南:原生运行英雄无敌3的3步技术落地
2026/10/10 0:03:38
稀疏多通道盲反褶积的MATLAB算法实现与参数调优
2026/10/10 3:42:06
Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化
2026/10/10 3:42:01
多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系
2026/10/10 3:41:58
hindsight:面向LLM应用的事后可观测性工程实践
2026/10/10 3:41:56
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/10 3:41:54
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/9 11:36:17
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)