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 大对象治理纪律
- 所有 HttpClient 大响应一律 ReadAsStreamAsync + DeserializeAsync:code review 检查
ReadAsByteArrayAsync、ReadAsStringAsync是否必要,默认禁用,需要时加// reviewed: small payload, max XKB注释。 - MemoryStream 不准裸用,中间件 + analyzer 拦截
new MemoryStream(),全部走RecyclableMemoryStreamManager.GetStream(tag),tag 必填,方便诊断。 - byte[] / char[] 长度 ≥85000 必须 ArrayPool,Roslyn analyzer 自定义规则:
new byte[expr]当 expr 可能 ≥85000 时给出 warning,unsafe 路径要显式#pragma warning disable。 - List 预知大小必须传 capacity,启用 IDE 提示 IDE0300/IDE0303,review 时关注没有 capacity 的
new List<X>()是否会增长到 LOH。 - Server GC + GCHeapHardLimit 显式设:容器场景下显式配
DOTNET_GCHeapHardLimit或System.GC.HeapHardLimit为 memory.max 的 75%,留 25% 给非托管(栈、动态库、Linux 文件 cache)。 - 禁用 GC.LargeObjectHeapCompactionMode = CompactOnce 作为常态药:只在紧急止血时临时启用,不准进生产逻辑。
- LOH Live Ratio 监控告警:低于 60% 持续 10 分钟报警,低于 40% 持续 5 分钟传呼。
- 滚动重启不是治理:任何依赖"定时重启 Pod"压住内存的方案视为掩盖问题,必须有根因 Issue。我们曾差点走上"每天定时 rolling restart"的歪路,被技术委员会按下来。
- 新引入第三方库做内存压力测试:任何处理 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)。用 jemalloc 或 heaptrack 之类的工具替换 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