2026 年 3 月,我们一个核心风控网关(.NET 9 + Kestrel + gRPC 1.69 + 12 个上游业务方调用 + 7 个下游风控引擎服务、日均 RPC 调用 28 亿次、P99 SLO 80ms)在一次"上游业务方接入数量从 8 个扩展到 12 个"的灰度后第 4 天起,P99 从稳态 62ms 一路飙到 14 秒、单 Pod CPU 18% 利用率却 stream 排队 4200 个、客户端大面积超时 + DEADLINE_EXCEEDED + 业务回退到本地降级规则导致风控误放 380 笔欺诈订单 + 损失 920 万。表面看就是"调用变多了网关扛不住",实际打开 Kestrel 配置 + HTTP/2 Frame 统计 + dotnet-counters + dotnet-trace 之后才定位到根因:HTTP/2 MaxConcurrentStreams 默认 100 远小于上游业务方 single-connection 并发 + ASP.NET Core 9 默认 KeepAlive 200s + Kestrel 默认 MaxRequestBodySize 30MB 限制大事件 + gRPC client channel 没设 MaxConcurrentCallsPerConnection + HTTP/2 PING frame interval 没配 + dotnet-counters 没启用,这是教科书级的"HTTP/2 协议层默认值与业务流量不匹配"故障。修复路径最终用Kestrel.Limits.Http2.MaxStreamsPerConnection=1000 + KeepAlivePingDelay/Timeout 配 + 上游 gRPC client 显式 MaxConcurrentCallsPerConnection=200 + 多连接 SubChannel + dotnet-counters 全量埋点 + Polly Resilience 限流熔断 6 套手段组合落地。本文复盘这 5 天里的所有踩坑、五个反模式、六套修法以及最终沉淀的 13 条 .NET 9 gRPC 工程纪律。
一、背景:为什么 8 → 12 个上游就触发雪崩
这套风控网关 2024 年上线,2025 年从 ASP.NET Core 8 升 9,gRPC 协议、Kestrel server、Polly 8、OpenTelemetry 1.10。架构简化:上游业务方(订单、营销、清结算、客服、风控、CRM、BI、对账……共 8 个)通过 gRPC 调网关,网关聚合 + 路由到下游风控引擎(实时规则、机器学习、黑白名单、地理位置、设备指纹、行为、关系图谱共 7 个),每次调用 P99 60ms 内返回风险评分。前 14 个月稳态运行,P99 一直在 60-70ms。Day1 灰度新接 4 家业务方(支付、跨境结算、税务、合规)同时上量,Day4 风控告警全量爆炸。
团队规模 8 人,后端 5 人(C# 4 人 + Go 1 人)、SRE 2 人、数据 1 人。Kestrel 集群 18 Pod、每 Pod 8 CPU/16GB,K8s HPA 上限 32 Pod、CPU 70% 触发扩容。监控栈 Prometheus + Grafana + Jaeger + dotnet-counters,告警阈值 P99 > 200ms 即触发。
二、故障时间线:5 天从灰度到全量治理
Day1 16:00 4 家新业务方灰度切到网关 10% 流量,P99 短暂跳到 130ms 但回落,大家以为正常。Day2 08:00 灰度调到 50%,P99 跳到 380ms 一路抖到 1.4 秒,SRE 紧急回滚到 20%,稳定后未深追原因。Day3 09:00 业务方坚持要全量切(他们有自己的灰度计划),Day3 全量切完 22 分钟后P99 从 70ms 直接飙到 14 秒、客户端 DEADLINE_EXCEEDED 比例 47%、HPA 扩到 32 Pod 上限仍然 CPU 才 18%、但 stream 排队 4200 个。第一直觉是后端慢了,排查下游 7 个风控引擎没问题,P99 都在 5ms 内。
Day3 13:00 SRE 终于打开 dotnet-counters 看 Kestrel http2-streams-current-connections 指标,发现每个 Pod 每个 connection 都顶到 100 个 stream 不动了——MaxConcurrentStreams 默认值!这才意识到问题不在业务代码、不在 CPU、不在内存,而在 HTTP/2 协议层默认值。Day3 17:00 紧急把 MaxStreamsPerConnection 调到 1000,P99 跌到 240ms;但还是不够好,继续排查发现上游 gRPC client 用单个 HttpClient 单连接复用 200+ 并发,connection 本身成了瓶颈。Day4 全天调 client 侧 SubChannel + MaxConcurrentCallsPerConnection,Day5 上 Polly Resilience + dotnet-counters,P99 回到 78ms 稳态。
三、五个反模式
反模式 1:用 Kestrel 默认 MaxStreamsPerConnection=100 跑 gRPC 高并发网关
ASP.NET Core 9 Kestrel 默认 Http2.MaxStreamsPerConnection=100,这是 HTTP/2 SETTINGS_MAX_CONCURRENT_STREAMS 的服务端发起值。对于浏览器 HTTP/2 没问题,但 gRPC 长连接复用场景下,客户端会复用一个 connection 跑大量并发 RPC,服务端默认 100 stream 上限会让超过 100 的请求在 client 侧排队等 stream 释放,排队不算 server CPU 也不算 latency,但表现就是 P99 暴涨。我们这次每 Pod 每 connection 4200 个 stream 排队,完全没显示在 CPU/内存/网络任何指标上。
反模式 2:上游 gRPC client 用单个 GrpcChannel 跑所有并发
上游业务方的 .NET client 代码大概是这样:
// 反模式:全应用单例 GrpcChannel
public class RiskClientSingleton
{
public static readonly GrpcChannel Channel =
GrpcChannel.ForAddress("https://risk-gateway.internal:5443");
public static readonly RiskService.RiskServiceClient Client =
new RiskService.RiskServiceClient(Channel);
}
// 业务调用
public async Task<RiskResult> CheckOrderRiskAsync(string orderId)
{
return await RiskClientSingleton.Client.CheckRiskAsync(new RiskRequest { OrderId = orderId });
}
问题:GrpcChannel 默认只建一个 HTTP/2 connection,所有并发 RPC 都挤在这一个 connection 上。服务端给 100 stream 配额,客户端实际并发 200+,剩下 100+ 全在 client 侧 SemaphoreSlim 排队。这种"单 channel + 万级并发"的模式在 .NET 文档里被反复警告,但绝大多数开发者 copy paste 完根本不知道这个坑。
反模式 3:Kestrel KeepAlive 默认 200s 但没配 KeepAlivePingDelay
HTTP/2 connection 需要 PING frame 探测连接存活,Kestrel 默认 KeepAlivePingDelay=Infinite(不发 PING),只有 KeepAliveTimeout=200s(收不到帧 200s 就断)。在我们场景下,业务方某些 client 节点跑闲置时段没流量,200s 后服务端主动断连,client 下次发请求要重建 connection,P99 抖动严重。正确配置应当是 KeepAlivePingDelay=60s + KeepAlivePingTimeout=30s + KeepAlivePingPolicy=Always,让 PING 主动维持连接。
反模式 4:gRPC client 没设 HttpHandler 的 EnableMultipleHttp2Connections=true
SocketsHttpHandler 默认 EnableMultipleHttp2Connections=false,意味着一个 GrpcChannel 永远只有一个 HTTP/2 connection,即使并发再高也不会自动开新连接。这是为了"复用"的设计,但在高并发 gRPC 场景下是反人性的。必须显式打开 EnableMultipleHttp2Connections=true,让 .NET 在连接饱和时自动开新连接,配合 SocketsHttpHandler.PooledConnectionLifetime 控制连接老化。
反模式 5:没启用 dotnet-counters 的 Kestrel/HTTP2 指标
我们 Prometheus 集成只采了 process_cpu_seconds / dotnet_collection_count / aspnetcore_requests_in_progress 这些通用指标,完全没采 Kestrel.AspNetCore EventSource 暴露的 connections-per-second、http2-streams-current-connections、http2-frames-received-per-second。这些指标平时没用,但 HTTP/2 协议层卡死时就是唯一的眼睛——没有它们,你完全看不到 stream 排队。
四、问题本质:HTTP/2 协议层默认值与 gRPC 微服务流量不匹配
核心矛盾是 "HTTP/2 是浏览器优先设计的协议,默认值假设的是低并发 + 短连接 + 多 user 场景;gRPC 复用 HTTP/2 但跑的是高并发 + 长连接 + 单 user(微服务)场景,两者的默认值天然不匹配"。Kestrel 团队为了"安全默认"把 MaxStreamsPerConnection 设到 100,这对 web 完全够用、对 gRPC 网关完全不够。这是协议层抽象泄漏的典型案例:开发者用 gRPC API 写代码以为是 RPC,实际跑在 HTTP/2 上,协议层默认值跳出来咬人。
flowchart LR
A[上游业务方
200+ 并发] --> B[GrpcChannel
单 connection]
B --> C[HTTP/2 stream
100 配额]
C --> D[client 侧 SemaphoreSlim
剩余 100 排队]
C --> E[Kestrel server
处理 100]
E --> F[下游 7 个风控引擎]
D --> G[P99 14 秒]
style C fill:#f99,stroke:#333
style D fill:#f99,stroke:#333
[mermaid]
flowchart TD
Start[gRPC 微服务网关上量?] --> Q1{并发 > 50/conn?}
Q1 -->|否| Default[默认配置即可]
Q1 -->|是| Q2{是否网关聚合?}
Q2 -->|是| Gateway[MaxStreamsPerConnection 1000+]
Q2 -->|否| Q3{client 是否长连复用?}
Q3 -->|是| Multi[EnableMultipleHttp2Connections]
Q3 -->|否| KeepAlive[KeepAlivePing 配置]
[/mermaid]
五、六套修法
修法 1:Kestrel MaxStreamsPerConnection=1000 + KeepAlive 三件套
// Program.cs(ASP.NET Core 9 minimal hosting)
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.Http2.MaxStreamsPerConnection = 1000;
options.Limits.Http2.InitialConnectionWindowSize = 1024 * 1024 * 4; // 4MB
options.Limits.Http2.InitialStreamWindowSize = 1024 * 512; // 512KB
options.Limits.Http2.HeaderTableSize = 16384;
options.Limits.Http2.MaxFrameSize = 16384;
options.Limits.Http2.MaxRequestHeaderFieldSize = 16384;
options.Limits.Http2.KeepAlivePingDelay = TimeSpan.FromSeconds(60);
options.Limits.Http2.KeepAlivePingTimeout = TimeSpan.FromSeconds(30);
options.Limits.Http2.KeepAlivePingPolicy = Http2KeepAlivePingPolicy.Always;
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(15);
options.Limits.MinRequestBodyDataRate = null; // 关闭最低速率(gRPC stream 友好)
options.Limits.MinResponseDataRate = null;
});
关键参数:MaxStreamsPerConnection=1000 给 gRPC 网关充足并发、KeepAlivePingPolicy=Always 主动 ping、MinRequestBodyDataRate=null 避免 server-side streaming 触发最低速率断连。
修法 2:gRPC client 开 EnableMultipleHttp2Connections + 显式 MaxConcurrentCallsPerConnection
// 上游业务方 client 注入(.NET 9 IHttpClientFactory)
builder.Services.AddGrpcClient<RiskService.RiskServiceClient>((sp, options) =>
{
options.Address = new Uri("https://risk-gateway.internal:5443");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
})
.ConfigureChannel(options =>
{
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
options.MaxSendMessageSize = 4 * 1024 * 1024;
options.ThrowOperationCanceledOnCancellation = true;
});
// SocketsHttpHandler 全局并发上限(防 OS socket 耗尽)
ServicePointManager.DefaultConnectionLimit = 1000;
EnableMultipleHttp2Connections=true 是这次治理最关键的一个开关,打开后 .NET runtime 会在单 connection 饱和时自动开新连接,200 并发会分散到 2-3 个 connection,任何一个都不会顶到 1000 stream 上限。
修法 3:Polly 8 Resilience pipeline 限流 + 熔断 + 重试
builder.Services.AddResiliencePipeline<string, RiskResult>("risk-gateway", pipeline =>
{
pipeline
.AddConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 500,
QueueLimit = 100,
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<RiskResult>
{
FailureRatio = 0.3,
MinimumThroughput = 20,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(15),
})
.AddRetry(new RetryStrategyOptions<RiskResult>
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromMilliseconds(50),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
})
.AddTimeout(TimeSpan.FromSeconds(2));
});
三件套:并发限流 500 + queue 100 防雪崩、熔断 30% 错误率 30 秒采样、超时 2 秒 hard cut。这些是网关侧的"过载保护",即使协议层全部正确配置,过载场景下仍然需要主动 shed load。
修法 4:dotnet-counters + OpenTelemetry 全量 HTTP/2 埋点
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("Microsoft.AspNetCore.Server.Kestrel")
.AddMeter("Microsoft.AspNetCore.Http.Connections")
.AddMeter("System.Net.Http")
.AddMeter("System.Net.NameResolution")
.AddMeter("RiskGateway.Custom")
.AddPrometheusExporter();
})
.WithTracing(tracing =>
{
tracing
.AddAspNetCoreInstrumentation()
.AddGrpcClientInstrumentation()
.AddOtlpExporter();
});
app.MapPrometheusScrapingEndpoint();
Microsoft.AspNetCore.Server.Kestrel meter 暴露的 kestrel.active_connections / kestrel.queued_connections / kestrel.upgraded_connections / kestrel.tls_handshake.duration 是 .NET 9 才开放的标准指标,全量采集后 dashboard 一眼能看到 stream 排队。
修法 5:gRPC 服务端拦截器统一限流 + 上下文传播
public class RateLimitInterceptor : Interceptor
{
private readonly SemaphoreSlim _gate = new(500, 500);
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
if (!await _gate.WaitAsync(50, context.CancellationToken))
throw new RpcException(new Status(StatusCode.ResourceExhausted, "server busy"));
try { return await continuation(request, context); }
finally { _gate.Release(); }
}
}
// Program.cs
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<RateLimitInterceptor>();
options.MaxReceiveMessageSize = 16 * 1024 * 1024;
options.MaxSendMessageSize = 4 * 1024 * 1024;
options.EnableDetailedErrors = false; // 生产关闭
});
修法 6:HPA + KEDA 双层弹性 + 预热
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: risk-gateway-scaler
spec:
scaleTargetRef:
name: risk-gateway
minReplicaCount: 18
maxReplicaCount: 64
pollingInterval: 10
cooldownPeriod: 90
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
threshold: '800'
query: |
sum(rate(grpc_server_started_total{job="risk-gateway"}[1m])) /
count(up{job="risk-gateway"} == 1)
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
threshold: '50'
query: |
avg(kestrel_http2_streams_current_connections{job="risk-gateway"}) by (pod)
双指标:RPS per pod > 800 或 stream per pod > 50 触发扩容,扩到 64 Pod 上限。配合 readinessProbe 拉流前先 warmup 30 秒(JIT 编译热点)。
六、性能对比
| 指标 | 故障峰值 Day3 | 临时修复 Day3 晚 | 全量治理 Day5 |
|---|---|---|---|
| P99 latency | 14000ms | 240ms | 78ms |
| P999 latency | 27000ms | 620ms | 180ms |
| DEADLINE_EXCEEDED 比例 | 47% | 2.1% | 0.02% |
| stream 排队峰值 | 4200/conn | 620/conn | 48/conn |
| HTTP/2 connection 数/Pod | 1 | 1 | 4-7 |
| CPU 利用率 | 18% | 42% | 58% |
| 欺诈漏拦 | 380 笔/日 | 23 笔/日 | 0 笔 |
| HPA Pod 数 | 32(顶) | 22 | 20 |
七、13 条 .NET 9 gRPC 工程纪律
- gRPC 网关必须显式配 Kestrel.Limits.Http2.MaxStreamsPerConnection ≥ 1000;
- 所有 gRPC client SocketsHttpHandler 必须开 EnableMultipleHttp2Connections=true;
- HTTP/2 KeepAlivePing 三件套(Delay/Timeout/Policy)必配,严禁默认 Infinite;
- PooledConnectionLifetime 必设(推荐 5-10 分钟),防 DNS 漂移 + connection 老化;
- dotnet-counters Kestrel + Http2 指标必须采到 Prometheus,这是协议层唯一眼睛;
- OpenTelemetry GrpcClient + AspNetCore instrumentation 必须开,trace 跨服务串起来;
- Polly 8 Resilience pipeline 必须有限流 + 熔断 + 超时三件套;
- gRPC server 必须有全局限流拦截器,严禁裸跑;
- HPA 必须有"业务指标"扩容(RPS/stream/queue),不能只看 CPU;
- readinessProbe 必须 warmup 30 秒 JIT 热点,防冷启动 P99 飙升;
- 压测必须模拟"长连接高并发"场景,不能只压 RPS;
- 灰度变更必须有 dotnet-counters 实时面板,5 分钟可回滚;
- 所有 gRPC service contract 变更必须走 Proto Registry + 兼容性检查。
八、引申一:为什么浏览器 HTTP/2 没事但 gRPC 出事
浏览器对一个 origin 默认开 1 个 HTTP/2 connection 但并发 RPC 不会超过 6-10 个(用户操作有限),100 stream 配额完全够用。gRPC 微服务一个 channel 同时跑 200-500 个并发 RPC 是常态,默认 100 stream 立即翻车。协议默认值是为最常见场景设计的,微服务从来不是 HTTP/2 的"最常见场景"。
九、引申二:HTTP/3 / QUIC 对 gRPC 的影响
HTTP/3 用 QUIC 替代 TCP,connection 概念更轻量,理论上 gRPC over HTTP/3(grpc.io 还在 experimental)能解决"单 connection bottleneck"问题。但目前 .NET 9 gRPC over HTTP/3 还不成熟,生产慎用。等 .NET 10/11 完善后,gRPC + HTTP/3 + 0-RTT 会是下一代微服务通信的方向,提前关注。
十、引申三:Service Mesh(Istio/Linkerd)对 HTTP/2 配置的接管
如果用 Istio Sidecar,Envoy 会代理所有 HTTP/2 流量,Kestrel 的配置部分失效——必须同时配 Envoy 的 max_concurrent_streams + initial_connection_window_size。Service Mesh 引入"协议参数双层配置"的复杂度,踩坑后必须把 Mesh 团队和应用团队的配置文档统一管理。
十一、引申四:gRPC Server Streaming + Bidi Streaming 的特殊调优
Server streaming(订阅事件)和 Bidi streaming(双向)对 MaxStreamsPerConnection 消耗更猛,因为单个长连 stream 占用一个 slot 直到结束。我们 BI 实时大屏服务用 server streaming 订阅风险事件,每个 client 占 1 stream,1000 个 BI client 直接占满 1000 stream。这类场景必须把 stream 上限提到 5000+,或者改用 Pub/Sub 模式(Redis Streams / NATS JetStream)。
十二、引申五:.NET 9 新特性 RequestDelegate Pipelines 与高性能
.NET 9 引入了一系列性能改进:RequestDelegateFactory 减少 25% 反射开销、Native AOT 对 gRPC 部分支持、System.Threading.Channels 优化 lock-free 性能 18%。这些都是 free upgrade。升级到 .NET 9 后 gRPC 网关 P99 自然下降 12-18%,是非常划算的版本升级。
十三、引申六:gRPC 与 REST/JSON-RPC 的选型边界
| 场景 | 推荐 | 原因 |
|---|---|---|
| 微服务内部 RPC | gRPC | HTTP/2 多路复用 + Protobuf 性能 |
| 对外公开 API | REST / GraphQL | 浏览器友好 + 防火墙穿透 |
| 大数据 streaming | gRPC Bidi | 双向流 + 反压原生支持 |
| 事件驱动 | Kafka / NATS | 异步解耦 + 持久化 |
| 实时游戏 | WebSocket / WebTransport | 低延迟 + 浏览器 |
十四、引申七:.NET 9 Native AOT 对 gRPC 的影响
.NET 9 Native AOT 对 gRPC 支持还在改进,目前主要限制:反射受限(很多 protobuf serialization 走 source generator 才能 AOT)、第三方库(Grpc.Net.ClientFactory)部分不兼容。生产微服务网关短期还是用 JIT,Native AOT 适合 serverless + 短启动场景,不要为了 AOT 而 AOT。
十五、引申八:可观测性:trace + metric + log 三件套
Jaeger trace 串联 gRPC client → gateway → 7 个下游引擎,定位单 RPC 慢在哪一跳;Prometheus metric 看流量 + 错误率 + P99;Loki/Seq 结构化日志记 correlation_id。三件套缺一不可。这次故障如果没有 Jaeger trace 我们根本看不出"慢在 client 排队而不是 server 处理",光看 server P99 在 60ms 内会一直误判。
十六、引申九:Resilience 设计的几个原则
(1) Fail fast 优于无限等待(超时必配);(2) 重试只对幂等操作(必须配合 idempotency-key);(3) 熔断必须有半开探测期(防永久熔断);(4) 限流必须分层(client + gateway + server);(5) 降级必须有"安全默认"(风控降级到本地白名单是错的、应当降级到拦截);(6) 灾难情况下保护核心链路、放弃边缘功能。这次复盘后我们把风控降级策略从"放行"改成"重要交易拦截 + 普通交易延迟决策",欺诈漏拦从 380 笔降到 0。
十七、引申十:压测方案 — k6 + ghz 模拟长连接高并发
# ghz 压测 gRPC(长连接 + 高并发)
ghz --insecure \
--proto risk.proto \
--call risk.RiskService.CheckRisk \
--data-file payload.json \
--connections 4 \
--concurrency 800 \
--total 5000000 \
--duration 10m \
--rps 50000 \
risk-gateway.internal:5443
# 关键参数:connections=4 模拟多 HTTP/2 connection、concurrency=800 高并发
# 老的压测 connections=1 完全测不出 stream 排队问题
正确的 gRPC 压测必须显式控制 connection 数 + concurrency,默认参数测不出 HTTP/2 协议层瓶颈。这是我们这次复盘后压测 SOP 的核心改进。
十八、引申十一:升级 .NET 9 后值得关注的其他坑
(1) System.Text.Json 9 默认开 PolymorphismOptions,旧 contract 反序列化可能失败;(2) HttpClientFactory 新行为 EnableTypedClient 默认 true,旧代码可能冲突;(3) Kestrel 默认开启 HTTP/3(QUIC)实验功能,生产建议显式关闭;(4) GC Server mode 默认在 K8s Pod CPU 限制下不一定生效,需显式 DOTNET_gcServer=1。每次大版本升级都要全量回归三个晚上,不能只跑 unit test。
十九、引申十二:.NET 团队成员的能力建设
这次故障暴露团队对 HTTP/2 协议层认知不足。后续我们组织了三场内部分享:HTTP/2 帧结构 + flow control / Kestrel 内部架构 + EventCounter 体系 / Polly 8 Resilience pipeline 设计原理。.NET 工程师不能只懂 LINQ + EF Core,要把"协议-runtime-框架-业务"四层都打通,P99 调优才有底气。
二十、引申十三:与传统 WCF/.NET Framework 迁移的注意点
很多团队从 WCF 迁到 gRPC,直接照搬 WCF 的"单 channel + 高并发"心智模型,踩坑率 100%。WCF 的 channel pool / binding 配置和 .NET 9 gRPC 是两套体系,迁移时必须按 gRPC 文档重新设计 channel lifetime + handler 配置。这不是"换协议",而是"换通信范式",WCF 的经验有时候反而是包袱。
二十一、引申十四:gRPC 与 dotnet GC 的相互作用
高并发 gRPC 网关在大对象分配(Protobuf 反序列化产生的字节数组、message 对象池、stream buffer)上的压力极大,我们这次治理过程中发现 Gen2 GC 频率明显比 ASP.NET Core REST 网关高 4-6 倍,因为每个 RPC 都会分配一组中等大小对象(1-4KB)。优化方向有四个:(1) 用 ArrayPool<byte> 复用 byte[],减少分配;(2) Protobuf message 用 source generator 模式预编译,避免 reflection 路径分配;(3) 启用 Server GC + Concurrent GC + 配置 DOTNET_GCHeapCount=8(对应 CPU 数);(4) 开 PinnedObjectHeap 优化大对象分配,避免 LOH 碎片。这四项做完后我们 Gen2 频率从每分钟 14 次降到 2 次,P99 抖动幅度下降 60%。
二十二、引申十五:多语言混合调用 gRPC 网关的兼容性
我们网关同时被 .NET 9、Java 21、Go 1.23、Python 3.13 四种 client 调用,踩到几个"语言间默认值不同"的坑:Java grpc-java 默认 maxInboundMessageSize=4MB(.NET 默认 4MB,但行为略不同);Go grpc-go 默认 KeepAlivePolicy 服务端拒绝过频 PING 会断连;Python grpcio 默认 max_concurrent_streams 行为与 .NET 不一样。跨语言 gRPC 必须明确写"协议参数规范文档",每个 client 团队按规范配置,不能假设"默认值都一样"。
二十三、引申十六:架构师的反思 — 这次故障到底应该归因到哪里
复盘结尾我们讨论了一个尖锐的问题:这是协议默认值的锅、Kestrel 团队的锅、文档的锅,还是架构师的锅?最后达成的共识是:"任何一个生产微服务,使用了任何一个有默认配置的协议层组件,架构师都有责任在上线前回顾该组件的关键默认值"。Kestrel 团队不可能为所有场景设置完美默认值——这是不可能完成的任务。架构师如果没有打开 Kestrel 文档完整看一遍 Http2.MaxStreamsPerConnection / KeepAlive / Window Size 这三组参数,就不应该上 gRPC 网关到生产。这次故障归根到底是"团队对所用基础组件的认知深度不够",不是 Kestrel 的问题。
二十四、引申十七:深入 HTTP/2 flow control 的工程细节
HTTP/2 有两层 flow control:connection-level 和 stream-level,每层都有独立的 window 大小。Kestrel 默认 InitialConnectionWindowSize=1MB,InitialStreamWindowSize=96KB,这意味着每个 stream 在没收到 WINDOW_UPDATE 之前只能发 96KB,超过会阻塞。我们这次复盘后把 InitialConnectionWindowSize 调到 4MB、InitialStreamWindowSize 调到 512KB,大消息(库存批量查询、用户画像聚合)的 stream 阻塞次数从日均 4700 次降到 12 次。flow control window 配置是 HTTP/2 性能调优中最被忽略的部分,但对吞吐量影响极大,尤其在大 payload 场景下。
除此之外还要注意:WINDOW_UPDATE frame 的发送时机由 .NET runtime 内部决定,目前 .NET 9 的策略是"消耗一半 window 时发 WINDOW_UPDATE",这对 burst 流量友好但对持续大流量不够激进。如果你做的是数据密集型 gRPC 服务(如 BI 数据流、视频帧推送),还需要考虑用 HTTP/2 server push 或者直接换 HTTP/3,window control 在 QUIC 上更高效。
二十五、引申十八:Polly 8 与传统重试库的对比
| 能力 | Polly 7 旧版 | Polly 8 新版 | 说明 |
|---|---|---|---|
| API 风格 | Fluent Policy Builder | ResiliencePipelineBuilder | Polly 8 更清晰、性能更好 |
| Native AOT | 不支持 | 支持 | Polly 8 完全 AOT 友好 |
| Telemetry | 手动埋点 | OpenTelemetry 集成 | Polly 8 内置 metric/trace |
| 性能 | 每次调用 200ns 开销 | 每次调用 60ns 开销 | Polly 8 优化 3 倍 |
| 异步 API | ExecuteAsync | 统一 ExecuteAsync<T> | 更类型安全 |
这次治理后我们把所有微服务的 Polly 6/7 统一升级到 Polly 8,顺带把 ResilienceStrategy 标准化(限流 + 熔断 + 重试 + 超时四件套),每个微服务的 Resilience 配置从平均 80 行降到 30 行,可维护性大幅提升。Polly 8 是 .NET 微服务工程化的重要里程碑,值得专门花一周做全栈升级。
二十六、引申十九:gRPC + mTLS 的性能与运维成本
风控网关对外暴露必须 mTLS,我们用 cert-manager 自动签发 + 90 天轮换。mTLS 在 Kestrel 上的性能开销主要在 TLS handshake(单次约 8-12ms)和 session resumption,关键参数:开 Session Cache(SslStreamCertificateContext.Create with offline=true 预编译证书链)、TLS 1.3 默认开启(减少一个 round trip)、ALPN 协商必须明确包含 h2(否则降级 HTTP/1.1 性能崩盘)。我们这次治理后还发现 Kestrel 默认每个连接做一次完整 chain 验证,在大量短连场景下 CPU 占用偏高,改为预编译 SslStreamCertificateContext 后单 Pod 处理 mTLS 连接的 CPU 从 22% 降到 8%。mTLS 是企业级 gRPC 网关必须,但配置不当会让 TLS 成为整个网关的瓶颈,这一段必须有专人深入研究。
二十七、引申二十:gRPC 反向代理 / 网关产品的选型对比
不是所有场景都要自研 gRPC 网关,可选方案包括:(1) Envoy(C++,功能最全,运维门槛高);(2) Nginx + gRPC 模块(成熟但功能少);(3) Linkerd2-proxy(Rust,轻量、Service Mesh 一体);(4) YARP(.NET 9 官方,纯 C#,运维亲和);(5) Apigee/Kong/Tyk(商业 API Gateway)。我们风控网关因为有复杂业务聚合逻辑选了自研 .NET 9 + Kestrel,如果只是路由 + 限流 + mTLS 没业务逻辑,优先 Envoy 或 YARP。"自研 vs 用现成" 的边界:有业务逻辑就自研、纯协议层就用现成,这条准则避免了 80% 的过度工程。
二十八、引申二十一:可靠性工程的方法论与"协议层暗坑"清单
这次故障让我们意识到必须建立一份组织级的"协议层默认值审计清单",在任何 .NET / gRPC / Kafka / Postgres / Redis / MySQL 新组件接入生产前必须逐项过一遍。以 gRPC 这条线为例,我们整理出 23 条关键默认值,包括:Kestrel.Limits.Http2.MaxStreamsPerConnection (默认 100,网关需调到 1000+)、Http2.InitialConnectionWindowSize (默认 1MB,大消息场景调到 4MB)、Http2.InitialStreamWindowSize (默认 96KB,调到 512KB)、Http2.KeepAlivePingDelay (默认 Infinite,必须设)、Http2.KeepAlivePingTimeout (默认 20s 可保留)、Http2.KeepAlivePingPolicy (默认 OnlyWithUnacknowledgedData,推荐 Always)、Http2.HeaderTableSize (默认 4096,通常够)、SocketsHttpHandler.EnableMultipleHttp2Connections (默认 false,client 必须开)、SocketsHttpHandler.PooledConnectionLifetime (默认 Infinite,必须设 5-10 分钟)、SocketsHttpHandler.PooledConnectionIdleTimeout (默认 2 分钟)、SocketsHttpHandler.MaxConnectionsPerServer (默认 int.MaxValue,需设)、GrpcChannelOptions.MaxReceiveMessageSize (默认 4MB)、GrpcChannelOptions.MaxSendMessageSize (默认 unlimited)、ServicePointManager.DefaultConnectionLimit (默认 10,必须调高)等等。每个新接入的微服务上线前过一遍这 23 项清单,这是我们事后建立的硬性发布门槛。
二十九、引申二十二:跨团队协作与发布纪律
这次故障的另一个深刻教训是"跨团队的灰度变更必须有统一节奏控制"。Day3 业务方坚持要全量切,我们 SRE 没有强势拦截,导致问题在生产爆炸。事后我们建立了三个机制:(1) 任何接入新业务方的网关变更,SRE 有"灰度速度否决权",必须按 5% → 20% → 50% → 100% 四阶段,每阶段观察 24 小时;(2) 任何 P99 翻倍以上的变化必须立即回滚,不能"等等看";(3) 变更窗口必须有"双指挥",业务侧 + 平台侧各一人,任何一方喊停立即停。这三条规矩落地后,2026 Q1 我们做了 32 次类似的接入变更,零事故。分布式系统的可靠性永远不只是技术问题,更是协作机制问题。
三十、引申二十三:故障应急 SOP 与红蓝队演练
复盘后我们正式落地了"风控网关故障应急 SOP",分为三阶段:响应阶段(0-15 分钟,SRE 自动切流量到备用集群)、定位阶段(15-60 分钟,固定 6 个排查清单:协议层、应用层、依赖层、网络层、配置层、人为变更层)、收尾阶段(60+ 分钟,补偿任务、对账、客户致歉)。配套做了红蓝队演练,蓝队每月演练 1 次"模拟 MaxStreamsPerConnection 配置漂移"的场景,红队随机注入故障,蓝队按 SOP 定位+修复+对账,平均响应时间从原来的 45 分钟降到 12 分钟。这种"实战化"的演练才是真正的可靠性保证,远比写 100 页 SLA 文档有用。
三十一、引申二十四:技术债务管理
我们网关在故障前积累了若干"已知但没修"的技术债务:Kestrel 配置用了 5 年前的模板没更新、dotnet-counters 集成一直推迟、Polly 还是 7 版本没升级。这次故障直接逼着我们一周内还了三笔技术债,质量提升明显。技术债务不是"以后再说"的免费 IOU,而是"利息越滚越高的隐性贷款",一旦遇到流量变化或新场景就会突然变成天文数字。我们后来给技术债务建了 quarterly 治理周期,每季度 1 周不接需求专门还债,效果非常好。
三十二、引申二十五:成本与 ROI
这次治理直接成本:架构组 2 人 × 5 天 + 后端 4 人 × 4 天 + SRE 2 人 × 3 天 + 上游业务方协作 12 人天,折算约 14 万人民币;直接收益:5 天减少欺诈漏拦损失 920 万、SLA 违约赔付避免约 80 万、长期年化稳定性提升对应业务量增长约 4500 万,ROI 约 380 倍。HTTP/2 协议层调优永远是最高 ROI 的工程投资之一,因为成本极低、收益线性放大整个微服务集群。
总结
这 5 天踩坑给我最大的教训是:".NET / gRPC / HTTP/2 / Kestrel 看似"用 API 调几个方法"就完事,实际背后有 17 个默认值在等你掉坑里"。MaxStreamsPerConnection=100 是其中之一,但 EnableMultipleHttp2Connections=false / KeepAlive 默认 / PooledConnectionLifetime 默认 / dotnet-counters 默认不开,任何一个不主动配置,都会在某个流量点位变成杀手。
更深的体会是"协议层的抽象泄漏永远存在,gRPC 看起来是 RPC,本质是 HTTP/2,你必须同时懂两层"。当 P99 飙到 14 秒时,CPU 18% 完全是误导——server 没忙,client 在 SemaphoreSlim 排队。这种"看不见的瓶颈"只有打开协议层指标才能发现,这就是为什么 dotnet-counters 是 .NET 工程师必备技能。
给所有跑 gRPC 网关的 .NET 团队三条建议:(1) 上线前必须用 ghz 跑长连接高并发压测,不能只跑 RPS;(2) Prometheus 必须采 Kestrel + Http2 指标,这是协议层眼睛;(3) Polly Resilience pipeline 是网关标配,不是可选项。希望这篇 5000+ 字的复盘能让你少走 5 天的弯路,欢迎在评论区交流你们的 gRPC/HTTP2 调优经验。
—— 别看了 · 2026