EF Core 性能陷阱:N+1 查询与批量更新的实战踩坑
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 会让开发者以为"不用管",结果性能爆炸。我的做法分三步:
- 显式加载:用
Include把关联数据一次性查出来 - 批量更新:避免循环里 SaveChanges,改成一次性提交
- 性能监控:加 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%。