模块三 · 内存与数据
EF Core 性能:它替你做三件事,也替你埋三个坑
EF Core 的便利来自跟踪、延迟翻译与自动关联;性能问题也大多来自这三处。这一节讲清 N+1、跟踪、投影与批量操作。
读完这一节,你能做到
- 说清 IQueryable 的延迟执行与翻译边界。
- 识别并修复 N+1 查询。
- 在跟踪与无跟踪之间做出选择。
- 用投影、批量更新与分页优化数据访问。
一、查询的生命周期
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。 - 高频查询字段建索引,外键通常需要索引。
- 避免在索引列上做函数运算。
- 大表加索引要评估锁与耗时,必要时在线变更。
七、常见坑
- 在循环里查询,形成 N+1。
- 多个 Include 造成笛卡尔积。
- 只读查询没有 AsNoTracking。
- 把整个实体查出来只用到两个字段。
- 用
Skip做深分页,越翻越慢。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 开启 SQL 日志,找出一个 N+1 并改成投影。
- 对比 AsNoTracking 前后的大列表查询耗时与内存。
- 用 ExecuteUpdate 批量更新 1 万条记录,对比逐条 SaveChanges。
② 让 AI 写同一题
把第 2 题(列表查询优化)交给 AI,补一句 只读查询必须 AsNoTracking,禁止在循环里查。
③ 对比要盯的点
- N+1 它用
Include还是投影Select解决?后者往往更省。 AsNoTracking它加了吗?只读查询开跟踪是白付内存。- 延迟执行它理解对吗?
IQueryable在ToList之前都没发 SQL。
④ 一句话取舍
它那版在 1 万条数据时会发多少条 SQL?
九、自测
- IQueryable 什么时候执行?
- N+1 的三种修法?
- 跟踪与无跟踪怎么选?
- 深分页为什么慢,怎么优化?
- 乐观并发冲突怎么处理?
参考答案要点: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;且按创建时间排序无索引。
动作:
- 用投影把用户字段一次性 JOIN 出来。
- 加
(UserId, CreatedAt)联合索引。 - 只读查询加
AsNoTracking。 - 深分页改游标。
结果:P99 220ms,数据库 QPS 降 55%。代价是 DTO 变多、缓存一致性要额外处理。
查询拆分的取舍
AsSplitQuery 把一个带多个 Include 的查询拆成多条 SQL,避免笛卡尔积;代价是多次往返。
判断标准:集合 Include 超过一个、且每个集合行数不少时,用拆分;只有一个集合且数据量小时,单条 JOIN 更快。
补充:索引与查询计划的配合
- 联合索引要按“等值列在前、范围列在后”排列。
- 排序字段进索引可以避免额外排序。
- 在索引列上做函数运算会导致失效。
- 用
ToQueryString()看生成的 SQL,再对照执行计划。
EF Core 不会自动为你建所有索引,索引仍要按查询模式设计。
补充:查询优化清单
- 先看 SQL,再看执行计划。
- 只查需要的列,用投影。
- 只读加 AsNoTracking。
- 关联用 Include,集合多时拆查询。
- 分页用游标,避免大 Skip。
- 批量操作用 ExecuteUpdate/ExecuteDelete。
- 缓存热点结果,注意失效。
数据库验收
优化后至少看四个数:单请求 SQL 条数、扫描行数、P99、连接池占用。只看接口耗时,容易把问题挪到别处。
三个不要
不要在循环里查库;不要 select * 拉全字段;不要用大 Skip 做深分页。
一个判断
先问“这次查询要更新实体吗”。不需要更新,就 AsNoTracking + 投影;需要更新,才保留跟踪。这个判断能解决大半性能问题。
验收
优化后看四个数:单请求 SQL 条数、扫描行数、P99、连接池占用。只看接口耗时,容易把问题挪到别处。
最后三句
先看 SQL 再谈优化;只读不跟踪;投影少取列。关联防笛卡尔积,分页用游标,批量用 ExecuteUpdate,索引按查询建,热点结果可缓存。索引要按查询模式设计,联合索引注意列顺序;深分页用游标,批量操作绕过变更跟踪。每次优化都用执行计划验证,而不是凭感觉;先量后改。先看计划,再改代码。先看计划,再改。别猜,先看 SQL。先测。
补遗:Dapper、事务与连接弹性
Dapper 是轻量 ORM:手写 SQL、自动映射、性能接近原生;适合复杂查询与高性能场景,可与 EF Core 混用。
事务:DbContext.Database.BeginTransaction() 显式控制;跨多个 SaveChanges 要共用同一事务。注意事务与异步不要混用阻塞调用。
连接弹性:启用 EnableRetryOnFailure 处理瞬时故障;重试要配合幂等,避免重复写入。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解