.NET 8 LOH 碎片化导致 ASP.NET Core 每 36 小时 Pod OOMKilled 的 8 天复盘:ArrayPool + RecyclableMemoryStream + Pipelines 三件套落地

三年稳定的 ASP.NET Core 服务在 .NET 6 升 8、Newtonsoft 换 STJ 后开始按表过夜:每 36 小时 Pod 必被 OOMKilled,GC Heap 看着稳定,RSS 却涨到 4GB。8 天复盘最终定位到 LOH 碎片化:序列化路径 4 处 ≥85000 字节瞬时分配把虚拟空间撑成筛子。文章包含 dotnet-gcdump + PerfView 诊断、5 个修法、对照基准、决策树和 9 条治理纪律。

2026 年 3 月,我们一套跑了三年的 ASP.NET Core 8 报价聚合服务突然开始"按表过夜"——每隔 36 小时左右,Kubernetes 必然把所有 6 个 Pod 轮流 OOMKilled 一遍。运维同事一开始以为是流量涨了,把 memory limit 从 2Gi 调到 4Gi,撑到第 52 个小时又挂。我们把 GC 模式从默认的 Server 改成 Workstation,从 36 小时变成 28 小时,反而更短。前后 8 天,翻遍 dotnet-counters、dotnet-gcdump、PerfView 一整套 .NET 内存追踪链,才意识到这根本不是托管堆涨上去,而是 LOH(Large Object Heap)碎片化把进程的虚拟地址空间撑成了筛子。本文是这次事故的完整复盘:从误判 GC 模式开始,到怎么用 ArrayPool + RecyclableMemoryStream + System.IO.Pipelines 把 LOH 几乎清零的全过程,以及我们立的 9 条 .NET 大对象治理纪律。

背景:被遗忘的 LOH 与一套"看起来很健康"的服务

这套服务的画像并不奇怪:ASP.NET Core 8.0.3,跑在 Linux Docker(.NET 官方镜像 mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim),Kubernetes 1.28 集群,Pod 配置 requests=1Gi/limits=4Gi,2 vCPU。业务上做的事情是从 12 家上游供应商 API 拉报价,聚合、归一化、缓存、出价。每条上游响应的 JSON 平均 380KB,最大的极端值 4.2MB(某家全产品列表的响应没有分页)。请求峰值 QPS 1800,P99 80ms,看起来一切都很好。

"看起来很好"维持了三年多。直到 2026 年 2 月,我们做了一次升级:从 .NET 6 升到 .NET 8,顺手把 Newtonsoft.Json 全部换成了 System.Text.Json,目的是降序列化开销。基准测了一周,P99 反而从 95ms 压到 80ms,内存 RSS 也从平均 1.6GB 降到 1.1GB——所有指标都漂亮。然后 3 月 5 日上线,本以为是个"无痛升级"。3 月 6 日凌晨 03:41,第一次告警来了。

维度 升级前 .NET 6 + Newtonsoft 升级后 .NET 8 + System.Text.Json
P99 延迟 95ms 80ms
平均 RSS 1.6GB 1.1GB
GC Gen2 频率 每分钟 2.4 次 每分钟 0.8 次
Pod 重启 / 24h 0 逐日累加,36~52h 后必挂
OOMKilled 时 RSS 3.8~4.0GB

注意"GC Gen2 频率反而下降"这一条——它本是我们误判的起点。当年 Newtonsoft 版本每分钟跑 2.4 次 Gen2,我们以为是性能问题;升级后压到 0.8 次,我们以为是优化。事后回头看,Gen2 频率下降在 .NET 容器场景下不一定是好事,它意味着 LOH 的压缩窗口也变少了——而我们恰好在序列化路径上引入了更多 ≥85000 字节的瞬时大对象。

事故时间线

时刻 事件
3/6 03:41 第一个 Pod OOMKilled,RSS=3.96GB。K8s 自动拉起新 Pod,看似平息。
3/6 10:20 晨会同步,SRE 认为是新版本流量上涨副作用,把 limits 提到 4Gi 已生效,继续观察。
3/7 15:53 同样的 Pod 36 小时后再次 OOMKilled。开始严肃排查。
3/7 18:00 第一轮怀疑:连接泄漏。检查 HttpClient、SqlConnection,全部用 IHttpClientFactory + using,无泄漏。
3/8 02:00 第二轮怀疑:缓存膨胀。MemoryCache 上加了 SizeLimit,但发现 cache size 一直稳定在 480MB,不是源头。
3/8 14:00 第三轮:用 dotnet-counters 监控 Pod,发现 gc-heap-size 稳定在 1.2GB,但 Process Working Set 涨到 3.4GB,中间凭空多了 2.2GB。
3/8 22:30 关键转折:让 SRE 在线上一个 Pod 上 dotnet-gcdump 抓 gcdump,本地 PerfView 打开,看见 LOH 占用 1.6GB,而 Live Objects 只有 280MB,说明 LOH 大部分是空洞。
3/9 全天 定位到序列化路径上 4 处会触发 ≥85000 字节分配的代码。出第一版修复:大对象走 ArrayPool。
3/11 上线 v1 RSS 涨幅从每天 2.0GB 降到 0.7GB,仍未根治。
3/12 引入 Microsoft.IO.RecyclableMemoryStream + System.IO.Pipelines,把 MemoryStream 与 byte[] 的瞬时分配全部转池化。
3/13 上线 v2 连续运行 96 小时,RSS 稳定在 1.05~1.15GB,Pod 重启清零。
3/14 复盘,立 9 条治理纪律,补监控。

第一轮排查:被 dotnet-counters 误导的两天

我们最先做的是在 Pod 里跑 dotnet-counters,这是 .NET 官方推荐的诊断工具。Pod 上事先装了 dotnet-tool:

# 进 Pod
kubectl exec -it pricing-api-7d9c4-xxx -- bash

# 找到目标进程 pid
ps -ef | grep dotnet

# 实时打印关键计数器
dotnet-counters monitor -p 1 \
  --counters System.Runtime,Microsoft.AspNetCore.Hosting

看到的结果是这样的(我手抄了一段当时的截屏):

[System.Runtime]
    CPU Usage (%)                                 38
    Working Set (MB)                            3,412
    GC Heap Size (MB)                           1,184
    Gen 0 GC Count (Count / 1 sec)               0.42
    Gen 1 GC Count (Count / 1 sec)               0.18
    Gen 2 GC Count (Count / 1 sec)               0.01
    LOH Size (B)                          1,612,398,592
    POH (Pinned Object Heap) Size (B)        24,576,000
    Allocation Rate (B / 1 sec)              28,447,232
    Number of Active Timers                         312
    Time spent in JIT (ms / 1 sec)                  0.4

第一眼看到 LOH Size 1.6GB,我们的反应是"哦,那就调 GC.LargeObjectHeapCompactionMode 让它压缩一次"。这是误区 1。当时执行了:

// 错误的应急手段
GC.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Aggressive, blocking: true);

结果:STW 暂停 1.4 秒,Working Set 从 3.4GB 短暂降到 2.6GB,但 1 小时内又涨回去。这证明 LOH 大对象在不停被分配,不是历史包袱压不下去,而是上游一直在制造新的

这里浪费了将近 4 个小时:我们写了一个后台 Hosted Service 每 30 分钟自动跑一次 LOH 压缩,认为"既然涨得回去,那就周期性把它压一次,总能维持住"。压力测试环境跑了 2 小时,看 RSS 曲线确实漂亮的锯齿形,峰值不超过 2.6GB,大家以为找到了便宜解。结果上到生产 40 分钟后被业务方投诉:每 30 分钟一次 P99 飙到 3 秒,影响了端到端的报价链路 SLA,我们紧急回滚。这件事的教训:诊断手段不能当治理手段。GC.LargeObjectHeapCompactionMode 是诊断 LOH 是否能被压缩、压缩后能省多少的工具,不是定时清扫的扫帚。同样的还有 GC.Collect(),绝大多数生产代码里调用它都是错的——除非你在分配峰值前后明确知道要主动腾位置,且 STW 代价你算清楚了。

误区:GC Server 模式 vs Workstation 模式

同事提了一个常见建议:"容器里把 GC 改成 Workstation 模式,Server 模式吃内存"。我们也试了。在 .csproj 加:

<PropertyGroup>
  <ServerGarbageCollection>false</ServerGarbageCollection>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

上线后,RSS 平均增长速率确实降了 15%——但 P99 从 80ms 飙到 220ms,因为 Workstation GC 只用一个 GC 线程,峰值时来不及回收;而且 LOH 碎片化问题原封不动还在。我们 4 小时后回滚。这条弯路告诉我们:容器里 .NET 8 Server GC + GCHeapHardLimit 配置正确时,几乎总是优于 Workstation,前提是别忘记容器内存限制感知。.NET 8 已经能从 cgroup v2 读 memory.max,但需要 ASP.NET Core 8.0.2+ 才修了 cgroup v2 的一个 bug,我们 8.0.3 是没问题的。

但其实,如果根因是 LOH 碎片,无论 Server 还是 Workstation 都救不了。LOH 默认不压缩。.NET 5 之后引入了"按需压缩",但触发条件苛刻,且每次压缩都是 STW。把 STW 压缩当常态药用,是把性能问题换成可用性问题。

关键定位:gcdump + PerfView 看见 LOH 是空洞

转折点出现在 3/8 晚上。我们换工具,直接抓 gcdump:

# 在 Pod 内执行,产物 .gcdump 文件大约 30~80MB
dotnet-gcdump collect -p 1 -o /tmp/heap_36h.gcdump

# 拷到本地分析
kubectl cp pricing-api-7d9c4-xxx:/tmp/heap_36h.gcdump ./heap_36h.gcdump

# Windows 上用 PerfView 打开 Memory -> GC Heap -> View Heap Stacks

PerfView 直接展示出 LOH 的对象拓扑。关键发现:LOH 总大小 1.61GB,但 GC 看到的 Live Objects 只占 280MB,其余 1.33GB 都是 free segments(空洞)。也就是说,LOH 里来回扎过无数大对象,每次 GC 回收后留下"洞",新来的大对象因为大小不匹配填不进旧洞,只能往后扩段,最终把进程虚拟空间一点点撑大。Live Objects 里我们看到了 4 个反复出现的类型:

类型 典型大小 来源代码路径
byte[] 120KB ~ 4.3MB 上游响应原始 body(HttpClient.ReadAsByteArrayAsync)
char[] 200KB ~ 6.1MB System.Text.Json 反序列化时 string 缓冲
MemoryStream 内部 _buffer 96KB ~ 2.1MB 聚合后再序列化写出
List<PriceItem> 内部数组 88KB ~ 1.2MB 聚合容器(扩容到下一档容量)

注意 LOH 的阈值是 85000 字节(约 83KB)。任何分配 ≥85000 字节的对象数组都会直接进 LOH,而不是 Gen0/1。Newtonsoft 时代,我们底层有一个手写的 BufferPool 包了一层,所以这些大对象天然池化;迁到 System.Text.Json + HttpClient.ReadAsByteArrayAsync 后,这层缓冲池没了——每次请求都新建一个 ≥85000 字节的 byte[]、char[]、MemoryStream 内部 buffer,LOH 当然不堪重负。

问题本质:LOH 的分配/回收为什么会撑大虚拟地址空间

这张图回答了一个本质问题:RSS 涨 ≠ GC Heap Size 涨。GC Heap Size 是托管堆"使用中"的大小,但操作系统看的是进程虚拟地址映射出去的物理页。LOH 一旦扩段,即便里面绝大部分是空洞,这些段的物理页通常也已经被触碰过,被 Linux 计入 RSS,从而被 cgroup 统计。.NET 9 引入了 DATAS(Dynamic Adaptation To Application Sizes)能在部分场景主动归还,但 .NET 8 没有,我们必须从源头解决。

多说一句关于"GC Heap Size 看着稳定"的迷惑性:dotnet-counters 给的 GC Heap Size 不包含未压缩的 LOH 空洞,它统计的是当前 GC 认为"还活着"的对象总和。这就解释了为什么这次事故里 GC Heap Size 全程稳定在 1.2GB,但 RSS 一路涨到 4GB——空洞不算进托管堆使用量,但它占了虚拟地址空间和物理内存。我们在事后写文档时,把这一条单独标黑加粗放在 .NET 容器化排查 Wiki 的第一段,因为它实在太反直觉,极容易让排查方向跑偏到"找内存泄漏"的死胡同里。一旦认识到这点,gcdump 就成了首选工具,因为只有它能直接看到 LOH 的段内布局。

顺带一提,Windows 上的诊断流程会更顺一些——PerfView 可以直接 attach 到 dotnet 进程抓 ETW trace,看 LOH 分配栈;Linux 容器没有 ETW,只能 dotnet-gcdump 离线分析,丢失了一些动态信息。但 .NET 8 开始支持 dotnet-trace + ep(EventPipe),也能采集到 GC 事件,只是要会用 SpeedScope 或 PerfView 解析。这条工具链 Linux 上比 Windows 落后大约 5 年,如果团队是 Windows 服务器迁 Linux 的,要心里有数。

修法 1:HttpClient 响应直接走 Stream + 池化 buffer

原代码:

// 旧实现:整块 byte 进 LOH
public async Task<PriceResponse> FetchAsync(string vendor, CancellationToken ct)
{
    using var resp = await _http.GetAsync(vendor, ct);
    resp.EnsureSuccessStatusCode();
    // 380KB 的 byte 直接进 LOH
    var bytes = await resp.Content.ReadAsByteArrayAsync(ct);
    // System.Text.Json 反序列化又内部分配 char
    return JsonSerializer.Deserialize<PriceResponse>(bytes, _jsonOpts)!;
}

改造后:

// 新实现:直接流式反序列化,不实例化大 byte 数组
public async Task<PriceResponse> FetchAsync(string vendor, CancellationToken ct)
{
    // HttpCompletionOption.ResponseHeadersRead 让 GetAsync 在收到 header 就返回
    using var resp = await _http.GetAsync(vendor,
        HttpCompletionOption.ResponseHeadersRead, ct);
    resp.EnsureSuccessStatusCode();

    // ReadAsStream 不会一次性把 body 读进 byte 数组
    await using var stream = await resp.Content.ReadAsStreamAsync(ct);

    // 异步流式反序列化,内部用小 buffer 分块读
    return (await JsonSerializer.DeserializeAsync<PriceResponse>(
        stream, _jsonOpts, ct))!;
}

两个关键点:第一,HttpCompletionOption.ResponseHeadersRead 必须显式传,否则 HttpClient 默认会把整个 body 缓冲到内存再返回——直接退化成 byte[] 路径。第二,DeserializeAsync 内部使用大约 16KB 的 buffer,落在 Gen0/1,不进 LOH,GC 几乎零代价回收。

这一步把 byte[] 大对象彻底干掉,但 char[]、MemoryStream、List 内部数组还在。

顺便提一个容易忽视的点:HttpClient 默认开启了 HttpCompletionOption.ResponseContentRead,这是为了让 HttpResponseMessage 在用户拿到对象时就已经完成 body 拉取——简化使用,但也意味着内存一次性吃掉整个 body。在拉远端报价、解析大文件、转发 SSE 流这三类场景里,默认值都是错的。我们后来在 HttpClientFactory 注册时用了一个 HttpMessageHandler 拦截器,默认对所有出站请求强制 ResponseHeadersRead,只有显式 opt-out 的小请求才走 ResponseContentRead。一年内类似的内存问题在另外两套服务上也被这种"中间件兜底"的方式预防了。

修法 2:用 RecyclableMemoryStream 替换 MemoryStream

聚合结果再序列化、回写响应,以前是 new MemoryStream()。Stream 长度一旦超过 85000 字节,内部 byte[] 就直接进 LOH。微软自己有现成的解决方案 Microsoft.IO.RecyclableMemoryStream,Bing/Office 内部用了多年,API 完全兼容 MemoryStream,但底层是多个小块缓冲(默认 128KB 一块)从池子里租用。

// Program.cs 单例注册
builder.Services.AddSingleton<RecyclableMemoryStreamManager>(_ =>
{
    var opts = new RecyclableMemoryStreamManager.Options
    {
        BlockSize = 128 * 1024,
        LargeBufferMultiple = 1024 * 1024,
        MaximumBufferSize = 16 * 1024 * 1024,
        AggressiveBufferReturn = true,
        GenerateCallStacks = false,
        MaximumLargePoolFreeBytes = 256L * 1024 * 1024,
        MaximumSmallPoolFreeBytes = 64L * 1024 * 1024,
    };
    return new RecyclableMemoryStreamManager(opts);
});

// 注入并使用
public sealed class AggregateService
{
    private readonly RecyclableMemoryStreamManager _streamMgr;

    public AggregateService(RecyclableMemoryStreamManager mgr) => _streamMgr = mgr;

    public async Task WriteAggregatedAsync(
        AggregatedPrice payload, Stream responseBody, CancellationToken ct)
    {
        // tag 用于诊断:出问题时可以看到谁在租用
        await using var ms = _streamMgr.GetStream(tag: "aggregate-write");
        await JsonSerializer.SerializeAsync(ms, payload, _jsonOpts, ct);
        ms.Position = 0;
        await ms.CopyToAsync(responseBody, ct);
    }
}

这里有一个被反复问的问题:RecyclableMemoryStream 的 BlockSize=128KB,这块自己不就在 LOH 上吗?那不还是 LOH 问题?答案是:对,块本身在 LOH;但块被池子永久持有、循环复用,LOH 上是稳定的几十~几百个大对象,不再有"分配-死亡-分配"的循环,所以也就没有空洞产生。这正是池化的核心价值:把瞬时大对象变成长期对象,把 LOH 当成一个固定大小的工作区,而不是流水线。

修法 3:ArrayPool 兜底所有自己写的大数组

聚合容器、临时缓冲、CSV 行拼接……凡是有 byte[]、char[]、PriceItem[] 显式 new 出来且可能 ≥85000 字节的,都改成 ArrayPool。

// 旧:每次新分配
var buf = new byte[bodyLength];
await stream.ReadExactlyAsync(buf, 0, bodyLength, ct);

// 新:租用 + 归还
var pool = ArrayPool<byte>.Shared;
byte[] buf = pool.Rent(bodyLength);
try
{
    await stream.ReadExactlyAsync(buf.AsMemory(0, bodyLength), ct);
    DoWork(buf.AsSpan(0, bodyLength));
}
finally
{
    // clearArray=false:不清零,我们能保证下次用前会写满
    pool.Return(buf, clearArray: false);
}

两条坑:第一,ArrayPool.Rent(n) 返回的数组长度 不一定等于 n,它会向上取一个 2 的幂(比如请求 100000,可能拿到 131072),所以必须用 AsSpan(0, n)AsMemory(0, n) 限制使用范围。第二,涉及敏感数据(token、用户隐私)时,clearArray 一定要传 true,否则下次别人租到这块还能看到你的旧数据——曾经的真实 CVE 模式。

修法 4:List 扩容到 LOH 的预防

聚合 12 家供应商,可能有 5~20 万条 PriceItem。List<PriceItem> 内部数组的扩容倍率是 2,扩到 1 万条以上就直接进 LOH 了。两种解法:

// 方案 A:提前预估容量,一次到位避免多次扩容
// 12 家 * 平均 8000 条 ~ 96000,预留 10% 余量
var items = new List<PriceItem>(capacity: 110_000);

// 方案 B:用分块容器(更彻底)
internal sealed class ChunkedList<T>
{
    private const int ChunkSize = 4096;
    private readonly List<T[]> _chunks = new();
    private int _count;

    public void Add(T item)
    {
        int chunkIdx = _count / ChunkSize;
        int idx = _count % ChunkSize;
        if (chunkIdx == _chunks.Count)
            _chunks.Add(new T[ChunkSize]);
        _chunks[chunkIdx][idx] = item;
        _count++;
    }

    public int Count => _count;

    public IEnumerable<T> AsEnumerable()
    {
        for (int c = 0; c < _chunks.Count; c++)
        {
            int limit = (c == _chunks.Count - 1) ? _count - c * ChunkSize : ChunkSize;
            for (int i = 0; i < limit; i++) yield return _chunks[c][i];
        }
    }
}

我们在 90% 路径选了方案 A——简单,且性能开销最低;少数极大列表用了方案 B。

还有一个反直觉的细节:List<T> 的扩容是按 2 倍翻番,但很多人会忘记 Dictionary<K,V>HashSet<T>Queue<T> 也有内部数组,而且 Dictionary 还会用到两个数组(entries + buckets),触发 LOH 的门槛更低。我们在排查时发现一处 Dictionary<string, PriceItem> 直接装了 18 万条,内部 entries 数组将近 4MB,稳稳进 LOH。对策一致:能预估就传 capacity,不能预估就考虑用 ConcurrentDictionary<K,V>(它的分段设计天然不容易出大数组)或者干脆分桶手写。

修法 5:System.IO.Pipelines 在高吞吐 endpoint 上的应用

对一个上传 + 转发的 endpoint(供应商把订单结果回推过来,我们再分发到下游 4 个消费者),我们做了更彻底的改造:用 System.IO.Pipelines 完全避免任何中间 byte[] 缓冲。

[HttpPost("/webhook/vendor-callback")]
public async Task<IActionResult> VendorCallback(CancellationToken ct)
{
    var reader = Request.BodyReader;
    var jsonReaderState = new JsonReaderState();

    while (true)
    {
        ReadResult result = await reader.ReadAsync(ct);
        ReadOnlySequence<byte> buffer = result.Buffer;

        // 边读边喂给 Utf8JsonReader,不需要 byte 数组
        if (TryParseChunk(ref buffer, ref jsonReaderState, out var parsed))
        {
            await _dispatcher.DispatchAsync(parsed, ct);
            reader.AdvanceTo(buffer.Start, buffer.End);
        }
        else
        {
            reader.AdvanceTo(buffer.Start, buffer.End);
        }

        if (result.IsCompleted) break;
    }
    return NoContent();
}

Pipelines 在 ASP.NET Core 内部就是 Kestrel 的传输层,对外暴露 Request.BodyReader。它的内部 buffer 全部走池化,正确使用下 LOH 零分配。这条路径改完后,这个 endpoint 的 P99 从 47ms 降到 18ms,GC Gen0 频率降 60%。

Pipelines 的入门门槛比 Stream 高:理解 ReadOnlySequence<byte> 不是单块内存而是多段拼接、SequencePosition 怎么 advance、FlushAsync 的反压语义,都需要时间熟悉。建议先在单一 endpoint 上做实验,跑 1~2 周稳定后再推广,不要一上来就全栈改造。我们当时也是先在 webhook 这条非关键路径上做,验证一周后才往主链路推。

修法对照与基准

v1(只做修法 1+3)上线 3 天后做对照,v2(再加修法 2+4+5)再跑 4 天:

指标 原始(.NET 8 升级后) v1 修法 1+3 v2 修法 1~5 全开
LOH Size (稳态) 1.4 ~ 1.6 GB 0.6 ~ 0.8 GB 180 ~ 240 MB
LOH Live Ratio 17% 54% 92%
Pod RSS (24h 均值) 3.1 GB(且持续涨) 1.7 GB(微涨) 1.10 GB(稳定)
P99 延迟 80ms 72ms 61ms
GC Gen2 频率 / min 0.8 0.4 0.12
每天 OOMKilled 次数 6 次 0 0
72 小时 RSS 增长 +5.8 GB(被 K8s 杀) +1.1 GB +0.04 GB

"LOH Live Ratio"是我们自己加的指标:Live Objects 大小 / LOH 总大小。健康值应该 ≥80%,低于 50% 说明 LOH 大量碎片化。这个指标用 EventCounters 跑不出来,我们写了一个后台任务每分钟 GC.GetGCMemoryInfo() + 自己累加 LOH 对象大小,推到 Prometheus。

这里值得展开聊一下为什么我们坚持自己造这个指标,而不是直接报警 LOH Size。LOH Size 高不一定有问题——只要它的 Live Ratio 是 80% 以上,说明确实有那么多大对象在用,加内存或者重构数据结构是正解,不是池化。Live Ratio 低才是池化能救的场景。区分这两类问题是治理的第一步,否则你会发现"按 LOH Size 报警"会把团队带到"加 limits"的歧路上去。Prometheus 我们做了两个面板:一个 LOH Size 时序图,一个 Live Ratio 时序图,叠加 Pod RSS,三条线一起看,一眼就能区分"真大对象常驻"还是"碎片化"。

决策树:.NET 服务出现 LOH 高占用时怎么走

我们立的 9 条 .NET 大对象治理纪律

  1. 所有 HttpClient 大响应一律 ReadAsStreamAsync + DeserializeAsync:code review 检查 ReadAsByteArrayAsyncReadAsStringAsync 是否必要,默认禁用,需要时加 // reviewed: small payload, max XKB 注释。
  2. MemoryStream 不准裸用,中间件 + analyzer 拦截 new MemoryStream(),全部走 RecyclableMemoryStreamManager.GetStream(tag),tag 必填,方便诊断。
  3. byte[] / char[] 长度 ≥85000 必须 ArrayPool,Roslyn analyzer 自定义规则:new byte[expr] 当 expr 可能 ≥85000 时给出 warning,unsafe 路径要显式 #pragma warning disable
  4. List 预知大小必须传 capacity,启用 IDE 提示 IDE0300/IDE0303,review 时关注没有 capacity 的 new List<X>() 是否会增长到 LOH。
  5. Server GC + GCHeapHardLimit 显式设:容器场景下显式配 DOTNET_GCHeapHardLimitSystem.GC.HeapHardLimit 为 memory.max 的 75%,留 25% 给非托管(栈、动态库、Linux 文件 cache)。
  6. 禁用 GC.LargeObjectHeapCompactionMode = CompactOnce 作为常态药:只在紧急止血时临时启用,不准进生产逻辑。
  7. LOH Live Ratio 监控告警:低于 60% 持续 10 分钟报警,低于 40% 持续 5 分钟传呼。
  8. 滚动重启不是治理:任何依赖"定时重启 Pod"压住内存的方案视为掩盖问题,必须有根因 Issue。我们曾差点走上"每天定时 rolling restart"的歪路,被技术委员会按下来。
  9. 新引入第三方库做内存压力测试:任何处理 byte[]、流、序列化的库,引入前在沙盒里跑 1 小时高压负载,看 LOH 增长曲线;不达标的整改或不用。事故后的复查中,System.Text.Json 在 ReadAsByteArrayAsync 配合下表现没有 Newtonsoft + 内部 BufferPool 好,完全是这个流程缺失的代价。

LOH Live Ratio 监控的实现

public sealed class LohMonitor : BackgroundService
{
    private readonly ILogger<LohMonitor> _logger;

    public LohMonitor(ILogger<LohMonitor> logger) => _logger = logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // GetGCMemoryInfo 是 .NET 5 之后才有,且不会触发 GC
                var info = GC.GetGCMemoryInfo(GCKind.Any);
                long lohSize = info.GenerationInfo[3].SizeAfterBytes;
                long fragmented = info.GenerationInfo[3].FragmentationAfterBytes;
                long lohLive = fragmented > 0 ? lohSize - fragmented : lohSize;

                double ratio = lohSize == 0 ? 1.0 : (double)lohLive / lohSize;

                if (ratio < 0.4)
                    _logger.LogError("LOH Live Ratio dangerously low: {Ratio:P1} ({Live}/{Size})",
                        ratio, lohLive, lohSize);
                else if (ratio < 0.6)
                    _logger.LogWarning("LOH Live Ratio low: {Ratio:P1}", ratio);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "LOH monitor tick failed");
            }
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

这个 BackgroundService 注册成单例后,30 秒一次输出 LOH Live Ratio,作为关键报警源。GC.GetGCMemoryInfo 是诊断而非触发 GC 的 API,代价极低。

顺带:几个被我们也试过、但放弃了的方案

方案 试过吗 为什么不用
定时 GC.Collect(2, Aggressive) 试过 STW 1.4 秒,把 P99 推到 3 秒以上;且不解决根本碎片
切换 Workstation GC 试过 峰值 GC 跟不上,P99 翻 2 倍
升 .NET 9 用 DATAS 评估过 升级窗口要排到 6 月,先用 .NET 8 内的工具治本
把 limits 调到 8Gi 讨论过 典型的"加内存"反模式,只是把死亡时间从 36h 推后,不解决问题
K8s 定时 rolling restart 讨论过 掩盖问题,见纪律 8
换回 Newtonsoft.Json 讨论过 把锅扣给 STJ 不公平,真正源头是缓冲池缺失

给读者的几个直接可拿走的检查项

如果你的 .NET 服务现在 RSS 不停涨而 GC Heap 看起来正常,可以这样做一次自检:

# 1. 抓 gcdump
dotnet-gcdump collect -p <pid> -o snap1.gcdump
sleep 1800
dotnet-gcdump collect -p <pid> -o snap2.gcdump

# 2. PerfView 打开,对比 LOH 段大小、Live Ratio
# 3. dotnet-counters monitor System.Runtime --counters
#    重点看 LOH Size、Allocation Rate、Working Set 三者的趋势

如果发现 LOH Size 与 Working Set 的差值正在拉大、LOH Live Ratio 持续低于 60%,你大概率撞上了和我们一样的问题。先用 grep 扫一遍代码:

# 高危 API 自检 grep
rg "ReadAsByteArrayAsync|ReadAsStringAsync" src/
rg "new MemoryStream\(" src/
rg "new byte\[" src/
rg "new char\[" src/

把这四条扫出来的位置,按修法 1~4 逐条改造,绝大多数 LOH 问题就能解决。Pipelines 是进阶,建议高吞吐 endpoint 再上。

几个延伸问题的 FAQ

Q1:.NET 9 的 DATAS 会不会让这些治理纪律都过时? 不会。DATAS 的核心是让 GC 在低负载时主动归还内存给操作系统,缓解"长尾驻留",但对 LOH 碎片化没有特别针对的优化。即便升上 .NET 9,池化、Pipelines、ArrayPool 这些工程实践依然是性能与稳定性的核心。DATAS 更像是一道安全网,不是一台清扫机。

Q2:为什么不直接把 LOH 阈值改高(比如 1MB)? 这是常被问到的:.NET 提供了 DOTNET_GCLOHThreshold 环境变量,可以把 LOH 阈值从默认 85000 字节抬到更高。但抬高阈值只是把更多大对象塞回 SOH(Small Object Heap),它们的分配代价上去了(SOH 需要压缩、复制),而且 Gen2 频率会显著上升。除非你有非常明确的、可基准化验证的收益,否则不要动这个参数。我们测过抬到 200000 字节,P99 上升 18%,放弃。

Q3:ArrayPool.Shared vs ArrayPool.Create(),什么时候用后者? 默认全部用 Shared,它是全局单例、CPU 友好、线程友好的实现。只有当你有非常特定的访问模式(比如固定大小的 buffer 反复借用,且不希望和别人的 Pool 抢资源)时,才考虑 Create() 自己实例化一个。我们有一个 metrics 上报路径需要 4KB 固定 buffer,用了 Create() 隔离开,效果稳定。

Q4:System.Text.Json 在大对象场景下到底好不好? 比 Newtonsoft 好。我们曾误以为换回去就行,真做了 benchmark 一对比,STJ 在流式反序列化(DeserializeAsync)上的吞吐比 Newtonsoft 高 60%,且分配少一半;问题的真正来源是我们把它当作"对 byte 数组反序列化"用,而不是"对 Stream 反序列化"。换工具不如换姿势。

Q5:对 Native 内存(非 GC 管理)的泄漏怎么排查? 这是本文之外的话题,但要简单提一句:如果 GC Heap + LOH 都健康但 RSS 还在涨,大概率是 native 部分(P/Invoke、SkiaSharp、SQLite、Kestrel 内部 native socket buffer)。用 jemallocheaptrack 之类的工具替换 glibc 的 malloc,可以看出非托管堆的分配栈。我们有一个图片缩略图服务就靠 jemalloc 抓出过 SkiaSharp 的一处泄漏。

总结

这次事故让我意识到三件事:第一,RSS 涨 ≠ GC Heap 涨,.NET 容器化场景的内存治理,GC Heap Size 只是冰山一角,LOH 碎片、ArrayPool 长期对象、Pinned Object Heap、栈大小、动态库都在 Working Set 里。第二,"少 GC"在容器里不总是优势:LOH 默认不压缩,Gen2 频率低 = 压缩机会少,碎片化机会就多。第三,升级是高风险点,我们升 .NET 6 → 8、Newtonsoft → STJ 时,benchmark 全是"绿"的,问题需要 36 小时才显形——任何升级后必须有 ≥72 小时的稳定性观察期,RSS 增长曲线必须是收敛的而非线性增长。如果 8 天前有人告诉我"LOH 碎片化",我会笑笑说"那是 .NET Framework 时代的事";而 8 天后我把这话纹在了 wiki 顶上:LOH 一直都在,只是它在你不池化的时候沉默地报复你。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Saga 分布式事务库存幽灵占用事故复盘:2 个月 47 单灵异库存 + 状态机持久化 + 幂等 + dead-letter 监控三件套

2026-5-26 17:56:33

技术教程

TypeScript discriminated union exhaustiveness check 漏写引发 ¥21.8 万对账偏差的 4 天复盘:never 守卫 + ESLint + 运行时白名单三层兜底

2026-5-26 18:27:37

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索