知

模块三 · 内存与数据

第 6 / 11 节20 分钟阅读型

EF Core 性能:它替你做三件事,也替你埋三个坑

EF Core 的便利来自跟踪、延迟翻译与自动关联;性能问题也大多来自这三处。这一节讲清 N+1、跟踪、投影与批量操作。

读完这一节,你能做到

  1. 说清 IQueryable 的延迟执行与翻译边界。
  2. 识别并修复 N+1 查询。
  3. 在跟踪与无跟踪之间做出选择。
  4. 用投影、批量更新与分页优化数据访问。

一、查询的生命周期

var query = db.Users.Where(u => u.Age > 18); // 只构建表达式
var list = await query.ToListAsync();        // 才真正执行 SQL

IQueryable 是表达式树,EF Core 把它翻译成 SQL。不是所有 C# 方法都能翻译:自定义方法、复杂字符串操作、部分日期函数会退化为客户端求值或直接抛错。

判断方法:打开 LogTo(Console.WriteLine) 看生成的 SQL;或开启 EnableSensitiveDataLogging(仅开发环境)。

二、N+1 查询

var users = await db.Users.ToListAsync();       // 1 次
foreach (var u in users)
    Console.WriteLine(u.Orders.Count);          // 每个用户 1 次

100 个用户就是 101 次查询。三种修法:

  • Include(u => u.Orders):一次 JOIN 取回,适合少量关联。
  • 投影:Select(u => new { u.Id, Count = u.Orders.Count }),只取需要的字段。
  • 批量查询:先查所有订单,再在内存里分组。

Include 不是越多越好:多个集合 Include 会导致笛卡尔积,数据量爆炸;用 AsSplitQuery 拆成多条 SQL。

三、跟踪与无跟踪

模式行为适用
跟踪(默认)保存快照,检测变更需要更新
AsNoTracking不跟踪,轻量只读查询
AsNoTrackingWithIdentityResolution不跟踪但去重实体只读且有关联

只读接口一律加 AsNoTracking,能显著降低内存与 CPU;更新场景才需要跟踪。

四、投影与分页

只查需要的列:

var items = await db.Orders
    .Where(o => o.UserId == id)
    .OrderByDescending(o => o.CreatedAt)
    .Select(o => new OrderDto(o.Id, o.Amount, o.CreatedAt))
    .Skip((page - 1) * size).Take(size)
    .ToListAsync();

投影减少数据量,分页减少行数;深分页用游标(where Id < lastId)而不是大 Skip。

五、批量与并发

EF Core 7+ 提供 ExecuteUpdate/ExecuteDelete,直接生成 UPDATE/DELETE,不加载实体:

await db.Users.Where(u => u.Expired).ExecuteDeleteAsync();

并发控制用乐观并发令牌([Timestamp]/RowVersion),冲突时抛 DbUpdateConcurrencyException,由业务决定重试或提示。

六、迁移与索引

  • 迁移文件要进版本控制,生产环境用脚本而不是 EnsureCreated。
  • 高频查询字段建索引,外键通常需要索引。
  • 避免在索引列上做函数运算。
  • 大表加索引要评估锁与耗时,必要时在线变更。

七、常见坑

  1. 在循环里查询,形成 N+1。
  2. 多个 Include 造成笛卡尔积。
  3. 只读查询没有 AsNoTracking。
  4. 把整个实体查出来只用到两个字段。
  5. 用 Skip 做深分页,越翻越慢。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 开启 SQL 日志,找出一个 N+1 并改成投影。
  2. 对比 AsNoTracking 前后的大列表查询耗时与内存。
  3. 用 ExecuteUpdate 批量更新 1 万条记录,对比逐条 SaveChanges。

② 让 AI 写同一题

把第 2 题(列表查询优化)交给 AI,补一句 只读查询必须 AsNoTracking,禁止在循环里查。

③ 对比要盯的点

  • N+1 它用 Include 还是投影 Select 解决?后者往往更省。
  • AsNoTracking 它加了吗?只读查询开跟踪是白付内存。
  • 延迟执行它理解对吗?IQueryable 在 ToList 之前都没发 SQL。

④ 一句话取舍

它那版在 1 万条数据时会发多少条 SQL?

九、自测

  1. IQueryable 什么时候执行?
  2. N+1 的三种修法?
  3. 跟踪与无跟踪怎么选?
  4. 深分页为什么慢,怎么优化?
  5. 乐观并发冲突怎么处理?

参考答案要点:ToList/foreach 时;Include/投影/批量;只读用 NoTracking;Skip 扫描;捕获异常重试或提示。

附录:高频追问

问:EF Core 与 Dapper 怎么选? EF Core 适合复杂模型与快速开发;Dapper 适合高性能、SQL 可控的场景,常混用。

问:Include 与 ThenInclude 的区别? Include 加载一级导航,ThenInclude 继续加载下一级。

问:AsSplitQuery 解决什么? 多个集合 Include 时的笛卡尔积,拆成多条 SQL。

问:为什么客户端求值危险? 可能把整表拉到内存,性能与内存都不可控;EF Core 3+ 默认禁止隐式客户端求值。

问:DbContext 能长期持有吗? 不能,Scoped 生命周期,跨请求共享会串数据与累积跟踪。

问:如何看生成的 SQL? LogTo、ToQueryString() 或数据库 Profiler。

问:批量插入怎么做? AddRange + 一次 SaveChanges;更大批量用 ExecuteSqlRaw 或第三方库。

问:并发令牌是什么? 行版本字段,更新时带上旧值,冲突则影响 0 行并抛异常。

问:迁移能回滚吗? 可以 Update-Database 到指定迁移,但生产要谨慎,先备份。

问:EF Core 适合微服务吗? 适合,但每个服务独立 DbContext 与数据库,避免共享表。

附:一个慢查询优化案例

现象:订单列表 P99 1.2 秒,数据库 CPU 高。开启 SQL 日志后发现:每个订单都查一次用户,典型 N+1;且按创建时间排序无索引。

动作:

  1. 用投影把用户字段一次性 JOIN 出来。
  2. 加 (UserId, CreatedAt) 联合索引。
  3. 只读查询加 AsNoTracking。
  4. 深分页改游标。

结果:P99 220ms,数据库 QPS 降 55%。代价是 DTO 变多、缓存一致性要额外处理。

查询拆分的取舍

AsSplitQuery 把一个带多个 Include 的查询拆成多条 SQL,避免笛卡尔积;代价是多次往返。

判断标准:集合 Include 超过一个、且每个集合行数不少时,用拆分;只有一个集合且数据量小时,单条 JOIN 更快。

补充:索引与查询计划的配合

  • 联合索引要按“等值列在前、范围列在后”排列。
  • 排序字段进索引可以避免额外排序。
  • 在索引列上做函数运算会导致失效。
  • 用 ToQueryString() 看生成的 SQL,再对照执行计划。

EF Core 不会自动为你建所有索引,索引仍要按查询模式设计。

补充:查询优化清单

  1. 先看 SQL,再看执行计划。
  2. 只查需要的列,用投影。
  3. 只读加 AsNoTracking。
  4. 关联用 Include,集合多时拆查询。
  5. 分页用游标,避免大 Skip。
  6. 批量操作用 ExecuteUpdate/ExecuteDelete。
  7. 缓存热点结果,注意失效。

数据库验收

优化后至少看四个数:单请求 SQL 条数、扫描行数、P99、连接池占用。只看接口耗时,容易把问题挪到别处。

三个不要

不要在循环里查库;不要 select * 拉全字段;不要用大 Skip 做深分页。

一个判断

先问“这次查询要更新实体吗”。不需要更新,就 AsNoTracking + 投影;需要更新,才保留跟踪。这个判断能解决大半性能问题。

验收

优化后看四个数:单请求 SQL 条数、扫描行数、P99、连接池占用。只看接口耗时,容易把问题挪到别处。

最后三句

先看 SQL 再谈优化;只读不跟踪;投影少取列。关联防笛卡尔积,分页用游标,批量用 ExecuteUpdate,索引按查询建,热点结果可缓存。索引要按查询模式设计,联合索引注意列顺序;深分页用游标,批量操作绕过变更跟踪。每次优化都用执行计划验证,而不是凭感觉;先量后改。先看计划,再改代码。先看计划,再改。别猜,先看 SQL。先测。

补遗:Dapper、事务与连接弹性

Dapper 是轻量 ORM:手写 SQL、自动映射、性能接近原生;适合复杂查询与高性能场景,可与 EF Core 混用。

事务:DbContext.Database.BeginTransaction() 显式控制;跨多个 SaveChanges 要共用同一事务。注意事务与异步不要混用阻塞调用。

连接弹性:启用 EnableRetryOnFailure 处理瞬时故障;重试要配合幂等,避免重复写入。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解