.NET 8 数据网关 P99 每 18 分钟飙到 4.2 秒的 5 天复盘:async 三连击反模式 + ThreadPool 饥饿定位 + 4 种修法 + 12 条 async 纪律

我们的 .NET 8 + ASP.NET Core 实时数据网关每 18 分钟出现 P99 4.2 秒尖刺,5 天定位到 .Result 同步阻塞 + 缺 ConfigureAwait + 自定义同步 Logger 三连击导致 ThreadPool 饥饿,4 种修法把 P99 稳定回 28ms。

2026 年 3 月,我们一个基于 .NET 8 + ASP.NET Core 的实时数据网关服务(data-gateway),被 SRE 紧急拉群——"服务每隔 18 分钟左右就会出现一次 P99 延迟尖刺,从 30ms 飙到 4.2 秒,持续 8-12 秒后恢复"。这个网关承接公司所有 IoT 设备的实时数据上报(每秒 8 万条),延迟尖刺导致每天 350 万条数据被丢弃,数据分析团队的实时报表受影响。

这次复盘是我们 5 天定位 .NET 8 ConfigureAwait 误用 + ThreadPool 饥饿 + Logger 同步 IO 三连击的完整路径。从最初怀疑 GC、网络、上游服务,到最终发现真凶是"async 链路里偶发的同步阻塞 + ThreadPool 注入算法响应滞后"。这是 .NET 异步编程里最隐蔽的一类问题,书上很少讲,但生产里非常常见。这篇文章给你一份"async 反模式排查清单 + 4 种修法"。

故障现场:这个网关的规模

维度 规模/参数
技术栈 .NET 8.0.3 + ASP.NET Core + Kestrel
部署 K8s,12 个 Pod,每 Pod 2 vCPU / 4GB RAM
QPS 8 万/秒(分摊到 12 Pod),单 Pod 6700 qps
响应模式 HTTP POST 短连接 + 异步落 Kafka
正常 P99 30ms
尖刺时 P99 4.2 秒
尖刺频率 约 18 分钟一次,每次持续 8-12 秒
受影响数据 350 万条/天被丢(超时上游放弃重试)

关键现象是"周期性"——18 分钟一次,几乎是节拍器一样规律。这种规律性强烈暗示不是外部因素(网络、上游),而是服务内部的某种"积累 + 释放"循环。

事故时间线

时间 事件
D1 上午 SRE 报警,P99 4.2s。怀疑 GC,加 GC log,Gen2 GC 每次只占 80ms,排除
D1 下午 怀疑网络抖动,抓 Pod 出入向 tcpdump,网络层延迟一直 < 2ms,排除
D2 怀疑 Kafka producer 阻塞,加 producer 耗时埋点,Kafka 写入 P99 始终 < 20ms,排除
D3 用 dotnet-counters 看 ThreadPool,发现尖刺时 ThreadPool Queue Length 从 0 飙到 28000+,定位到 ThreadPool 饥饿
D4 用 dotnet-trace + PerfView 分析,定位到 3 处 ConfigureAwait 误用 + 1 处 Logger 同步 IO
D5 修法上线,P99 稳定回到 28ms,尖刺消失

第一轮排查:GC / 网络 / 上游全部排除

前 2 天我们走了几条经典的"延迟尖刺"排查路径,全部排除:

# 1. GC 怀疑:开 GC log
export DOTNET_gcServer=1
export DOTNET_gcConcurrent=1
dotnet-counters monitor -p $PID --counters System.Runtime[gc-heap-size,gen-0-gc-count,gen-2-gc-count,time-in-gc]

# 结果:Gen2 每 4 分钟一次,80ms,远低于 4 秒
# Time in GC: 1.2%,正常

# 2. 网络抓包:确认入站和出站都没问题
tcpdump -i eth0 -nn -w gateway.pcap 'port 80'
# Wireshark 看 RTT,稳定 1.5ms

# 3. Kafka 写入延迟:在 producer 内加 stopwatch
# 结果 P99 18ms,正常

2 天没进展后,我开始怀疑"不是 .NET 之外的问题,可能是 .NET runtime 层面的问题"。.NET 异步编程里有几个"隐性坑":SynchronizationContext、ThreadPool 饥饿、ConfigureAwait 误用。这几个都不会在普通监控里直接暴露,需要专门的工具。

第二轮排查:ThreadPool 饥饿浮出水面

D3 我用 dotnet-counters 加了 ThreadPool 维度,立刻看到了"作案现场":

dotnet-counters monitor -p $PID --counters System.Runtime[\
  threadpool-thread-count,\
  threadpool-queue-length,\
  threadpool-completed-items-count,\
  monitor-lock-contention-count]

# 平时:
# threadpool-thread-count: 28
# threadpool-queue-length: 0-12
# monitor-lock-contention-count: < 5/s

# 尖刺时(对比):
# threadpool-thread-count: 28 -> 缓慢爬到 65(每秒增加 1 个,这是 .NET ThreadPool 注入算法)
# threadpool-queue-length: 0 -> 28000+ -> 慢慢下降
# monitor-lock-contention-count: 突然 200+/s

这给了关键信号:每隔 18 分钟,ThreadPool 队列会突然堆积 28000+ 任务,导致新进来的请求排队等线程,P99 从 30ms 飙到 4 秒。同时锁竞争激增,说明有大量任务在"互相等待"。

.NET ThreadPool 的注入算法是"每秒最多增加 1 个 worker thread"(称为 starvation detection),这就是为什么尖刺恢复要 8-12 秒——队列堆积到 28000 任务后,ThreadPool 慢慢扩到 65 线程才能消化掉。这套算法是 .NET 1.0 时代设计的,在高并发场景下响应太慢。

第三轮排查:用 dotnet-trace 定位到 3 处反模式

D4 我用 dotnet-trace 抓了一次尖刺期间的 trace,然后用 PerfView 分析"长时间阻塞的线程都在干什么":

dotnet-trace collect -p $PID --providers Microsoft-DotNETCore-SampleProfiler --duration 00:00:30 -o gateway.nettrace
# 等到尖刺触发,抓 30 秒

# 用 PerfView 打开 nettrace 文件,看 "ThreadTime Stacks" 视图

PerfView 显示"BLOCKED_TIME" 列里,几个线程长时间阻塞在 WaitOneMonitor.EnterTask.Wait 这几个调用上。顺着调用栈往上扒,定位到 3 处 .NET 异步反模式:

反模式 1:Task.Result / Task.Wait 同步阻塞

// 反面教材 1:在 async 方法里用 .Result(同步阻塞 async)
public async Task<IActionResult> IngestData(IngestRequest req)
{
    // 看似异步,实际 .Result 同步阻塞线程
    var enriched = _enrichmentService.EnrichAsync(req).Result;
    await _kafkaProducer.ProduceAsync(enriched);
    return Ok();
}

// 反面教材 2:在 ASP.NET Core 里用 Task.Wait()
public IActionResult IngestSync(IngestRequest req)
{
    Task.Run(async () => {
        await _kafkaProducer.ProduceAsync(req);
    }).Wait();  // 阻塞当前线程 + Task.Run 又占一个线程
    return Ok();
}

这两种写法在 .NET Framework 时代会引发"经典死锁"(因为 ASP.NET Framework 有 SynchronizationContext),在 ASP.NET Core 里不会死锁,但会让线程阻塞,加剧 ThreadPool 饥饿。我们代码里第三方 SDK 的封装层就有这种写法,因为以前是从 .NET Framework 迁移过来的代码。

反模式 2:ConfigureAwait(false) 缺失 + Library 阻塞

// 反面教材:在库代码里没用 ConfigureAwait(false)
public class EnrichmentService
{
    public async Task<EnrichedData> EnrichAsync(IngestRequest req)
    {
        // 没加 ConfigureAwait(false),默认 captureContext=true
        var meta = await _metadataClient.GetAsync(req.DeviceId);
        var rules = await _ruleEngine.EvaluateAsync(req.Data);
        return new EnrichedData(meta, rules);
    }
}

ASP.NET Core 没有 SynchronizationContext,所以缺 ConfigureAwait(false) 不会引发死锁。但在库代码里,如果调用方是 .NET Framework 应用(我们这个 SDK 同时给 Framework 和 Core 用),就会有问题。更隐蔽的是:即使在 .NET Core 里,缺 ConfigureAwait(false) 也会让 continuation 调度多一次"capture context"操作,每次大约 10-50 微秒,在 8 万 qps 下累积起来是可观的开销。

反模式 3:Logger 同步 IO(罪魁祸首)

// 反面教材:每条请求都同步写日志,文件 IO 阻塞 ThreadPool
public class CustomFileLogger : ILogger
{
    private static readonly object _lock = new();
    private readonly string _filePath;

    public void Log<TState>(LogLevel level, EventId id, TState state,
        Exception ex, Func<TState, Exception, string> formatter)
    {
        var msg = formatter(state, ex);
        lock (_lock)
        {
            // 同步 IO + 全局锁,8 万 qps 下完美的瓶颈
            File.AppendAllText(_filePath, $"{DateTime.Now}: {msg}\n");
        }
    }
}

这是真正的元凶——我们继承自老项目的一个自定义 Logger,每条日志都同步写文件 + 全局锁。在 8 万 qps 下,这个全局锁让所有线程串行排队,每个线程在锁上阻塞 0.5-2ms。平时还好,但当日志量在某些时段突然增加(比如健康检查 + 业务日志同时多)时,锁竞争爆炸,ThreadPool 所有线程都被卡在 lock 里,队列堆积。

四种修法

修法 1:消灭所有 .Result / Wait()(收益:消除主要阻塞)

// 正解:async 一路到底
public async Task<IActionResult> IngestData(IngestRequest req)
{
    var enriched = await _enrichmentService.EnrichAsync(req).ConfigureAwait(false);
    await _kafkaProducer.ProduceAsync(enriched).ConfigureAwait(false);
    return Ok();
}

// 接口层强制 async,Controller 方法签名也是 Task<IActionResult>

这一步消除了 ThreadPool 队列 60% 的堆积。

修法 2:库代码统一加 ConfigureAwait(false)(收益:减少 context switch 开销)

我们用 Roslyn Analyzer 自动检查,所有库项目必须开启 CA2007 规则,缺 ConfigureAwait(false) 报警。

<!-- .editorconfig -->
[*.cs]
dotnet_diagnostic.CA2007.severity = error
# CA2007: Consider calling ConfigureAwait on the awaited task

# 库项目额外配
[Libraries/**/*.cs]
dotnet_diagnostic.CA2007.severity = error

修完之后,p50 延迟从 18ms 降到 12ms,虽然不解决尖刺,但日常延迟更稳定。

修法 3:换成 Serilog + 异步 Sink(收益:消除元凶)

// 正解:用 Serilog 异步 Sink
// Program.cs
builder.Host.UseSerilog((ctx, lc) => lc
    .WriteTo.Async(a => a.File(
        "logs/gateway-.log",
        rollingInterval: RollingInterval.Day,
        buffered: true,
        flushToDiskInterval: TimeSpan.FromSeconds(1)
    ), bufferSize: 10000, blockWhenFull: false)  // 关键:满了就丢,不阻塞业务
    .WriteTo.Async(a => a.Console())
);

// 业务代码不变
_logger.LogInformation("Ingested {DeviceId} {Size}bytes", req.DeviceId, req.Data.Length);

Serilog 的 Async sink 用一个独立的后台线程消费日志队列,业务线程只是入队(O(1) 无锁操作)。这是消除 P99 尖刺的关键一步。

修法 4:ThreadPool 初始线程数调优(收益:缩短"扩容期")

// Program.cs - 启动时设置 ThreadPool minimum
ThreadPool.SetMinThreads(workerThreads: 100, completionPortThreads: 100);
// 默认 minimum = Environment.ProcessorCount,2 vCPU 容器只有 2
// 设成 100 后,前 100 个线程不受"每秒注入 1 个"算法限制

这一步让 ThreadPool 即使遇到突发流量也能立即响应。但要注意不要设太大——超过 200 反而会因为线程上下文切换过多,P50 延迟上升。我们最终值是workerThreads=100(经过 4 次调优找到最优点)。

修复前后对比基准

指标 修复前 修法 1+2 后 修法 1+2+3 后 修法 1-4 全部
P50 延迟 18ms 12ms 9ms 8ms
P99 延迟(稳态) 30ms 22ms 18ms 15ms
P99 尖刺峰值 4.2s 1.8s 350ms 28ms(消失)
尖刺频率 18 分钟/次 40 分钟/次 2 小时/次
ThreadPool 峰值队列 28000 9000 2200 < 200
每天丢数据 350 万条 120 万条 5 万条 0

最终 P99 从 4.2s 降到 28ms,降幅 99.3%,丢数据完全消失。日志体积反而比之前小 20%(因为之前同步写有重复 flush)。

决策树:.NET async 反模式排查

我们立的 12 条 .NET async 编程纪律

  1. async 一路到底,绝不混合同步:出现 .Result / Wait() 立刻 PR review 打回;
  2. 库代码强制 ConfigureAwait(false):CA2007 设为 error,缺一个 ConfigureAwait 都不让合并;
  3. 不要在 ASP.NET Core 里写 Task.Run(async ...).Wait():这是用 2 个线程做 1 个线程的事;
  4. 不要写自定义同步 Logger:用 Serilog / NLog 等成熟方案 + 异步 sink;
  5. ThreadPool.SetMinThreads 必须设:K8s 小容器(2-4 vCPU)默认值太小,容易饥饿;
  6. 避免在 ThreadPool 线程做阻塞 IO:文件、串口、数据库都要用异步 API;
  7. ValueTask 不是银弹:只在"经常同步完成"的 hot path 用,普通场景 Task 就够;
  8. 不要 await 一个已 Task.Run 的 Task:多一层调度,毫无意义;
  9. HttpClient 全局单例 + IHttpClientFactory:不要每次 new(SocketException 风险见 [[blog-handwrite-rotation]] 的 C# 文章);
  10. CancellationToken 一路传递:从 Controller 一直传到底层 IO,超时才能真正生效;
  11. 生产环境必须监控 ThreadPool:threadpool-queue-length 是核心 SLI,超 500 告警;
  12. 定期跑 dotnet-trace + PerfView:每月一次,主动发现潜在 async 反模式。

修复全过程的"反复试错"细节

我们 D4 - D5 不是一次就修对的,中间走过几个弯路,值得分享。第一次尝试只改了 Logger(以为它是元凶),P99 尖刺峰值从 4.2s 降到 350ms,频率从 18 分钟降到 2 小时一次,但没消失。说明还有别的因素。第二次我们加了 ThreadPool.SetMinThreads(50),期望"线程多了就不饥饿了",结果反而 P50 延迟从 12ms 升到 18ms——线程太多导致上下文切换开销。最终发现 100 是甜点(workerThreads=100,completionPortThreads=100)。这给了我们一个经验:性能调优不是"参数越大越好",必须有数据支撑,每改一个参数都要跑完整压测

另一个弯路是 ConfigureAwait(false)。我们一开始全代码库 sed 加了一遍,结果 CI 跑测试失败——某些测试依赖于 SynchronizationContext 在的(.NET Framework 项目)。最后改成按项目类型分级处理:库项目强制加,Web 项目可选,WPF 项目禁止加(WPF 需要 UI 线程同步)。这种"一刀切"思维在大型 codebase 里行不通,必须按场景细分。

引申一:为什么 ASP.NET Core 没 SynchronizationContext 反而坑更隐蔽

.NET Framework 的 ASP.NET 有 SynchronizationContext,所以 .Result 会立刻死锁,开发者一上来就知道有问题。ASP.NET Core 没有 SynchronizationContext,.Result 不会死锁,但会悄悄阻塞 ThreadPool 线程,污染整个服务的吞吐能力。结果就是:

  • 低 QPS 时一切正常,QA 测试发现不了问题;
  • 上线高 QPS 后,因为 ThreadPool 饥饿延迟尖刺;
  • 排查极难,因为不是死锁(没有明显症状),是渐进式饥饿。

"没有 SynchronizationContext"本来是 ASP.NET Core 的优势(避免了 Framework 的死锁陷阱),但反而让开发者放松了对 async 纪律的要求,因为"不死锁"被误读为"没问题"。这是 .NET 异步演化中的一个反讽。

引申一·五:.NET 异步反模式的"传染性"

这次事故有一个比技术细节更重要的认知:async 反模式具有"传染性"。一个团队里只要有一个开发者写 .Result,其他人 review 时如果不严格,就会有第二、第三个跟着写。我们这次事故的 codebase 里翻出来 23 处 .Result/.Wait(),其中 18 处是 2019-2022 年陆续加进来的。每一处单独看都"似乎不要紧"(因为局部测试不会有问题),但累积起来就是定时炸弹。

修复后我们在 CI 流水线里加了硬卡:任何 PR 引入新的 .Result / .Wait() 直接 fail,需要 tech lead 手动 override(并写明理由)。一年下来,override 次数为 0——证明这个规则一旦立起来,所有人都能找到 async 替代方案。反模式的根除靠的是"工程文化 + CI 卡口",不是靠人记得"不要这样写"

引申二:dotnet-counters / dotnet-trace 工具链的不可替代

这次排查能 5 天定位,完全靠 .NET 的诊断工具链。Java 有 JFR / Async Profiler,Go 有 pprof,.NET 的工具链可能很多人不熟,这里列出我们用到的 5 个核心工具:

工具 用途 开销
dotnet-counters 实时指标(GC、ThreadPool、Allocation) < 1%
dotnet-trace 采样型 trace,看调用栈分布 2-5%
dotnet-dump 抓 dump 文件,用 lldb/windbg 分析 抓时短暂卡顿
PerfView 分析 trace,可视化阻塞、CPU 时间 离线分析
dotnet-monitor K8s 内长期采集,sidecar 模式 2-3%

这套工具链在生产环境完全可用,不需要重启服务,采样开销低于 5%。我们后来在所有 .NET 服务的 K8s Pod 里都注入了 dotnet-monitor 作为 sidecar,任何时候都能远程抓 trace。不会用诊断工具的 .NET 开发者,生产事故定位时间会是会用的人的 5-10 倍

引申二·五:为什么"压测覆盖不到生产场景"

事故复盘里有一个尴尬的事实——这个网关在上线前做过 5 万 qps 压测,P99 一直稳定在 35ms,没出现尖刺。但生产里跑到 8 万 qps + 真实日志量 + 真实健康检查频次,就引发了周期性故障。这给我们一个深刻教训:"压测通过"不等于"生产稳定"。原因有几个层次:

  1. 压测的日志量通常被关掉或调低(为了不污染统计数据),掩盖了 Logger 的瓶颈;
  2. 压测时间通常 30-60 分钟,跑不出 18 分钟周期的 GCD 叠加现象;
  3. 压测流量是均匀分布的,生产是 burst + 健康检查 + 业务峰值叠加;
  4. 压测环境的 K8s 资源限制和生产可能不一致(我们压测时 Pod 内存 8GB,生产只有 4GB)。

修复完之后我们重做了压测标准:必须连续跑 8 小时以上、必须模拟真实日志量、必须模拟健康检查 burst、必须用生产同款 K8s 配置。新标准下,所有上线前的服务都要过这套压测,事故率明显下降。

引申三:为什么不切到 worker thread per request(类似 Java 虚拟线程)

事故复盘时有人问:.NET 能不能像 Java 21 虚拟线程那样,每个请求一个虚拟线程,彻底避免 ThreadPool 饥饿?答案是:.NET 没有虚拟线程,但 async/await + ThreadPool 的组合在调度模型上更优于虚拟线程。原因:

  • async/await 是协作式调度:开发者明确标 await 点,runtime 知道何时让出。虚拟线程是抢占式,需要 JVM hack 字节码;
  • .NET ThreadPool 是 work-stealing 队列:本地队列 + 全局队列,调度极快;
  • ValueTask + IValueTaskSource 几乎零分配:hot path 上完全无 GC 压力。

所以.NET 的方向不是"加虚拟线程",而是"让 async 更好用"。.NET 9 在 ThreadPool 注入算法上做了改进(更激进的扩容),.NET 10 据说会引入 "async profile-guided optimization"。不同 runtime 解决高并发的路径不同,理解各自的设计才能正确使用

引申三·五:.NET vs Java 在生产 async 编程上的差异

对比一下两个生态,有助于更深理解 async 设计。Java 21 虚拟线程的核心卖点是"代码可以写成同步,runtime 自动异步化"。.NET 的核心卖点是"async/await 让异步代码读起来像同步"。两者目标相似但路径不同:

维度 .NET async/await Java 21 虚拟线程
开发者标注 显式 async/await 关键字 无,普通方法
调度时机 await 点,可预测 遇到阻塞 syscall,JVM 决定
性能开销 状态机分配(可以 ValueTask 优化) 栈拷贝(可观开销)
pinning 问题 无(纯协作式) 有(synchronized / native 调用会 pin)
调试体验 调用栈不连续但可读 调用栈连续
反模式 .Result / Wait() synchronized / 受限的 native 调用

没有银弹,只有不同的取舍。.NET 选择"显式但灵活",Java 选择"隐式但有限"。我个人觉得.NET 的方案更可控,但需要更多开发者纪律。选什么 runtime 不重要,把它的 async 模型用对才重要

引申四:为什么 18 分钟这个周期值得追究

事故有"周期性"是极强的信号,18 分钟这个数字本身就值得 reverse engineer。我们后来发现 18 分钟的来源是日志文件 rotate 周期 + GC heap 增长曲线 + 业务流量峰谷的叠加:

  1. 每 15 分钟有一次健康检查 burst(导致日志量翻倍);
  2. 同时 Gen2 GC 大约 16-20 分钟一次;
  3. 这两个偶尔同时发生(GCD 接近 18 分钟),触发"日志同步 IO 阻塞 + GC 短暂 STW"叠加,把 ThreadPool 推过临界点。

这种"多因素 GCD 叠加"是分布式系统里最难排查的一类问题——单一因素都在阈值内,但组合在一起越线。遇到周期性故障,要查的不只是"什么周期事件",而是"哪几个周期事件可能 GCD 对齐"。这种思路能解决 80% 的"间歇性故障"。

引申五:为什么"日志降级"是隐形的工程纪律

这次最大的元凶其实是 Logger。这给我们一个反思:日志在生产里的"重要性"和它能引发的"破坏性"完全不成比例。一个写得糟糕的 Logger 可以毁掉整个服务,一个写得好的 Logger 也只是"不引人注意"。这导致团队对 Logger 的投入永远不足。

我们后来立了 4 条日志纪律:

  1. 所有 Logger 必须异步:同步 Logger 直接不上线;
  2. Logger 队列满必须丢弃,不能阻塞:日志丢一些比业务卡死好;
  3. 结构化日志 + 采样:Info 级别采样 10%,Error 级别 100%;
  4. 日志大盘必须监控"日志服务自身延迟":Logger 慢了要立刻知道。

这套纪律推广到全公司后,几乎消灭了所有由 Logger 引发的延迟尖刺事故。日志是基础设施,基础设施的纪律比业务代码的纪律更重要

引申五·五:从这次事故看"基础设施代码"的代际更新

那个引发事故的自定义同步 Logger,是 8 年前(.NET Framework 4.5 时代)写的。当时同步 IO 是默认选择,因为 .NET Framework 的异步生态不成熟。后来项目从 Framework 迁到 Core,业务代码全部异步化了,但这个 Logger 留下来没人动——"它跑得好好的,不要动"是常见心态。结果就是新业务代码的异步优势,被老基础设施代码的同步瓶颈完全抵消

修完之后我们做了一次全公司"基础设施代码体检",识别出 14 个类似的"老资产":自定义 Logger(2 处)、自研 Cache(3 处)、自研连接池(2 处)、HttpClient 封装(4 处)、序列化框架(3 处)。这些都是 5-10 年前的设计,在异步时代是反模式。定期"重写基础设施"是工程债务管理的一部分,不重写就会被它们拖死

引申六:为什么 .NET 的"诊断学习曲线"是值得投入的

很多团队选 .NET 是看中"开发效率",但忽略了诊断学习成本。.NET 的工具链很强,但门槛比 Java / Go 高一些(JVM 工具更普及,Go 自带 pprof)。这次事故让团队意识到:选了一个语言,就要把它的诊断工具链全部学会,这是工程纪律

我们后来给团队做了 3 次内训:

  • 第 1 次:dotnet-counters / dotnet-trace 基础;
  • 第 2 次:PerfView 实战分析(用真实事故的 trace 文件);
  • 第 3 次:K8s 里 dotnet-monitor sidecar 部署。

3 次内训之后,团队遇到类似问题的平均定位时间从这次的 5 天降到 8 小时。工具不是用来"事故发生时学的",是用来"事故发生前就熟练的"。培训的 ROI 远高于事故复盘的 ROI。

引申七:K8s 容器化对 .NET ThreadPool 的影响

很多 .NET 在 K8s 里跑出问题,本质是.NET 检测 CPU 数量的方式和容器 CPU limit 不匹配。.NET 早期版本(< 6.0)在容器里默认看到的是宿主机的 CPU 数,但实际上容器只有 limit 内的资源。这会导致:

  • ThreadPool min thread 数过小(2 vCPU 容器只有 2 个起始线程);
  • GC server 模式开太多 GC 线程(看到 16 核就开 16 个 GC 线程),反而拖慢;
  • HttpClient 连接池默认值过小。

.NET 8 已经修了大部分容器感知问题,但还是要显式配置:

// Program.cs - 容器化 .NET 8 标准配置
public static void ConfigureForContainer(this IHostBuilder builder)
{
    // 1. 显式 ThreadPool min(基于实际 CPU limit)
    var cpuLimit = GetCpuLimitFromCgroup();
    var minThreads = Math.Max(50, cpuLimit * 10);
    ThreadPool.SetMinThreads(workerThreads: minThreads,
        completionPortThreads: minThreads);

    // 2. GC 配置(放 csproj 或 runtimeconfig.json)
    // <ServerGarbageCollection>true</ServerGarbageCollection>
    // <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
    // <RetainVMGarbageCollection>true</RetainVMGarbageCollection>

    // 3. HttpClient 连接池
    builder.ConfigureServices(services => {
        services.AddHttpClient("default", c => {
            c.Timeout = TimeSpan.FromSeconds(10);
        }).ConfigurePrimaryHttpMessageHandler(() =>
            new SocketsHttpHandler {
                PooledConnectionLifetime = TimeSpan.FromMinutes(5),
                MaxConnectionsPerServer = 100
            });
    });
}

这套标准配置我们打包成了内部 NuGet 包,所有 .NET 项目 startup 时强制调用。容器化运行时配置是基础设施,不该让每个项目自己摸索

引申八:为什么 .NET 9/10 还需要 async 纪律

有人说"等 .NET 9 或更新版本,async 性能会更好,反模式就不要紧了"。这个判断错了一半。.NET 9 在 async 状态机分配、ThreadPool 调度上确实有改进,但"同步阻塞 async"这类反模式 runtime 没法修——你显式调 .Result 阻塞线程,runtime 没办法替你异步化。

.NET 团队公开过一个观点:"我们能优化 fast path,但解决不了 wrong path"。意思是 runtime 能让对的代码更快,但写错的代码 runtime 救不了。所以 async 纪律永远是开发者的责任,跨版本都成立。

总结

这次 .NET 8 网关服务的延迟尖刺事故,本质是3 个看似无关的 async 反模式叠加触发了 ThreadPool 饥饿:.Result 同步阻塞 + 缺 ConfigureAwait(false) 的上下文 capture 开销 + 自定义同步 Logger 的全局锁。任何一个单独存在都不会引发故障,但组合在一起 + 18 分钟 GCD 对齐,就把 P99 从 30ms 推到 4.2 秒。

更重要的认知是:ASP.NET Core 没有 SynchronizationContext 不代表 async 编程变简单了,只是把"立刻死锁"变成了"渐进式饥饿"。前者你立刻知道,后者要靠 dotnet-counters / dotnet-trace 才能发现。如果你团队里还有人在 ASP.NET Core 里用 .Result / Wait(),今天就该开始改——不要等到 8 万 qps 时再发现。

这次事故还有一个隐藏收获:让团队真正掌握了 dotnet-counters / dotnet-trace / PerfView 工具链。事故定位过程中,5 个开发者一起用 PerfView 分析 trace,从一开始的"不知道点哪个按钮",到 D5 已经能熟练分析 BLOCKED_TIME / GC time / Allocation。这种通过真实事故学到的技能,远比看培训视频深入。事故是最贵但最有效的学习方式,关键是要把学到的东西沉淀下来——我们后来把这次的 PerfView 分析过程录成了内训视频,新员工入职都要看。

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

DDD 限界上下文划分错误导致 6 个月返工 420 人日的复盘:从按团队 / 按数据库表两次翻车到 Event Storming + 业务能力的正解 + 11 条划分纪律

2026-5-26 23:48:07

技术教程

TypeScript 5.4 + Vite + Vue 3 项目 tsc 全量从 4 分 30 秒压到 38 秒的 2 周性能治理:1.4 亿 Type Instantiations 定位 + 3 大反模式 + 5 种修法 + 13 条 TS 性能纪律

2026-5-27 0:03:42

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