EF Core 查询性能调优备忘录
同样是 LINQ,有人写出来 3ms,有人写出来 3s。EF Core 的性能问题极少出在框架本身,绝大多数出在「生成了什么样的 SQL」这件事没有被认真对待。这篇备忘录收录我调优时反复使用的几个手段,按使用频率排序。
一、只读查询先关掉跟踪
默认情况下 ToList 出来的每个实体都会进入变更跟踪器,查询多、实体多时这是一笔可观的内存与 CPU 开销。只读场景(列表页、导出、报表)加上 AsNoTracking 几乎是免费的胜利:
var list = await _db.Orders
.AsNoTracking()
.Where(o => o.Status == OrderStatus.Paid)
.ToListAsync();
如果整个查询上下文都是只读的(比如 GraphQL/报表服务),直接在 DbContext 级别配置 QueryTrackingBehavior.NoTracking 更省心。
二、用投影代替整个实体
「先查整个实体再取两个字段」是列表页最贵的写法——列多了、行多了、还常常顺带触发延迟加载。投影只取需要的列,而且 Select 里的导航属性会被自动翻译成 JOIN:
var rows = await _db.Orders
.Where(o => o.CreatedAt >= from)
.Select(o => new OrderRow(o.Id, o.Amount, o.Customer.Name)) // 自动生成 INNER JOIN
.ToListAsync();
三、识别并消灭 N+1
症状:列表页 50 行数据,数据库却收到了 51 条查询。根因几乎都是物化之后在内存里访问导航属性:
// ❌ 物化后访问 o.Customer.Name,每行触发一次查询
var orders = await _db.Orders.ToListAsync();
foreach (var o in orders)
{
Console.WriteLine(o.Customer.Name);
}
// ✅ 方案一:Include 预加载
var orders = await _db.Orders.Include(o => o.Customer).ToListAsync();
// ✅ 方案二(更快):直接投影,SQL 一次性 JOIN 完成
var orders = await _db.Orders
.Select(o => new { o.Id, CustomerName = o.Customer.Name })
.ToListAsync();
定位手段很朴素:开发环境开着日志看 SQL 条数,或者接入 DbContext 的诊断事件对单请求查询数设阈值告警。
四、深分页改用键集分页
// ❌ Skip(100000).Take(20):数据库要真的扫过前 10 万行
var page = await _db.Orders
.OrderBy(o => o.Id)
.Skip(100000).Take(20)
.ToListAsync();
// ✅ 键集分页:无论翻到第几页,成本恒定
var page = await _db.Orders
.Where(o => o.Id > lastSeenId)
.OrderBy(o => o.Id)
.Take(20)
.ToListAsync();
偏移分页在前几百页还能撑,越往后越慢;键集分页(keyset / seek)利用索引直接定位起点,代价是只能顺序翻页。管理后台那种「跳到第 N 页」的需求可以两者结合:浅层用偏移,深层用游标。
五、让执行计划说话
所有猜测都不如一份执行计划。两个基本动作:
query.ToQueryString()打印 EF 将要生成的 SQL,先确认它不是你想的样子;- 把 SQL 拿到数据库里
EXPLAIN ANALYZE,看是顺序扫描还是索引扫描,过滤列和排序列是否在索引里。
绝大多数慢查询最后都归结为:缺一个合适的复合索引,或者在 WHERE 里对列做了函数运算导致索引失效。
六、批量写入别走实体管道
海量数据的更新/删除不需要把实体读进内存再逐条处理,EF Core 7+ 的集合操作直接翻译成 SQL:
// ❌ 读出 10 万实体、逐条标记、一次 SaveChanges
// ✅ 数据库里直接执行 DELETE
await _db.Orders
.Where(o => o.Status == OrderStatus.Expired && o.CreatedAt < cutoff)
.ExecuteDeleteAsync();
备忘录一句话版:只读就 NoTracking,列表就投影,翻页用键集,改删用 Execute 系列,剩下的交给执行计划。六条纪律守住,EF Core 很难慢。