公司那套内部系统的一个后台服务,跑一晚上内存从 180 MB 涨到 2 G 多,重启就恢复正常,第二天照旧。三个生命周期的概念我背得很熟,但真到自己写的代码上,还是拖了差不多四天才找到原因。这里按我当时的排查顺序记一遍,包括走错的两条路。

一、现象

服务是 .NET 8 的 Worker Service,挂着一个 BackgroundService 每 30 秒拉一次订单做汇总。监控上的内存曲线是一条几乎笔直的斜线:启动 180 MB,每 6 小时涨 400 MB 左右,到第二天早上 2.1 GB,然后因为容器内存上限被杀掉重启。手动 GC 之后能从 2.1 GB 掉到 1.9 GB,说明不是简单的「还没到回收时机」。

第一反应是「有对象一直被引用着没释放」。这句话说了等于没说,但当时我确实只知道这么多。

二、我先猜错了两次

第一次猜的是 IMemoryCache。代码里有一处缓存没设过期时间,我给所有 Set 都加了 5 分钟绝对过期,第二天看曲线,一点没变。后来才想明白:那个缓存的 key 一共只有十几个,撑死几千个对象,不可能涨到 2 G。

第二次猜的是 HttpClient。这个怀疑方向其实是对的,只是对象错了——我们用的是 AddHttpClient,本来就不该有端口耗尽或连接堆积的问题。为了排除它,我把默认的 Handler 生命周期从 2 分钟改成 5 分钟,毫无变化。这两次加起来花了一天半。

三、换工具:dotMemory 的快照对比

第三天我决定不再猜,用 dotMemory 连上去取了两个快照:一个在服务启动 10 分钟后(210 MB),一个在 8 小时后(1.4 GB),然后做 Compare with baseline。差异视图里排在最前面的是 OrderHistory[],比基线多了 41 万个实例,占 1.1 GB。

再点支配树(dominators)往上看,这些数组全部挂在同一个对象下面:ChangeTrackerDbContext → 一个我自己写的 OrderSummaryCache 类。看到这一行的时候我其实已经知道答案了,只是当时不太愿意承认——那个类是我写的。

如果早点用这个工具,大概一天就能收工。我一直拖着不用,是因为觉得「先看代码能不能看出来」,这个想法让我多花了两天。

四、真正的原因

OrderSummaryCache 注册成了 Singleton,构造函数里注入了 AppDbContext,而 AddDbContext 默认是 Scoped。结果就是:Singleton 活多久,那个 DbContext 就活多久;而 DbContext 每查一次数据就把实体放进 ChangeTracker 里记着,一晚上下来记了 41 万个实体,谁也回收不掉。

// 错的地方:Singleton 依赖了 Scoped
services.AddSingleton<OrderSummaryCache>();
services.AddDbContext<AppDbContext>(o => o.UseSqlServer(conn));

有意思的是,.NET 的容器在开发环境下会把这个问题直接报出来——ValidateScopes 默认在开发模式是开的,抛的异常信息写得很清楚。我们生产环境没开,所以一路跑到了线上。这也是我后来改的第一处配置。

五、改法

我最后用的是 AddDbContextFactory,在需要的地方 using var db = await _factory.CreateDbContextAsync(ct),用完就释放。中间也试过注入 IServiceScopeFactory 手动建 scope,能用,但代码里到处是 CreateScope,读起来很乱,第二天就改回去了。

改成工厂之后,同一个服务跑了一周,内存稳定在 220 MB 上下,波动不超过 30 MB。顺手我也把生产环境的 ValidateScopes 打开了,代价是启动时间多了几十毫秒,可以接受。

六、一条我自己的粗糙规则

选生命周期的时候我现在按这个顺序想:先默认 Scoped;确实无状态且创建成本高才考虑 Singleton;Transient 只在明确需要「每次都要新的」时用。还有一条更管用的辅助判断——如果一个 Singleton 的构造函数里出现了 DbContext、或者任何带 IDisposable 的东西,我就停下来再看一眼。

最后交代一个我没做完的地方:改完之后内存虽然平了,但第二个晚上之后还是以每小时几 MB 的速度缓慢上涨,涨到 300 多 MB 就不再动了。我没继续查,可能是某个静态集合在攒东西,也可能是我没看懂 dotMemory 里的另一处差异。等哪天它又开始涨到 G 级,我再补一篇。