ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

70GB Dump揪出EF Core内存泄漏:一行AsNoTracking救回服务

70GB Dump揪出EF Core内存泄漏:一行AsNoTracking救回服务 那天下午的告警来得很突然。生产环境一台跑 .NET Core API 8.0 的机器内存占用在半小时内从 2GB 一路冲到了接近上限。第一反应是查日志、看接口但一圈下来CPU 平稳、连接数正常、接口响应也还凑合唯独内存像漏了底的水桶只涨不落。更麻烦的是没等我们缓过神进程直接崩了Windows 事件日志里留下了一条“crash dump triggered”的记录。等我们把目光转向 EF Core 的时候隐约感觉这事没那么简单。谁都没想到最后会折腾出一个 70GB 的 Dump 文件再绕了一大圈最终的修复仅仅是加了一行AsNoTracking。这篇文章把整个排查链路完整记录下来包括我怎么抓的 Dump、怎么从海量对象里定位到 ChangeTracker、为什么三层架构会把问题放大以及最后那“一行代码”背后的原理和取舍。1. 内存告警与第一轮猜测常见嫌疑犯为什么都被排除了1.1 现象出现时的现场还原先说环境。这套服务是 .NET 8.0 Web APIORM 用 EF Core 8.0结构是典型的 Controller - BaseService - BaseRepository 三层。所有业务实体都继承统一的BaseEntity所有仓储类都继承BaseRepositoryT看起来中规中矩代码评审也没看出毛病。出事那天我们刚开始灰度一个后台报表接口这个接口会跨四张表关联查询近三个月的订单数据。上线后约二十分钟监控告警弹出来了内存使用率从常规的 2.5GB 左右持续攀升半小时内涨到 12GB 以上且没有任何回落迹象容器内存上限是 16GBCPU 却稳定在 10% 以下完全不像死循环或高计算负载这里有个关键细节内存上涨是持续性的、阶梯式累积不是在请求结束后回落。如果是临时大对象GC 会在每个请求完成后回收一部分内存曲线应该是锯齿状。但这次曲线几乎只涨不降说明有大量对象被长期引用GC 根本无法回收。1.2 第一轮排查被冤枉的三个“背锅侠”遇到内存问题团队第一反应通常是那几个老面孔这次我们也把嫌疑对象挨个过了一遍。嫌疑一连接泄漏。很多 .NET 老项目内存泄漏确实是连接没关闭导致的但我们检查了数据源监控连接数始终在正常范围连接池也没有持续增长。说明连接释放是正常的。嫌疑二DbContext 生命周期问题。在 ASP.NET Core 里AddDbContext默认注册为 Scoped每个请求一个实例请求结束自动 Dispose。我们确认过是AddDbContext且没有手动单例化 DbContext这个方向基本排除。嫌疑三缓存滥用。有人怀疑是不是IMemoryCache里塞了什么大对象但翻遍代码这个报表接口没有写缓存的逻辑其他接口写入缓存的数据量也很小。排查到这一步常规手段全部失效。我盯着监控看了一会儿发现一个有意思的现象内存涨到一定程度后会有一个小幅回落随即立刻继续上涨。这说明 GC 一直在尝试回收但每次回收都不彻底根节点上挂着的东西越来越多。1.3 决定抓 Dump唯一能看清内存真相的手段排查内存问题光靠猜不行。在 .NET 平台上想知道托管堆里到底堆了什么东西最有效的办法就是抓进程 Dump然后分析托管堆对象分布。这里我多说一句很多人觉得 Dump 文件特别大、不好搞就尽量回避。但实际上内存类问题里 Dump 分析是效率最高的一条路。一次完整的dumpheap -stat能把对象类型按数量排出来谁占内存、有多少实例一目了然。比起挨个怀疑、反复改代码、再发版验证 的循环抓 Dump 反而更省时间。下一个问题就是怎么抓一个正在崩的边缘、内存接近上限的 .NET 进程的 Dump还不把服务器搞死。2. 抓取 70GB Dump一场与磁盘赛跑的“考古行动”2.1 工具选型与操作命令我用的工具是dotnet-dump.NET 官方诊断工具在目标环境只需要执行两个命令# 安装工具如果没装的话 dotnet tool install -g dotnet-dump # 查看目标进程ID dotnet-dump ps # 抓取Dump文件 sudo dotnet-dump collect -p 12345 -o /data/dumps/api_crash.dmp考虑到机器内存已经接近 16GB进程本身占用又高抓取 Dump 时系统会有短暂的停顿因为需要把整个进程地址空间复制到磁盘。我们当时在夜里低峰期操作的线上几乎没请求所以影响相对可控。如果你遇到的是高峰期建议优先用procdump配合触发条件来抓或者先摘流量再操作。抓 Dump 本身是“内存敏感”操作别在生产高峰硬来。2.2 为什么这个 Dump 高达 70GB这是全程最让人震撼的环节。dotnet-dump collect跑了几分钟结束后我看了眼文件大小70GB。很多人第一反应是“搞错了吧进程内存不是 16GB 左右吗Dump 怎么比内存还大”。其实 Dump 文件包含进程的完整地址空间其中包括了内存映射文件、共享内存等区域所以 Dump 文件体积大于进程私有内存是很常见的事情尤其是当进程里存在大量大对象堆LOH和共享映射区域时。70GB 的 Dump 侧面说明这个进程内部的状态非常复杂不是简单的几个大字符串而是海量小对象加大量内部结构叠加出来的结果。2.3 分析环境准备的血泪经验70GB 的 Dump分析它同样是个体力活。我本地的笔记本根本打不开最后是找了一台 64GB 内存的跳板机把 Dump 文件传过去利用dotnet-dump analyze做远程分析。这里有几个实操经验必须写下来给 Dump 文件留足磁盘空间最好在专门的挂载盘上操作至少预留 Dump 文件大小 1.5 倍的磁盘空间否则抓取到一半磁盘写满功亏一篑。分析命令要“定向”而不是“全量”不要一上去就执行全量dumpheap70GB 的堆会直接卡死终端。先用条件过滤或者使用分页参数。耐心等待索引构建dotnet-dump analyze首次加载大 Dump 会有一段索引构建过程看起来像卡住了其实是在构建对象图耐心等就行。我第一次执行dumpheap -stat等了两分多钟输出结果出来后整个人愣住了。3. dumpheap 之后最恐怖的一幕百万级对象全部“活着”3.1 对象统计的震撼数据dumpheap -stat的输出很长我截取了几段关键信息Statistics: MT Count TotalSize Class Name ... 7f9d8c4d5a20 1047832 314349600 TestApp.Domain.Entities.OrderEntity 7f9d8c4d5ab40 1047832 419132800 Microsoft.EntityFrameworkCore.ChangeTracking.Internal.ChangeTracker 7f9d8c4d5ac60 1047832 209566400 Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry注意看数字OrderEntity实例数超过 100 万总大小超过 300MB。单个实体对象本身并不大但和它伴生的还有等价数量的InternalEntityEntry、属性值数组、快照字典等结构。这类对象在 EF Core 的 ChangeTracker 里是一整套“伴侣关系”每追踪一个实体就要记录它的当前状态、原始属性快照、导航属性快照、状态标记等。这些伴侣对象加在一起每个实体的实际内存开销往往是实体本身的好几倍。我算了一下光这一套伴侣结构就吃掉了好几 GB。3.2 用 gcroot 追踪谁在引用这些对象找到大量实体对象后下一步是确认它们被什么引用、为什么 GC 回收不掉。我用dotnet-dump的gcroot命令追踪了几个典型对象gcroot 7f9d8c4d5ac60输出显示Found 1 unique roots in PinnedHandle: ... System.Object[] - Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager - ... - Dictionaryobject, InternalEntityEntry引用链最终指向 StateManager也就是 DbContext 内部的状态管理器。这基本坐实了这些实体全部被某个或某几个DbContext 的 ChangeTracker 引用着处于“根引用”状态GC 根本不敢回收。3.3 EF Core 快照追踪机制内存暴涨的原理根源这里要科普一下 EF Core 的默认行为。当 DbContext 执行查询并返回实体时默认会开启快照追踪Snapshot Change Tracking。这意味着EF Core 会在内存中为每个返回的实体建立一个InternalEntityEntry记录实体当前状态Unchanged/Modified/Added/Deleted 等。它会保存一份“原始属性快照”即查询回来那一刻各个属性的值用于SaveChanges时做对比检测。它会把所有被追踪实体放进内部字典StateManager保证同一个上下文里同一个主键只有一个实例。这套机制的初衷是好的让你查询出来之后可以直接修改实体属性然后调用SaveChangesEF Core 自动对比快照和当前值生成 UPDATE 语句。代价就是内存。一旦你查询返回的实体数量达到百万级ChangeTracker 里的伴侣对象数量也跟着变成百万级。这时候内存消耗不是 1 倍数据量而是 3 到 5 倍的数据量甚至更高。我们这次就是典型的“只读查询却走了完整追踪链路”的案例查询结果又恰好是百万级内存直接爆掉。顺带说一句平时业务数据量小这个机制不会引起注意但数据量一上来它就会变成你线上事故的主因。很多人说“EF Core 在数据量大时就是性能垃圾”其实 EF Core 挺冤枉的因为问题不在于 ORM 本身而在于你让它把不该追踪的对象全都追踪了。4. 为什么三层架构会把问题放大BaseRepository 的“通用陷阱”4.1 三层架构里那句被忽略的“所有查询都被追踪”排查到根因之后剩下的问题就是为什么所有查询结果都会被追踪这就要回到三层架构的代码设计。我们项目里有一个BaseRepositoryT所有仓储的公共查询方法都在这里典型代码如下public abstract class BaseRepositoryT where T : BaseEntity { protected readonly AppDbContext _dbContext; protected BaseRepository(AppDbContext dbContext) { _dbContext dbContext; } // 获取全部数据危险写法默认被追踪 public virtual ListT GetAll() { return _dbContext.SetT().ToList(); } // 带条件查询同样默认被追踪 public virtual ListT Find(ExpressionFuncT, bool predicate) { return _dbContext.SetT().Where(predicate).ToList(); } // 分页查询照样被追踪 public virtual PagedResultT GetPaged(int pageIndex, int pageSize, ExpressionFuncT, bool predicate null) { var query _dbContext.SetT().AsQueryable(); if (predicate ! null) query query.Where(predicate); var total query.Count(); var list query.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); return new PagedResultT { TotalCount total, List list }; } }BaseService里再调用这些方法public class OrderService : BaseServiceOrderEntity { public async TaskListOrderEntity GetLastThreeMonthsOrdersAsync(DateTime startDate) { // 直接走 BaseRepository 的 GetAll/Find全部被追踪 return await _repository.FindAsync(o o.CreateTime startDate); } }Controller 再调用 Service。整体链路看起来很干净Controller 只知道 ServiceService 只知道 RepositoryRepository 才知道 DbContext。问题恰恰出在这里——基于泛型的公共查询方法为了“通用”把所有查询都当成“可写查询”来对待于是全部走了默认追踪路径。4.2 为什么平时没爆、偏偏这次爆了这套代码在老的项目里跑了快一年内存一直很平稳。原因很简单数据量级不够。以前每次查询最多返回几千条记录ChangeTracker 里保存几千个实体撑死了占用几 MB 内存根本感知不到。这次报表接口查的是近三个月的订单数据量级是百万级。当查询结果一次性返回百万条记录时ChangeTracker 的开销被迅速放大。加上报表接口本身还会做跨表关联可能有多个实体类型同时被追踪总内存消耗就呈指数级上升。这其实是一个很普遍的情况很多系统在初期数据量小的时候各种“埋雷”代码都不会引爆。等到业务增长、数据积累到百万级某天某个新接口上线内存立刻爆表。所以排查这类问题不能只看改动的地方更要审视整条调用链上每个查询的默认行为。4.3 复盘真正触发这次事故的接口长什么样事后我们复盘了那个报表接口发现它有两个致命设计查询范围过大直接查三个月订单没有强制限制条数也没有先做聚合再返回。查询本身是纯展示用途数据查询出来之后只做统计、拼接 CSV 导出完全没有修改动作。这两个特征组合起来基本就是教科书级的“误用追踪”场景一个只读查询却承载了 EF Core 完整的写追踪开销。5. 一行 AsNoTracking 背后的原理和取舍5.1 最小修复一行代码改掉百万级内存占用定位到问题后修复非常简单。在查询链路里加上AsNoTracking()// 修复前 var orders await _dbContext.SetOrderEntity() .Where(o o.CreateTime startDate) .ToListAsync(); // 修复后 var orders await _dbContext.SetOrderEntity() .AsNoTracking() .Where(o o.CreateTime startDate) .ToListAsync();就这么一处改动发布后内存曲线立刻变成了正常的锯齿状。请求结束后内存回落到基准线不再持续攀升。为什么效果这么立竿见影因为AsNoTracking()让 EF Core 跳过了快照追踪流程不再为每个实体创建InternalEntityEntry不再保存原始属性快照不再把实体放入 StateManager 的字典里查询出来的实体变成了“一次性的普通对象”用完之后就让 GC 正常回收。5.2 什么时候能加 AsNoTracking什么时候不能加很多人看到这里可能会走向另一个极端所有查询一股脑全加AsNoTracking。这也不行要分场景。适合加的场景报表查询、列表展示、导出数据这些场景只需要把数据读出来展示统计类接口只读取不修改查询结果会传给其他系统、不需要回写数据库的场景配合 AutoMapper 投影到 DTO 的只读查询不适合加的场景先查询出来修改属性再SaveChanges提交更新的场景。这种情况去掉追踪后EF Core 无法感知实体变化最常见的报错就是Cannot update identity column或者直接更新失败。需要在同一个 DbContext 中对同一主键实体做多次查询并且希望得到同一个实例的场景。追踪模式下有 Identity Resolution同一个主键只保留一个实例AsNoTracking模式下每次查询都会返回新实例如果你在代码里用引用相等去比较实体就会出错。补充一个折中方案AsNoTrackingWithIdentityResolution()。这个模式既不做快照追踪又保留 Identity Resolution适合那些“需要批量读取且避免同一实体多次实例化”的场景。但它同样不能用于更新实体属于只读场景的增强版。5.3 修改场景的正确姿势显式 AsTracking 或依赖默认即可如果你的业务场景确实需要更新实体保持默认追踪即可不需要额外加什么。默认追踪本来就是 EF Core 的设计初衷查出来、改、保存。需要警惕的是“不需要更新却默认追踪”的浪费。如果你把全局配置改成了NoTracking默认值后面会说那么在更新实体时需要显式.AsTracking()来开启追踪。我个人的经验是以“查询是否最终用于写操作”为标准来区分要不要追踪而不是以代码位置来区分。和写操作相关的查询保持默认追踪和写操作无关的查询一律加上 NoTracking。这个标准简单、好记、评审也容易把关。6. 根治方案把“不追踪”变成默认用法而不是每次手动救火6.1 修改默认追踪行为全局配置 NoTracking单点修复只是治标。我们要做的是让“只读查询默认不追踪”成为架构层面的规范。第一个动作是修改DbContext的全局默认配置public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { // 默认行为改为 NoTracking需要追踪的查询用 AsTracking 显式开启 ChangeTracker.QueryTrackingBehavior QueryTrackingBehavior.NoTracking; } }设置之后所有查询默认都不追踪。如果某个查询确实需要修改实体显式调用.AsTracking()即可var order await _dbContext.Orders.AsTracking() .FirstOrDefaultAsync(o o.Id orderId); order.Status OrderStatus.Shipped; await _dbContext.SaveChangesAsync();这个改动的好处是把“安全模式”变成默认模式“高性能模式的开关”从每次查询层层加码变成只有写操作时才显式开启。团队新成员上手时大部分查询天然就是安全的只有真正要更新数据时才会去了解 Tracking 机制。6.2 改造 BaseRepository让通用方法明确区分读写有了全局默认之后BaseRepository的通用方法逻辑也要跟着调整不然很多方法在无追踪状态下被误用就麻烦了。我们的做法是明确区分两类方法public abstract class BaseRepositoryT where T : BaseEntity { // 只读查询默认不带追踪 public virtual IQueryableT Query() { return _dbContext.SetT().AsNoTracking(); } // 可写查询显式带追踪 public virtual IQueryableT TrackedQuery() { return _dbContext.SetT().AsTracking(); } // 示例获取待导出数据只读 public async TaskListT GetExportListAsync(ExpressionFuncT, bool predicate) { return await Query().Where(predicate).ToListAsync(); } // 示例获取待更新实体可写 public async TaskT GetByIdForUpdateAsync(object id) { return await TrackedQuery() .FirstOrDefaultAsync(BuildKeyPredicate(id)); } }这样从方法命名上就避免了混淆代码评审时几乎不用动脑子就能看出某个查询是否会写库。6.3 批量处理场景的“分页 流式”双保险除了加AsNoTracking对大结果集查询还有两个必杀技一是分页加载。如果业务能接受分页一百万条记录切成 100 页每页 1 万条内存占用会非常平滑。二是流式处理。EF Core 支持流式查询使用AsAsyncEnumerable配合分批ToList可以做到边读边释放await foreach (var batch in _dbContext.Orders .AsNoTracking() .Where(o o.CreateTime startDate) .AsAsyncEnumerable() .Buffer(10000)) // 借助 System.Interactive 或手动分批 { // 每批处理完这一批对象就可被GC回收 ProcessBatch(batch); }这样即使数据量再大内存占用也始终维持在某一批的水平不会随总数据量线性增长。6.4 改造后的验证效果完成全局配置和 BaseRepository 改造后我们重新跑了一遍那个报表接口查询百万条记录整个过程内存峰值从之前的 12GB 降到了 1.2GB请求结束后内存回到基准线曲线呈正常锯齿状接口响应时间反而更快了因为省去了快照构建和状态管理的时间开销观察了三天没有再出现持续内存攀升后来我们又顺手在 CI 里加了一个检查脚本扫描代码中调用仓储查询方法的地方凡是返回ListT或IQueryableT且没有指定 Tracking 行为的给出警告。当然这个东西没法拦截所有问题但至少能让新代码不再主动埋雷。回过来想这次事故最值得记一笔的不是“70GB Dump”这个数字而是排查链路本身从监控告警到 Dump 抓取再到对象统计和引用链分析最后落到一行代码每一步都在缩小问题的范围。很多人遇到内存暴涨第一反应是去改代码、加内存、重启服务但其实一个 Dump 分析就能把所有”猜”的过程省掉。希望这篇实录能帮遇到类似问题的朋友少走弯路。如果你现在也对着一条持续上涨的内存曲线发愁不妨先别急着加配置抓个 Dump 看一眼托管堆罪魁祸首往往就在那堆你不曾留意的默认行为里。
返回列表