← 博客
技术2026-08-293 次阅读

EF Core 性能陷阱:N+1 查询与批量更新的实战踩坑

# EF Core# .NET# C## 性能优化# MES# 数据库

EF Core 性能陷阱:N+1 查询与批量更新的实战踩坑

背景:为什么查询会变慢

MES 系统的工站列表页,当初设计时数据量不大,查询响应在 200ms 内。半年后,产线从 5 条扩展到 20 条,每个工站关联了设备、报工记录、质检结果。页面响应从 200ms 飙到 8 秒,产线主管直接投诉:"系统卡到没法用。"

一查代码,问题出在 EF Core 的导航属性加载上——代码里写的是:

var stations = context.Stations.ToList();
foreach (var station in stations)
{
    var device = station.Device;  // 这里在循环里
    var count = station.ReportCount;
}

看起来没问题,但实际上 EF Core 会为每个 station 单独发一条 SQL 查询 Device 和 ReportCount,20 条记录就是 2 + 40 = 42 条 SQL——这就是经典的N+1 查询问题。

解决思路:一次查询搞定关联数据

EF Core 的核心坑不在于不会写,而在于"太智能"。默认 Lazy Loading 会让开发者以为"不用管",结果性能爆炸。我的做法分三步:

  1. 显式加载:用 Include 把关联数据一次性查出来
  2. 批量更新:避免循环里 SaveChanges,改成一次性提交
  3. 性能监控:加 SQL 日志,发现问题立即定位

踩坑记录一:Include 写错了位置

初次优化,我把代码改成:

var stations = context.Stations
    .Include(s => s.Device)
    .Include(s => s.ReportRecords)
    .ToList();

结果性能没改善多少。后来发现:Include(s => s.ReportRecords) 会 膨胀数据——一个工站有 100 条报工记录,查询结果就是 100 倍的数据量,内存直接吃爆。

正确做法:只加载需要的字段

// 如果只需要统计数,不要加载完整集合
var stations = context.Stations
    .Include(s => s.Device)
    .Select(s => new StationViewModel
    {
        Id = s.Id,
        Name = s.Name,
        DeviceName = s.Device.Name,
        ReportCount = s.ReportRecords.Count  // EF Core 会自动转成子查询
    })
    .ToList();

这样 SQL 会变成 JOIN 聚合查询,数据量不会膨胀。

踩坑记录二:批量更新的三个错误姿势

MES 系统有个夜班作业,要批量更新 200 个工站的"最后运行时间"。第一次写的是:

foreach (var station in stations)
{
    station.LastRunTime = DateTime.Now;
    context.SaveChanges();  // 每次循环都提交
}

这等于发了 200 次 UPDATE,还触发了 200 次触发器、200 次日志写入,夜班作业从 1 分钟变成 10 分钟。

错误姿势 2:批量加载后一次性 SaveChanges

var stations = context.Stations.ToList();
foreach (var station in stations)
{
    station.LastRunTime = DateTime.Now;
}
context.SaveChanges();  // 终于改对了一次

这比第一次好,但 EF Core 会跟踪所有 200 条实体,变更检测开销大,内存占用高。

正确姿势:直接 SQL UPDATE

// 如果只需要更新字段,别用 EF Core
context.Database.ExecuteSqlRaw(
    "UPDATE dbo.Stations SET LastRunTime = {0} WHERE Id IN ({1})",
    DateTime.Now,
    string.Join(",", stationIds));

一条 SQL 搞定,0.05 秒完成。EF Core 适合业务逻辑,不适合纯批量操作。

踩坑记录三:异步查询的陷阱

为了提升响应速度,我把代码改成异步:

var stations = await context.Stations.ToListAsync();

结果发现,异步本身不解决 N+1 问题——如果还是循环里加载关联数据,异步只是"异步地慢"而已。

另外要注意:ToListAsync() 会立即执行查询,如果在循环里调用,依然是 N+1 查询。正确的异步模式是:

// 一次性异步查询,关联数据用 Include
var stations = await context.Stations
    .Include(s => s.Device)
    .ToListAsync();

踩坑记录四:查询缓存没加对

MES 的工站配置页,数据 30 分钟才变一次,但每次请求都查数据库。加了缓存代码:

var stations = await _cache.GetOrCreateAsync("stations", async entry =>
{
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
    return await context.Stations.ToListAsync();
});

看起来 OK,但实际部署后发现:某工站改了配置,缓存还是旧的。原因是缓存失效没处理好。

正确做法:带失效通知的缓存

// 用 CHANGE TRACKING 模式
context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

// 缓存 Key 加时间戳
var cacheKey = $"stations_{context.Stations.Max(s => s.LastUpdateTime):s}";

或者更简单:工站配置表加了 LastUpdateTime 字段,缓存 Key 直接带上这个时间戳,任何改动都会让缓存失效。

踩坑记录五:索引没跟上

优化完 EF Core 查询,SQL 也改成了 JOIN,但性能还是慢。后来发现:表上的索引没匹配查询条件。

查询是:

WHERE DepartmentId = @dept AND IsActive = 1

但表上只有 Id 的主键索引。加了一个复合索引:

CREATE INDEX IX_Stations_DepartmentActive 
ON dbo.Stations(DepartmentId, IsActive) INCLUDE (Name, DeviceId);

INCLUDE 列可以把需要返回的字段也放进去,避免回表查询,性能提升 10 倍以上。

总结

EF Core 性能优化的核心心法:少查、批量、缓存、索引。

  • N+1 查询:用 Include 或 Select 显式加载,别相信 Lazy Loading
  • 批量更新:用直接 SQL,别循环 SaveChanges
  • 异步:异步不解决逻辑问题,先优化查询逻辑
  • 缓存:带失效机制,否则缓存比不缓存更坑
  • 索引:查询变了,索引也要跟着变

MES 系统的性能优化,不是一步到位的,而是边用边调。我现在的做法是:先看 SQL 日志,再改代码,最后看执行计划——这三步走下来,性能问题基本都能定位。

附:性能检查清单

  • 查询日志里有没有 N+1 ?—— 搜 "SELECT FROM Stations WHERE Id ="
  • 循环里有 SaveChanges 吗?
  • 异步查询是一次性还是循环调用?
  • 高频查询有缓存吗?缓存 Key 有失效机制吗?
  • 查询条件的字段有索引吗?复合索引顺序对吗?

照着这清单过一遍,MES 系统的 EF Core 性能问题能解决 80%。

评论 (0)