同样是 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 很难慢。