2026 年 1 月某个周三下午,我们的 ASP.NET Core 后端服务 report-service 在被一位企业管理员调用"导出全部用户工单"时,Pod 内存从平时的 1.5GB 在 90 秒内冲到 6.1GB,触发 K8s OOMKilled。这位管理员是某客户的 HR 负责人,每月定期做一次这个导出动作,过去 8 个月一直没问题——这次客户数据量长大了,从 8 千条工单增长到 9 万多,后端就崩了。客户当然不满意,我们紧急加内存到 8GB 临时凑合,然后投入 5 天排查根因。
定位的结果让所有人都意外:不是单纯的"大数据量超出内存",而是一个看起来非常合理的 LINQ 查询在 EF Core 8 的默认行为下产生了三层叠加问题——所有 entity 都被 ChangeTracker 持有(导出场景完全不需要)、Lazy Proxy 在 foreach 里触发 N+1 数据库查询(每条工单单独查一次 user / project / status 导航属性)、ToList 把整个 9 万行的对象图加载到内存(包括所有未来才用得到的关联数据)。三者叠加让 9 万条工单的实际内存占用接近 5GB,远超我们的预算。这篇是完整复盘,涵盖 EF Core 的 ChangeTracker 工作机制、N+1 在"看起来没用 lazy"的代码里如何潜伏、4 个具体修复点的工程权衡,以及落地的《.NET ORM 性能纪律》。
服务背景:这个 ASP.NET Core 报表服务
| 维度 | 数值 |
|---|---|
| 业务 | SaaS 工单系统的报表 / 导出服务,客户用来定期拉数据做合规审计 |
| 技术栈 | .NET 8 + ASP.NET Core + EF Core 8 + PostgreSQL 15 + Npgsql 8 |
| 部署 | K8s,4 Pod,每 Pod 2 vCPU + 4GB(事故后临时调到 8GB) |
| 典型 query | SELECT 全部工单 WHERE tenantId = ? AND createdAt >= ? |
| 事故场景 | 某客户工单量从 8k 增长到 90k,导出 API 内存暴涨 OOM |
| 导出格式 | CSV / Excel(用 ClosedXML) |
| 临时处置 | Pod 内存从 4GB 升到 8GB,但仍有 OOM 风险(更大客户数据量更大) |
事故时间线:从 OOM 到根因落地的 5 天
| 时刻 | 事件 |
|---|---|
| 01-13 14:20 | 客户管理员触发导出,Pod 90 秒内 OOM |
| 01-13 14:45 | 临时把 Pod 内存调到 8GB,客户重试,这次"勉强"成功(峰值 7.2GB),但用了 4 分钟 |
| 01-13 下午 | 开始排查。dotnet-counters 看实时指标,Working Set 在导出期间从 1.5GB 直线上升 |
| 01-14 | 用 dotnet-dump 抓 dump,用 dotnet-gcdump 看堆分布。看到 Order entity 实例 ~ 90k 个,User entity 实例 ~ 7800 个(去重后),意外的是这些都是 EF Core tracked entities |
| 01-14 下午 | 用 EF Core 的 logging interceptor 抓所有执行的 SQL,看到 1 条主查询 + 87000+ 条 N+1 查询,每条都是 SELECT * FROM users WHERE id = ? |
| 01-15 | 翻代码,确认问题:主查询是 db.Orders.Where(...).ToList(),后面 foreach 里访问 order.User.Name,触发了 lazy loading 的 N+1 |
| 01-16 | 设计修复:四层组合 — AsNoTracking + projection 到 DTO + Include 预加载 + IAsyncEnumerable 流式 |
| 01-17 | 压测:相同 90k 行导出, 修复前 6.1GB / 90s, 修复后 320MB / 18s,差距巨大 |
| 01-18 ~ | 分批上线,事后扫公司另外 6 个 .NET 项目,发现 4 个有类似 N+1 隐患 |
第一反应:"加 .AsNoTracking() 不就行了"
稍有 EF Core 经验的人第一反应都是 AsNoTracking——它能让查询不进 ChangeTracker,节省内存。我当时也这么想,加上去测试:
| 方案 | 内存峰值 | 耗时 |
|---|---|---|
| 修复前 | 6.1 GB | 90 秒 |
| + AsNoTracking | 4.2 GB | 85 秒 |
有改善但远远不够。AsNoTracking 解决了"ChangeTracker 多余引用",但没解决另一个更大的问题:N+1 仍然在跑,每条工单触发一次 user/project/status 的额外查询。光是这 87000 个查询就消耗大量内存(每个 Npgsql DataReader 的 buffer)和时间(80+ 秒主要花在等待数据库往返)。
"加 AsNoTracking 就行"是个广为流传的错误简化——它只是 EF Core 性能调优的第一步,不是全部。
真凶 1:EF Core ChangeTracker 的默认行为
EF Core 默认对所有查询出来的 entity 启用 change tracking。具体做的事:
- 每个 entity 实例都保留原始值快照(原 column 值的副本),用于后续判断"有没有改过"
- 所有 entity 实例都被 DbContext 持有引用,直到 Context.Dispose
- 对于带导航属性的 entity,如果 Lazy Loading 启用,EF Core 会生成 proxy 类替代原 entity(动态生成,Castle DynamicProxy)
对一个"修改场景"的代码(查出来 → 改属性 → SaveChanges),这套机制是必要的——它让你不用手写 UPDATE 语句。但对于"只读导出场景",这些全部是负担:
| 开销 | 每个 entity 大小 | 9 万行总计 |
|---|---|---|
| entity 实例本身 | ~ 800 字节(取决于列数) | 72 MB |
| 原始值快照 | ~ 800 字节 | 72 MB |
| Castle proxy 包装 | ~ 200 字节 | 18 MB |
| ChangeTracker 内部字典/引用 | ~ 300 字节 | 27 MB |
| 关联导航属性的 collection 实例 | ~ 200 字节 × 3 (User/Project/Status) | 54 MB |
| 合计(只算 Order entity) | ~ 2.7 KB | ~ 243 MB |
这只是 Order 自己,关联的 User / Project / Status 又是几倍。整体堆栈轻松超过 GB。
三层叠加的因果链
这张图最关键的信息是三个因素互相放大:ChangeTracker 让每个对象内存翻倍 / LazyProxies 让查询数翻百倍 / 90k 行让两者的常数都被放大。任何一个单独存在都不会致命,叠加才是灾难。这也是为什么"加 AsNoTracking 不够"——它只去掉了第一个因素,后两个仍在。我们后来内部叫这种问题"复合性能反模式",任何一项性能事故复盘都强制画一张这种因果图,确保不会"修了表面忽略主因"。
真凶 2:N+1 在不用 Lazy Loading 的代码里也会出现
这是最坑的一点。大家都知道"Lazy Loading 会导致 N+1",所以业务代码里没人显式启用 Lazy。但是 EF Core 的实际行为是:
- 如果 DbContext 启用了 UseLazyLoadingProxies,所有导航属性默认 lazy
- 如果你在 foreach 里访问 entity.Navigation,会触发新的 SQL 查询
我们的 DbContext 配置:
public class AppDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseNpgsql(connectionString)
.UseLazyLoadingProxies(); // ❌ 这里!三年前为了某个 use case 启用的, 没人记得
}
}
启用 UseLazyLoadingProxies 后,EF Core 会用 Castle DynamicProxy 包装每个 entity,所有 virtual 导航属性会被拦截,首次访问时触发 SQL 查询。这意味着:
// 业务代码 - 看起来非常普通
var orders = db.Orders.Where(o => o.TenantId == tenantId).ToList();
foreach (var order in orders) {
// 访问导航属性
csvWriter.WriteField(order.User.Name); // 触发 SELECT * FROM users WHERE id = X
csvWriter.WriteField(order.Project.Name); // 触发 SELECT * FROM projects WHERE id = Y
csvWriter.WriteField(order.Status.Label); // 触发 SELECT * FROM statuses WHERE id = Z
}
这段代码看起来就是普通的 LINQ + foreach,但实际上会发出1 + 90000 × 3 = 270001 条 SQL 查询。绝大部分查询 SELECT 同一个 user(因为 90k 工单可能只对应 7800 个 user),但 EF Core 不会缓存——它每次都查,然后在 ChangeTracker 里发现"已经有了",合并到同一个实例。"合并"也是有开销的——而且 SQL 来回 ~ 0.8ms,27 万次就是 216 秒,事故的 90 秒里有 80 秒花在这上面。
更微妙的是用 dotnet-counters 看 CPU 时根本看不出 N+1——CPU 一直很低,因为大部分时间在等数据库。看不出 CPU 高 + 内存高 + 慢,容易误判成"网络慢"或"GC 慢"。EF Core 的 logging 才能暴露真相。
真凶 3:ChangeTracker 的"identity map" 不是免费的
EF Core 的 ChangeTracker 维护了一个 identity map(按 entity 类型 + 主键索引的字典),保证同一个数据库行在同一个 Context 里只有一个 .NET 对象实例。这是个好特性,但它的代价是:
- 每个新查出来的 entity 都要在字典里查一次"是不是已经有了"
- 查到了,合并新数据到旧实例;查不到,加进字典
- 字典的开销随 entity 数量线性增长
在我们的导出场景,主查询返回 9 万条 Order,加上 lazy loading 触发的 7800 个 User + 5400 个 Project + 12 个 Status,identity map 里有 ~ 10.3 万个 entry。每次访问 Order.User 都要在字典里查一次,90k 次 foreach × 3 个导航属性 = 27 万次字典查询。虽然字典查询是 O(1),但 27 万次的常数累加也不小。
修法:四层组合优化
修法 1:AsNoTracking(基础)
导出场景明确是只读,完全不需要 ChangeTracker。加 AsNoTracking 让 EF Core 跳过整个 tracking 机制:
var orders = db.Orders
.AsNoTracking() // ✅ 跳过 ChangeTracker
.Where(o => o.TenantId == tenantId && o.CreatedAt >= since)
.ToList();
但仅此一步不够,Lazy Loading 在 AsNoTracking 下仍然会触发 N+1(EF Core 会临时启动 detached lookup)。
修法 2:Projection 到 DTO(核心)
最有效的优化是不要查 entity,直接查需要的字段投影到 DTO:
public class OrderExportRow {
public long OrderId { get; set; }
public string OrderNumber { get; set; }
public DateTime CreatedAt { get; set; }
public string UserName { get; set; }
public string ProjectName { get; set; }
public string StatusLabel { get; set; }
public decimal Amount { get; set; }
}
var rows = db.Orders
.AsNoTracking()
.Where(o => o.TenantId == tenantId && o.CreatedAt >= since)
.Select(o => new OrderExportRow {
OrderId = o.Id,
OrderNumber = o.Number,
CreatedAt = o.CreatedAt,
UserName = o.User.Name, // ✅ EF Core 翻译成 JOIN
ProjectName = o.Project.Name,
StatusLabel = o.Status.Label,
Amount = o.Amount,
})
.ToList();
EF Core 把这个 LINQ 翻译成一条带 JOIN 的 SQL:
SELECT o.id, o.number, o.created_at, u.name AS UserName,
p.name AS ProjectName, s.label AS StatusLabel, o.amount
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN projects p ON o.project_id = p.id
JOIN statuses s ON o.status_id = s.id
WHERE o.tenant_id = $1 AND o.created_at >= $2
关键好处:
- 只查需要的字段,不查 entity 完整结构(orders 表可能有 30 列,我们只要 7 列)
- 关联表用 JOIN 一次拿,完全消除 N+1
- 结果是 plain DTO,不进 ChangeTracker,不带 proxy 开销
- 内存占用接近裸数据大小(每行 ~ 200 字节,9 万行 ~ 18MB)
修法 3:IAsyncEnumerable 流式处理
就算用 DTO,90k 行加起来 18MB——内存够用。但导出可能涉及百万级数据,把全部加载到内存仍然不安全。最稳健的做法是流式:
public async IAsyncEnumerable<OrderExportRow> StreamOrdersAsync(
long tenantId, DateTime since, [EnumeratorCancellation] CancellationToken ct = default)
{
var query = db.Orders
.AsNoTracking()
.Where(o => o.TenantId == tenantId && o.CreatedAt >= since)
.Select(o => new OrderExportRow {
OrderId = o.Id,
UserName = o.User.Name,
// ...
});
// EF Core 8 的 AsAsyncEnumerable 不会一次性加载, 是 forward-only cursor
await foreach (var row in query.AsAsyncEnumerable().WithCancellation(ct)) {
yield return row;
}
}
// Controller 端写到 HTTP response
[HttpGet("export")]
public async Task ExportAsync(CancellationToken ct)
{
Response.Headers["Content-Type"] = "text/csv";
Response.Headers["Content-Disposition"] = "attachment; filename=orders.csv";
using var writer = new StreamWriter(Response.Body);
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
csv.WriteHeader<OrderExportRow>();
await csv.NextRecordAsync();
await foreach (var row in _exportService.StreamOrdersAsync(tenantId, since, ct)) {
csv.WriteRecord(row);
await csv.NextRecordAsync();
}
}
这种流式实现内存占用接近常数(取决于 Npgsql 的内部 buffer + CSV writer 的 buffer),无论数据量多大,几百 MB 内能处理完。
修法 4:全局禁用 Lazy Loading Proxy
最后我们决定从 DbContext 配置层面彻底关闭 lazy loading proxy:
optionsBuilder
.UseNpgsql(connectionString)
// .UseLazyLoadingProxies() // ❌ 移除
;
// 同时所有 entity 的导航属性, 把 virtual 关键字去掉
// 这样如果有人忘了 Include 直接访问导航属性, 编译时还过, 运行时是 null
public class Order
{
public long Id { get; set; }
public long UserId { get; set; }
public User User { get; set; } = null!; // 注意: 不是 virtual
// ...
}
"运行时 null"听起来吓人,但实际上很好——它强制业务代码显式表达"我需要这个关联"(通过 Include 或 projection),不能"无意识地"触发 N+1。这种"fail loudly"的设计比"安静地慢 100 倍"好得多。
决策树:面对一个新查询应该选什么模式
这棵决策树后来嵌进了我们 .NET 团队的 PR 模板:任何新增 LINQ 查询的 PR,作者必须在 description 里说清楚走的是哪条分支,以及实际生成的 SQL。这一个小改动让团队对 EF Core 的性能直觉提升了一个量级——以前是"先写完测一下没问题就 merge",现在是"写之前先想清楚预期 SQL"。code review 也因此变得更有抓手。
3 天调研中被否决的方案
| 方案 | 看似可行 | 否决理由 |
|---|---|---|
| 把导出整个搬到独立的 Python 脚本 + pandas | 绕过 EF Core 所有问题 | 多维护一条技术栈成本高;且 .NET 团队的核心问题没解决,后续其他报表还会踩 |
| 直接 Dapper 写 raw SQL 替换 EF Core | 性能极致 | 导出代码可以,但全站迁移代价巨大;且失去 EF Core 的 migration / change tracking 红利 |
| Pod 内存调到 16GB + 限制并发 | 简单粗暴 | 治标不治本,数据继续增长还会 OOM;且浪费集群资源 |
| 把导出异步化丢到后台队列, 文件生成完发邮件 | 用户体验降级但稳定 | 需要重做产品流程,客户不喜欢"等邮件";且根本问题——同一份代码内存暴涨——没修 |
| 升级到 EF Core 9 试试 | 新版本可能有优化 | 9.x 还在预览,生产用风险高;且查文档新版本对 lazy proxy + tracking 行为没本质改变 |
每个被否决的方案都让我们更清楚"真正该修什么"。最后选定的 projection + AsNoTracking + 流式三连组合,既是技术最优,也是组织成本最低——所有改动都在导出接口本身,不影响其他模块。
验证:同一导出场景的对比
| 方案 | 内存峰值 | 耗时 | SQL 查询数 |
|---|---|---|---|
| 修复前(原始) | 6.1 GB | 90 秒 | ~ 270,000 |
| + AsNoTracking | 4.2 GB | 85 秒 | ~ 270,000 |
| + Projection 到 DTO | 520 MB | 22 秒 | 1 |
| + AsAsyncEnumerable 流式 | 320 MB | 18 秒 | 1 |
| 禁用 Lazy Proxy(强制显式) | 320 MB | 18 秒 | 1 |
从 6.1GB / 90 秒 到 320MB / 18 秒,内存降到原来的 5%,时间降到 20%。这是改 50 行代码的成果。
顺手扫到的另外 4 个 EF Core 反模式
事后我们扫了公司所有 .NET 服务的 EF Core 用法,常见反模式:
| 反模式 | 问题 | 修法 |
|---|---|---|
| 读路径不用 AsNoTracking | 额外 ChangeTracker 开销 | 读路径默认 AsNoTracking |
| Foreach entity 访问导航属性 | N+1 | 用 Include 或 projection |
| 导航属性都是 virtual + 启用 lazy proxy | 无意 lazy loading | 关 lazy proxy + 去 virtual |
| 大查询 ToList() 全加载 | 内存暴涨 | 分页或流式 |
| 循环里 db.SaveChanges() | 每次都开事务 | 批量 SaveChanges |
| Include 嵌套过深 | JOIN 爆炸,笛卡尔积 | 用 split queries |
| 未禁用 model snapshot 大型 entity | 启动慢 | 用 EF Core 8 的 compiled models |
立的《.NET ORM 性能纪律》
- DbContext 默认禁用 UseLazyLoadingProxies,所有导航属性不允许 virtual(防止无意 lazy)。
- 所有读路径默认 AsNoTracking,需要 tracking 才显式标 .AsTracking()。
- 导出 / 报表场景必须用 projection 到 DTO,不允许查完整 entity。
- 大查询(预期 > 1000 行)必须用流式(AsAsyncEnumerable + IAsyncEnumerable),不允许 .ToList() 全加载。
- EF Core 必须接入 SQL logging(开发环境 100%,生产环境采样),便于发现 N+1。
- 关键查询的 EXPLAIN 结果必须 review,LINQ → SQL 翻译有时不直观,要确认。
- Include 嵌套不允许超过 2 层,需要更深的关联用 split query 或 多次查询 + 内存 join。
- 性能基线测试必须在 PR 合并前跑,关键 API 的内存 / 耗时不允许退化超过 10%。
给读者的几条自查清单
- 打开 DbContext,看有没有 UseLazyLoadingProxies 配置。有就基本上是 N+1 隐患。
- grep 你的读路径代码,看有没有 AsNoTracking。没有的等于浪费 ~ 40% 内存。
- 开 EF Core SQL logging(
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information)),跑一次典型 API 流程,看实际发出多少 SQL。 > 5 条就要警觉。 - 找你最大的查询(行数最多的 .ToList()),改成 .AsAsyncEnumerable() 看内存差异。差别大说明原来浪费了。
- 检查 entity 类,导航属性是否都是 virtual。是的话不是 lazy proxy 模式就是历史遗留,需要清理。
- 用 dotnet-counters monitor 看 .NET 应用的 working set + GC,导出 / 报表 API 调用时是否陡升。陡升说明加载了不该加载的东西。
- 用 dotnet-gcdump 抓堆 dump,看 EF Core 内部对象(ChangeTracker / EntityEntry / ProxyType)的数量,过多说明有"长期持有 DbContext"的问题。
这次事故让我对 ORM 有了新的敬畏:它的便利性是有代价的,这个代价只有在数据量增长时才会暴露。3 年前写的代码对 8k 行数据"足够好",对 90k 行数据就崩塌。这种"sublinear vs superlinear"的临界点几乎所有 ORM 项目都会遇到——区别在于你是提前发现还是被生产事故教育。
另一个心得:EF Core 的"默认配置"在某些场景下是反模式。UseLazyLoadingProxies、change tracking 默认开、virtual 导航属性的暗示式 lazy ——这些"开发友好"的默认值在生产 + 大数据量下都是隐患。新项目从一开始就要明确配置:关 lazy proxy + 默认 AsNoTracking + 强制 projection,这是更"工程化"的姿势。"开发便利"不应该以"生产隐患"为代价。
这次复盘的长期收益
| 维度 | 修复前 | 修复后 60 天 |
|---|---|---|
| 报表服务 Pod 内存配额 | 8 GB(临时加上去的) | 降回 4 GB,且 P99 working set 1.2 GB |
| 导出 API P95 耗时 | 90 秒(90k 行) | 18 秒 |
| 支持的最大客户数据量 | ≤ 100k 行(再多 OOM) | 已实测 500k 行稳定 |
| EF Core 相关线上事件 | 每月 2-3 次内存 / 慢查询告警 | 0 次 |
| 团队 .NET 性能调优能力 | "AsNoTracking 就完事了" | "projection 优先 + 流式优先 + SQL 必须看" |
| 新加 LINQ 查询的 PR review 抓手 | 无,看不出性能影响 | 必须贴预期 SQL 和决策树分支 |
降回 4GB 这一项很意外——原以为修复是"省一些内存",结果是"省到可以缩容一半"。一个 4 Pod * 8GB 的服务变成 4 Pod * 4GB,集群层面立刻多出 16GB 容量给其他服务,K8s 资源利用率从 58% 提到 72%。这种"性能优化反向带来成本节约"的链路,在云原生环境格外明显。
认知更新:对 EF Core 和 ORM 的 4 个新认知
- ORM 不是"屏蔽 SQL 让你不用懂数据库",而是"用 LINQ 表达 SQL 让你写得更少"。前者是错的——不懂 SQL 的人写 EF Core 一定会出问题;后者是对的——懂 SQL 的人才能用好 ORM。新人入职第一周培训重点放"看 SQL"而不是"学 LINQ"。
- ChangeTracker 是写场景的核心,在读场景就是负担。EF Core 之所以默认开 tracking,是因为不知道你这个 query 是读还是写;一旦你知道,就要明确告知。AsNoTracking 不是"性能优化技巧",是"读路径的正确写法"。
- Lazy Loading 这个特性应该被 EF Core 默认废弃。它解决的是"少写 .Include()"的便利问题,代价是无意识的 N+1。在 2026 年,业内已经基本共识"显式优于隐式",lazy proxy 这种黑魔法应该退场。Microsoft 自己的 docs 也开始建议"prefer explicit loading"。
- "性能问题"和"架构问题"经常是同一件事。这次的根因不是某个 query 写错,是 3 年前架构决策时埋下的 lazy proxy + 默认 tracking 的双重默认值。修代码改 50 行,修架构默认值改 5 行——但后者影响所有后来的代码,长期价值高得多。
这次事故让我对 ORM 有了新的敬畏:它的便利性是有代价的,这个代价只有在数据量增长时才会暴露。3 年前写的代码对 8k 行数据"足够好",对 90k 行数据就崩塌。这种"sublinear vs superlinear"的临界点几乎所有 ORM 项目都会遇到——区别在于你是提前发现还是被生产事故教育。
另一个心得:EF Core 的"默认配置"在某些场景下是反模式。UseLazyLoadingProxies、change tracking 默认开、virtual 导航属性的暗示式 lazy ——这些"开发友好"的默认值在生产 + 大数据量下都是隐患。新项目从一开始就要明确配置:关 lazy proxy + 默认 AsNoTracking + 强制 projection,这是更"工程化"的姿势。"开发便利"不应该以"生产隐患"为代价。
第三个心得是关于"性能 dump 的价值"。这次定位过程里最关键的一步是 dotnet-gcdump 抓了一个堆 dump,然后在 PerfView 里按类型聚合看到 EntityEntry 实例数惊人。光看 CPU / 内存曲线只能知道"有问题",真要定位"问题在哪个类型"必须看 dump。我们现在所有 .NET 服务都接入了周期性 dump 采集(每天凌晨低峰自动抓一次,保留 7 天),出问题时不用等"复现"就能立刻看上一次健康状态的 dump 做对比。这套机制让"事后归因"从 6 小时压到 30 分钟。
第四个心得:"修这个 bug"和"修这类 bug"是两件事。原本我们计划改完报表服务就收工,后来主动扫了全公司 6 个 .NET 项目又挖出 4 个潜在 N+1。一次复盘的真正价值不是修当下,是把同类问题在它们爆雷前都摸出来。这种"主动扫雷"耗时大约是修一个 bug 的 3 倍,但避免 4 次类似事故——ROI 极其划算。我们后来在 SRE 团队设了一个固定流程,每次 P1 / P2 事故复盘后必须做"同类扫雷",这套流程半年下来主动避免了 11 次潜在事故,口碑提升非常明显。
下次拿到一个性能问题,如果是 .NET + EF Core 项目,第一件事开 SQL logging 看真实查询量——大概率你会发现一个让你惊讶的数字。然后这篇文章里的修法可以直接抄。如果你在生产环境跑了类似的优化,欢迎在评论区分享你的内存 / 耗时对比数据,以及踩到的其他 EF Core 反模式——这块经验在中文社区还相对稀缺,每一份实战数据都是后来者的灯塔。
最后再补一个工程文化层面的反思:这次事故触发前其实有 3 次小预警——内存告警刚触阈值的几次小尖峰、客户偶尔抱怨"导出有点慢"、新人 onboarding 时问过"为什么所有 entity 都是 virtual",每次大家都用"还能用"、"是历史代码"、"先这样"绕过去了。所有大事故都有它的"预热信号",区别只在团队有没有把它当回事。我们后来在事故管理里专门加了一个"小信号月度复盘"机制——把过去 30 天的所有低优先级告警 + 客户小抱怨 + 新人提出的"为什么这样"问题集中拉出来过一遍,挑出可能升级成事故的提前修。半年下来这个机制至少提前避免了 2 次类似量级的 OOM,投入产出比远超事后排查。希望读到这里的你也能在自己团队里建立类似的"小信号雷达",别再让一个看似无害的 default 配置把团队 3 年后的某个周三午后毁掉。
说到底,这次 5 天复盘最值钱的不是 320 MB / 18 秒这个最终数字,而是团队对 EF Core 的认知从"工具"升级到"权衡"。以前大家觉得 EF Core 是个"装上就用的便利层",现在大家明白它的每一个默认值背后都有取舍,每一行 LINQ 背后都有一段你要负责理解的 SQL。这种认知升级会渗透到后续所有数据访问代码里,以"看不见的方式"提升整个团队的产出质量。事故的最大红利从来不是 fix 本身,是它强行让团队补上了原本想偷懒不学的那门课。从这个角度,这次 5 天复盘是我们 .NET 团队近两年最划算的一次投资,值得在年底总结里专门记一笔,也希望读到这里的你能从中省下原本要付出的那 5 天排查代价和那次本来注定会发生的客户体验事故,把这次教训提前内化成你自己团队的工程默认值和最佳实践模板,真正把数据访问层做扎实,把性能问题挡在生产线之外。
—— 别看了 · 2026