上周三下午,计划主管直接打电话过来:「订单列表打不开了,点一下转半分钟,计划文员等着投料排产。」

我打开监控看了一眼:P95 800ms,峰值 1.2 秒。再看服务器,数据库 CPU 40%,应用 CPU 20%——两边都不忙,但它就是慢。

这种「谁都不忙、接口就是慢」的情况,九成不是服务器性能不够,而是你在用最贵的方式拿数据。具体到 .NET 项目,绝大多数时候问题出在 EF Core 的查询姿势上。

这篇里的排查顺序和改法,我整理进了《.NET 生产环境避坑手册》第二章,一共三篇 EF Core 事故复盘,文末可以自取。先看结论:下面几个点,任何一个单独做都能砍掉一大截耗时,全做完是 800ms 到 60ms。

一、先别改代码,把 800ms 拆开看

我见过太多人一上来就加缓存、加 Redis、换 ORM,最后发现根本没找对地方。

改代码之前,先拿三个数:

要拿的数 怎么拿
接口总耗时 在 Action 里 Stopwatch 包一层,或者看 APM / 日志里的耗时字段
SQL 执行总耗时 EF Core 日志 LogTo,或数据库侧 Extended Events
一次请求发了几条 SQL 同上,数日志条数——这个数最容易被忽略,也最能说明问题

第三个数是关键。我当时数出来是 217 条。

一个「查订单列表」的接口,发了 217 条 SQL。这就不是快慢的问题了,这是设计问题。

打开 EF Core 日志的办法(开发环境够用):

optionsBuilder
    .UseSqlServer(conn)
    .LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name },
            LogLevel.Information)
    .EnableSensitiveDataLogging();   // 仅开发环境!

⚠️ EnableSensitiveDataLogging() 会把参数值打进日志,生产环境千万别开——里面可能有客户名、价格、身份证号。

如果要看某条 LINQ 最终翻译成了什么 SQL,不用连数据库:

var sql = query.ToQueryString();

这一步只花十分钟,但决定了后面所有优化有没有意义。

二、优化点 1:N+1 查询,这是最贵的一刀

现场复现

代码长这样,看着一点问题都没有:

// 一页 50 条订单
var orders = await _db.Orders
    .Where(o => o.Status == OrderStatus.InProduction)
    .OrderByDescending(o => o.CreatedAt)
    .Skip((page - 1) * 50).Take(50)
    .ToListAsync();

foreach (var o in orders)
{
    // 每条订单查客户
    o.CustomerName = (await _db.Customers.FindAsync(o.CustomerId))?.Name;

    // 每条订单查明细条数
    o.ItemCount = await _db.OrderItems.CountAsync(i => i.OrderId == o.Id);

    // 每条订单查最新报工记录
    o.LastReportTime = await _db.WorkReports
        .Where(r => r.OrderId == o.Id)
        .MaxAsync(r => (DateTime?)r.ReportedAt) ?? DateTime.MinValue;

    // 每条订单查是否有待处理异常
    o.HasIssue = await _db.OrderIssues.AnyAsync(x => x.OrderId == o.Id && !x.Resolved);
}

50 条订单 × 4 次查询 = 200 次,加主查询 1 次,201 次往返。再算上分页组件额外查一次总数,217 条对上了。

这就是 N+1:先查 1 次主表,然后为每条记录再查 N 次。单条 SQL 都只要 0.5~2ms,看着不慢,但 200 次往返乘起来就是 700ms——而且这 200 次里,绝大部分时间花在网络往返和连接上下文切换上,不是数据库算得慢。

所以你会看到那个反常现象:数据库 CPU 40%,接口却 800ms。CPU 没忙,都在路上耗掉了。

三种改法,按推荐程度排

改法一:投影一次查完(最推荐)

把「补字段」的活儿交给 SQL,一次查完:

var list = await _db.Orders
    .Where(o => o.Status == OrderStatus.InProduction)
    .OrderByDescending(o => o.CreatedAt)
    .Skip((page - 1) * 50).Take(50)
    .Select(o => new OrderListItemDto
    {
        Id = o.Id,
        OrderNo = o.OrderNo,
        CustomerName = o.Customer!.Name,
        ItemCount = o.Items.Count,
        LastReportTime = o.WorkReports.Max(r => (DateTime?)r.ReportedAt),
        HasIssue = o.Issues.Any(x => !x.Resolved)
    })
    .ToListAsync();

生成的 SQL 会带几个相关子查询或者 LEFT JOIN,1 条搞定。这是我最常用的写法:既解决 N+1,又顺手只取需要的列(第三点会讲为什么这很重要)。

改法二:Include 预加载,但小心数据量

var orders = await _db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .AsSplitQuery()          // 多个集合 Include 时必须加,后面会讲
    .Where(o => o.Status == OrderStatus.InProduction)
    .OrderByDescending(o => o.CreatedAt)
    .Skip((page - 1) * 50).Take(50)
    .ToListAsync();

Include 能把查询压到 1~3 条,但它有个代价:会把整行实体的所有列都查出来。订单表 60 个字段,你页面只用 8 个,剩下 52 个白查、白传、白占内存。

小数据量随手用没问题,列表页这种场景我更倾向投影。

改法三:批量查字典,再在内存里拼

有些统计口径用 LINQ 表达不出来(比如要走存储过程、要跨库),这时候用「先批量查、再内存匹配」:

var orderIds = orders.Select(o => o.Id).ToList();

// 一次查完全部明细条数
var itemCounts = await _db.OrderItems
    .Where(i => orderIds.Contains(i.OrderId))
    .GroupBy(i => i.OrderId)
    .Select(g => new { OrderId = g.Key, Count = g.Count() })
    .ToDictionaryAsync(x => x.OrderId, x => x.Count);

// 一次查完全部报工时间
var lastReports = await _db.WorkReports
    .Where(r => orderIds.Contains(r.OrderId))
    .GroupBy(r => r.OrderId)
    .Select(g => new { OrderId = g.Key, Last = g.Max(x => x.ReportedAt) })
    .ToDictionaryAsync(x => x.OrderId, x => x.Last);

注意 orderIds.Contains(...) 在数据量很大时会生成巨大的 IN 列表。EF Core 8 之后改用 OPENJSON 传参,性能好很多,但也带来过一个 breaking change:在部分老版本 SQL Server 上会冒出「关键字 WITH 附近有语法错误」。这个坑我在手册第二章里单独写过,遇到了别慌,是已知问题。

这一刀下去:217 条 SQL → 2 条,800ms → 260ms。

三、优化点 2:只读查询,一定加 AsNoTracking

解决了 N+1,260ms 还是偏慢。下一步看跟踪开销。

EF Core 默认对查出来的每个实体做变更跟踪:它会保存一份原始值快照,等你调 SaveChanges() 时逐字段比对,决定发什么 UPDATE。

问题在于——列表页根本不改数据,凭什么要为它保存快照?

// 只读查询,加这一个方法
var list = await _db.Orders
    .AsNoTracking()
    .Where(o => o.Status == OrderStatus.InProduction)
    .Select(o => new OrderListItemDto { /* ... */ })
    .ToListAsync();

AsNoTracking() 的效果在数据量小的时候不明显,但一旦是大列表、报表、导出,差别很大:内存占用能差将近一半,因为不用为每行实体存一份快照;SaveChanges() 时的 DetectChanges() 扫描也省了。

几个实践细节:

  • 只要不改,就加 AsNoTracking()。这是默认习惯,不用犹豫。
  • 要改的实体别加,否则 SaveChanges() 不会更新它——这个坑很隐蔽,代码不报错,数据就是没改。
  • 查出来要改一部分字段,用 AsNoTrackingWithIdentityResolution() 或者干脆先 AsNoTracking() 查 DTO、改完再 Update()。
  • 匿名类型和 DTO 投影本身就不会被跟踪,所以投影 + AsNoTracking() 其实有点重复,但加上不亏,语义更清楚。

顺带说个 Blazor Server 的坑

如果你的项目是 Blazor Server,DbContext 的注册方式要特别注意。默认 AddDbContext 注册成 Scoped,在 Blazor Server 里 Scoped 的生命周期是整个用户连接——也就是说一个用户开着页面八小时,这个 DbContext 就活八小时。

后果有两个:一是跟踪的实体越堆越多,内存只涨不跌;二是多个组件并发用同一个上下文,直接抛 A second operation was started on this context instance before a previous operation completed。

正确做法是用工厂,每次操作创建一个短的:

builder.Services.AddDbContextFactory<AppDbContext>(opt =>
    opt.UseSqlServer(conn));

// 使用处
public class OrderService(IDbContextFactory<AppDbContext> factory)
{
    public async Task<List<OrderListItemDto>> GetListAsync(int page)
    {
        await using var db = await factory.CreateDbContextAsync();
        return await db.Orders.AsNoTracking() /* ... */.ToListAsync();
    }
}

这一刀:260ms → 190ms,内存分配少了大约四成。

四、优化点 3:Select 投影,别把整行实体捞回来

前面提过一句,这里单独讲,因为它是最容易被忽略、收益又最直接的一条。

订单主表我有 60 多个字段:客户信息、金额、税率、工艺参数、各种时间戳、备注、扩展字段……列表页只显示 8 个。

不投影的话,EF Core 生成的是 SELECT [o].[Id], [o].[OrderNo], ...(60 个列全查),然后 50 行数据通过 TDS 传回来。

改法很简单,Select 成 DTO:

.Select(o => new OrderListItemDto
{
    Id = o.Id,
    OrderNo = o.OrderNo,
    CustomerName = o.Customer!.Name,
    Qty = o.Qty,
    Status = o.Status,
    CreatedAt = o.CreatedAt
})

三个收益叠在一起:

  1. 数据库少读列——宽表上 60 列和 8 列,IO 差好几倍;
  2. 网络少传数据——这一页的返回体从 3.2MB 降到 400KB 左右;
  3. 应用少分配内存——不用构造 50 个完整实体,也不用为它们存跟踪快照。

有个细节要注意:投影之后导航属性就不能再「顺手」用了。因为查出来的已经是 DTO,没有导航属性这回事。有人投影完发现 item.Customer.Phone 拿不到,以为是 bug,其实是预期行为——需要什么字段,就在 Select 里一次性写全。

这也是我推荐投影的原因:它会逼你把「这个页面到底需要什么数据」想清楚。想清楚之后,SQL 自然就瘦了。

这一刀:190ms → 130ms。

五、优化点 4:深分页别用 Skip,总数别每次都 Count

Skip 的代价随页码线性增长

.Skip((page - 1) * 50).Take(50)

翻到第 200 页时,SQL 是 OFFSET 9950 ROWS FETCH NEXT 50 ROWS ONLY。数据库必须先扫过前 9950 行再丢掉,页码越深越慢。我实测第 200 页要 2.3 秒。

改法是「键集分页」(Keyset Pagination)——不用偏移量,用上一页最后一条的值做起点:

// 上一页最后一条是 id=10240,按 Id 倒序
var list = await _db.Orders
    .AsNoTracking()
    .Where(o => o.Id < lastId && o.Status == OrderStatus.InProduction)
    .OrderByDescending(o => o.Id)
    .Take(50)
    .Select(o => new OrderListItemDto { /* ... */ })
    .ToListAsync();

配合 Id 上的索引,不管翻到第几页,都是索引 seek,耗时基本恒定。第 200 页从 2.3 秒降到 90ms。

代价是不能直接跳到第 N 页,只能「上一页/下一页」。对 ERP 的列表页来说这完全可以接受——业务上没人真的会点到第 200 页,他们要的是「按条件筛选」。

别每次都查总数

分页组件通常要显示「共 X 条」,于是每次请求都来一发:

var total = await query.CountAsync();   // 全表或大范围 COUNT

数据量上百万时,这个 COUNT 可能比列表查询本身还慢。

三个处理办法:

  • 缓存总数:条件不变时缓存 30~60 秒,翻页时直接用缓存值;
  • 只判断「有没有下一页」:多取一条(Take(51)),返回 50 条,多出来那条存在就说明有下一页。前端只需要「上一页/下一页」,压根不需要总数;
  • 近似值:管理后台显示「约 1.2 万条」,用系统视图的行数估算,不需要精确。

这一刀:首页 130ms → 95ms,深页 2.3s → 90ms。

六、优化点 5:批量写入,别在循环里 SaveChanges

上面讲的都是读。写这边有个更普遍、杀伤力更大的写法:

// 千万别这么写
foreach (var row in rows)          // rows 有 1 万条
{
    _db.WorkReports.Add(row);
    await _db.SaveChangesAsync();  // 一万次事务、一万次往返
}

每一次 SaveChanges() 都是一个独立事务、一次网络往返、一次全量 DetectChanges() 扫描。1 万条这样写,月度成本重算跑了 42 秒,而且中途失败会留下一半脏数据。

改法一:AddRange + 一次 SaveChanges

await _db.WorkReports.AddRangeAsync(rows);
await _db.SaveChangesAsync();

一次事务、一次往返。但它仍然是逐条 INSERT,而且 AddRange 到 1 万条时 DetectChanges() 本身就开始慢了。

改法二:分批提交

const int batchSize = 1000;
foreach (var batch in rows.Chunk(batchSize))
{
    await _db.WorkReports.AddRangeAsync(batch);
    await _db.SaveChangesAsync();
    _db.ChangeTracker.Clear();     // 关键:清掉跟踪,防止越跑越慢
}

ChangeTracker.Clear() 这行很多人不知道,但它是分批导入能不能跑住的关键——不清的话,跟踪的实体一直累积,第 10 批比第 1 批慢好几倍。

改法三:ExecuteUpdate / ExecuteDelete,根本不进跟踪

EF Core 7 之后有了批量更新和删除,直接翻译成一条 SQL:

// 把某批订单状态批量改成已关闭,一条 UPDATE
await _db.Orders
    .Where(o => o.Status == OrderStatus.Finished && o.CloseFlag == false)
    .ExecuteUpdateAsync(s => s
        .SetProperty(o => o.CloseFlag, true)
        .SetProperty(o => o.ClosedAt, DateTime.Now));

// 批量删除
await _db.TempImportRows
    .Where(r => r.BatchId == batchId)
    .ExecuteDeleteAsync();

以前这种需求得先 ToList() 全部查出来、改属性、再 SaveChanges()——查一遍、改一遍、写一遍,数据量大时是灾难。现在一条 SQL 完事。

但有两个必须知道的限制:

  1. 绕过变更跟踪,所以你内存里已经加载的实体会变成过期数据,需要手动刷新或重新查;
  2. 不触发 SaveChanges 的拦截器和领域事件。如果你的审计日志、软删除是靠 SaveChanges 拦截器做的,用 ExecuteUpdate 会直接绕过去,审计字段不会写。

第二条我在项目里踩过:批量关单之后发现审计表里一条记录都没有,查了半天才想起来是拦截器没跑。

这一刀:月度成本重算 1 万行,42 秒 → 6.8 秒。

七、优化点 6:多个集合 Include,记得 AsSplitQuery

这个坑比较隐蔽,特征是「数据量不大,但查询突然变慢,还特别吃内存」。

var bom = await _db.Boms
    .Include(b => b.Items)          // 一个 BOM 20 个物料
    .Include(b => b.Operations)     // 30 道工序
    .Include(b => b.Materials)      // 15 种材料
    .FirstOrDefaultAsync(b => b.Id == id);

EF Core 默认用「单查询」模式,把三个集合塞进一个 SQL,用 JOIN 拼。结果会产生笛卡尔积:

20 × 30 × 15 = 9000 行。而 BOM 主表那几十个字段,在这 9000 行里被重复传了 9000 遍。

改法就是加一行:

var bom = await _db.Boms
    .Include(b => b.Items)
    .Include(b => b.Operations)
    .Include(b => b.Materials)
    .AsSplitQuery()                 // 拆成 4 条查询
    .FirstOrDefaultAsync(b => b.Id == id);

拆成 4 条独立查询,主表只传 1 行,每个集合各传自己的行。BOM 树查询 1.6 秒 → 340ms。

也可以全局开:

optionsBuilder.UseSqlServer(conn, o => o.UseQuerySplittingBehavior(
    QuerySplittingBehavior.SplitQuery));

但别一刀切开全局。拆分会多几次往返,单集合、小数据量的场景反而更慢。我的习惯是:两个及以上集合 Include 就加 AsSplitQuery(),单个集合不加。

八、最后那 35ms:别指望 ORM 解决索引问题

做到 95ms 的时候,我已经没什么 EF 层面能改的了。剩下的一刀在数据库:

那个查询的条件是 Status + CreatedAt 组合排序,而表上只有一个 CreatedAt 单列索引。加一个组合索引:

CREATE INDEX IX_Orders_Status_CreatedAt
ON Orders (Status, CreatedAt DESC)
INCLUDE (OrderNo, CustomerId, Qty);

INCLUDE 把列表页要用的列带上,让这个查询变成覆盖索引,不用回表。

95ms → 60ms。

这里要说清楚一件事:ORM 优化的上限是「不浪费」,不是「变快」。 EF Core 能做的是别发 217 条 SQL、别查 60 个用不上的列、别为只读数据存快照。真正的查询速度,最后还是落在索引和执行计划上。

所以顺序不能反:先修 ORM 的浪费,再看索引。 反过来先加索引,你会用索引去喂一个本来就不该存在的 217 条查询。

九、完整账单一览

步骤 动作 SQL 条数 耗时
起点 — 217 800ms
优化点 1 投影 / 批量查字典,干掉 N+1 217 → 2 260ms
优化点 2 只读查询加 AsNoTracking 2 190ms
优化点 3 Select 投影,只取 8 列 2 130ms
优化点 4 键集分页 + 缓存总数 2 95ms
优化点 5 组合索引 + INCLUDE 覆盖 2 60ms

另外两个场景单独算:

场景 改法 前后
月度成本重算(1 万行写入) 分批 + ChangeTracker.Clear() / ExecuteUpdate 42s → 6.8s
BOM 树查询(3 个集合 Include) AsSplitQuery() 1.6s → 340ms

十、怎么防止半年后又被改回去

优化做完就完事,是最容易反弹的。我在项目里留了三道闸:

① 日志里固定打 SQL 条数。 每条请求结束记一条「SQL 条数 + 总耗时」,超过阈值(比如 20 条)就告警。N+1 是慢慢长出来的,今天有人加一个循环内查询,明天再加一个,没人会注意到。

② 慢查询日志带上调用堆栈。 只记「某条 SQL 慢 800ms」没用,你要知道是哪个接口、哪段代码发出来的。

③ Code Review 六条清单:

  • 循环体里有没有 await 数据库操作?
  • 只读查询有没有 AsNoTracking()?
  • 列表页是不是查了整行实体、而不是 DTO 投影?
  • 有没有 Skip 大于一万的深分页?
  • 循环里有没有 SaveChanges()?
  • 两个以上集合 Include 有没有 AsSplitQuery()?

这六条不解决「写得快不快」,只解决「别写出 217 条 SQL」。

最后

回头看这个接口,最讽刺的是:没有一处是「服务器性能不够」。数据库 CPU 40%,应用 CPU 20%,加机器、加内存、加缓存,一样都解决不了。

它慢,是因为代码在拿数据的方式上,每一层都在浪费:查 200 次能一次查完的、存了根本不会改的快照、搬了 52 个用不上的字段、扫了 9950 行只为了跳过它们。

顺便提醒一句:别用缓存盖住慢查询。 缓存能让这个接口看起来变快,但那 217 条 SQL 还在,换个筛选条件、缓存一过期,问题原样回来,而且更难查——因为你已经不知道慢在哪了。

如果你手上也有这种「CPU 不忙但接口慢」的接口,就按上面的顺序过一遍:先数 SQL 条数,再拆跟踪开销,再看投影,然后分页,最后才是索引。

这几步的排查过程,加上另外三篇 EF Core 事故复盘(Contains 报错、延迟加载序列化崩、RowVersion 并发覆盖),我整理进了《.NET 生产环境避坑手册》第二章。

需要的话,在公众号后台回复:避坑,自取。

拿到之后建议先翻第二章,那三篇都是一句话就能对照出来的坑,比看文档快。