2026 年 4 月 8 日下午 14:23,我正在和产品经理对一个需求,手机连续震了 6 下——是我们 .NET 平台组的告警群:"billing-gateway 调用 customer-service 的成功率从 99.95% 跌到 31%,持续 18 分钟,P99 飙到 8 秒"。我打开看板第一反应是"customer-service 又崩了",但点过去一看,被调方监控全绿——QPS 正常、CPU 30%、错误率 0.02%。两个服务都健康,中间这条调用链却在大面积失败。这不科学。
接下来 4 小时的排查把我们带进了一个 .NET 后端工程师都该背一下的故事:HttpClient 单例化的"经典最佳实践"在 K8s 滚动发布场景下,会因为 SocketsHttpHandler 默认永不刷新连接池而把请求一直打到已经被 SIGTERM 的旧 Pod IP 上,DNS 解析结果被无限缓存,直到所有旧连接被 RST 才会"被动"重连。这篇是完整复盘,涵盖 HttpClient 三代设计的演进、IHttpClientFactory 的真实价值、SocketsHttpHandler 的 5 个关键调优参数,以及我们落地的《.NET 出站请求规约》。
服务背景:这套 .NET 8 微服务架构
| 维度 | 数值 |
|---|---|
| 业务 | SaaS 计费网关,负责把订单事件按合同规则计费并出账 |
| 规模 | 日均出账 180 万笔,峰值 QPS 4200,P99 60ms |
| 技术栈 | .NET 8 + ASP.NET Core + gRPC + EF Core 8 + PostgreSQL 15 |
| 部署 | K8s 1.28,16 个 Pod,每 Pod 2 vCPU + 2GB |
| 调用关系 | billing-gateway → customer-service (HTTP/1.1 JSON) → ledger-service (gRPC) |
| 服务发现 | K8s ClusterIP Service + kube-proxy iptables 模式 |
| HttpClient 用法 | billing-gateway 在 Startup 把 HttpClient 注册成 Singleton,直接 inject 注入 |
这套架构 2023 年上线,2024 年从 .NET 6 升 .NET 8,跑了将近 3 年,从来没出过类似事故。事故的导火索是 customer-service 团队当天上午做了一次滚动发布——这种动作他们每周做 5 次,过去 200 多次都顺利,这一次完全相同的发布步骤,却让 billing-gateway 大面积报错。
事故时间线:从告警到根因落地的 4 小时
| 时刻 | 事件 |
|---|---|
| 14:00 | customer-service 团队启动滚动发布,从 v2.18.3 升到 v2.18.4(仅修了一个文案 bug) |
| 14:05 | customer-service 12 个 Pod 完成滚动,新 Pod 全部就绪,旧 Pod SIGTERM 后被回收 |
| 14:05 ~ 14:23 | billing-gateway 端逐渐冒出 SocketException: Connection refused 和 IOException: The response ended prematurely 错误,P99 缓慢爬升 |
| 14:23 | 错误率突破 30%,告警触发 |
| 14:30 | 我加入排查,初判是 customer-service 故障,联系对方团队 |
| 14:42 | customer-service 团队确认服务正常,QPS 只有平时一半,所有 Pod 健康 |
| 14:55 | 抓 billing-gateway Pod 的网络连接,发现大量 ESTABLISHED 连接的 remote IP 不在 customer-service 当前 Pod IP 列表里 |
| 15:10 | 定位:billing-gateway 还在用旧 Pod 的 IP,但旧 Pod 早已不存在 |
| 15:35 | 临时方案——把 billing-gateway 全部滚动重启,新 Pod 重建连接,服务恢复 |
| 15:50 | 开始根因分析,翻 HttpClient 注册代码 + SocketsHttpHandler 文档 |
| 16:30 | 定位到 PooledConnectionLifetime 默认是 Timeout.InfiniteTimeSpan(即永不过期),DNS 解析结果跟随连接一起"长寿" |
| 17:40 | 设计修复方案——迁移到 IHttpClientFactory + 显式配置 PooledConnectionLifetime = 2 分钟 |
| 次日 | 预发跑 4 次模拟 customer-service rolling update,验证无连接复用问题 |
| +2 天 | 分批灰度上线 billing-gateway,事后扫描公司另外 23 个 .NET 服务,发现 14 个有同样隐患 |
第一反应:被"两端都健康"骗了 20 分钟
最初的 20 分钟我们把方向走偏了。监控显示 billing-gateway 在大面积报错,customer-service 完全健康——所有 .NET 工程师第一反应都是:"客户端有 bug 没重试"、"网络抖动"、"会不会是 Istio sidecar 出问题"。我们甚至一度怀疑 customer-service 的 readinessProbe 没配好,Pod 还没真正 ready 就接流量。
真正把我们拉回正确方向的是一条 netstat -an 输出。15:00 我让现场同学进 billing-gateway 一个 Pod 抓网络快照:
kubectl exec -it billing-gateway-7d4fc-x2k9p -- ss -tnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 10.244.3.45:54218 10.244.5.12:8080 dotnet,pid=1
ESTAB 0 0 10.244.3.45:54220 10.244.5.12:8080 dotnet,pid=1
ESTAB 0 0 10.244.3.45:54221 10.244.5.13:8080 dotnet,pid=1
ESTAB 0 0 10.244.3.45:54223 10.244.5.18:8080 dotnet,pid=1
...(共 56 条 ESTABLISHED)
同时 kubectl 拉 customer-service 当前 Pod IP:
kubectl get pods -n default -l app=customer-service -o jsonpath='{.items[*].status.podIP}'
10.244.5.41 10.244.5.42 10.244.5.43 10.244.5.44 ... (12 个新 IP,均以 .41-.52 结尾)
对比一下,billing-gateway 还在持有的 56 条 ESTABLISHED 连接里,没有一个 remote IP 出现在 customer-service 的当前 Pod 列表里!所有连接都指向 .12 / .13 / .18 这些已经被回收的旧 Pod IP。这些 IP 仍然存在(K8s 网络栈还没回收 iptables 规则),但是没有任何 Pod 监听了——所以连接发出请求,要么收到 RST,要么直接 hang。
这一刻我们才意识到:不是 customer-service 有问题,是 billing-gateway 从来没去重新做 DNS 解析。
真凶 1:HttpClient 单例化的甜蜜陷阱
翻 billing-gateway 的 Startup 代码,HttpClient 的注册是这样写的:
// Program.cs - 经典"最佳实践"写法
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(sp =>
{
var client = new HttpClient
{
BaseAddress = new Uri("http://customer-service:8080"),
Timeout = TimeSpan.FromSeconds(5)
};
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
});
// 然后业务代码 ctor 注入 HttpClient
}
这种写法 2017 年微软官方就强烈推荐了。不要每次 new HttpClient(),否则会因为底层 SocketsHttpHandler 不及时释放,导致 TIME_WAIT 状态的 socket 堆积、端口耗尽——这个坑当年坑了无数 .NET 后端服务。"HttpClient 应该是单例的"几乎成了 .NET 圈子里的"政治正确"。但很少人意识到,把它做成单例同时还引入了另一个相反方向的坑:连接和 DNS 一起被冻在了第一次解析的状态。
HttpClient 内部持有一个 HttpMessageHandler(默认是 SocketsHttpHandler),Handler 内部维护连接池。当一个 HttpClient 是 Singleton、整个进程生命周期内都不会被释放,那么:
- HttpClient.SendAsync 第一次调用时,SocketsHttpHandler 会做一次 DNS 解析,拿到 IP 列表,挑一个 IP 建立 TCP 连接,放进连接池
- 之后所有后续请求,默认优先复用连接池里的现成连接,不会再做 DNS 解析
- 只有当连接池里的连接因为对方主动 close / RST / 网络异常被剔除,才会触发新连接;此时才会重新 DNS 解析
- 如果对方"优雅退出"(K8s SIGTERM 后 30 秒优雅期内 close 长连接)且新流量都走老连接,DNS 永远不会刷新
这个机制在 single-instance 后端是合理的——目标 IP 几乎不变,频繁重新解析是浪费。但在 K8s 这种"Pod IP 经常变"的环境下,它就是定时炸弹。
真凶 2:PooledConnectionLifetime 默认是 InfiniteTimeSpan
那么问题来了:K8s 滚动发布时,老 Pod 收到 SIGTERM,应用应该会主动 close 长连接的——为什么 billing-gateway 还能拿着这些连接发请求?
查了下 customer-service 的关闭逻辑,他们用的是 .NET 自带的 GenericHost,优雅关闭的实现是:
- 收到 SIGTERM 后,停止接受新请求
- 等当前处理中的请求完成,最多等 5 秒(HostOptions.ShutdownTimeout = 5s)
- 5 秒后强制退出,Kestrel 关闭所有 socket
这就有个微妙的时间窗:
| 时刻 | customer-service 老 Pod 状态 | billing-gateway 视角 |
|---|---|---|
| T0 | 收到 SIGTERM,停止接新请求 | 已有连接继续可用 |
| T0+5s | Kestrel 关闭所有 socket,Pod 退出 | 下次发请求会拿到 RST,触发重连 |
| T0+10s | K8s 回收 Pod,iptables 规则更新 | 客户端要做 DNS,大概率拿到新 Pod IP |
看起来 5 秒 + 重连机制应该没问题——但billing-gateway 这次的情况是,SIGTERM 后那 5 秒内,这条连接没有任何流量经过(被分到其他 Pod 处理了),客户端不知道服务端已经在关闭。5 秒后 Kestrel close socket,客户端不在那个瞬间发请求,也感知不到 RST(TCP 是状态机,close 信号要等下次 read/write 才能触发)。结果就是:billing-gateway 的连接池里还挂着这条"对方已经死了但本地以为还活着"的连接,直到下次复用时才发现。
更要命的是 SocketsHttpHandler.PooledConnectionLifetime 的默认值是 Timeout.InfiniteTimeSpan——也就是连接池里的连接永远不会主动过期。结合 HttpClient 单例,这条断掉的连接可以一直挂在池子里,等下次有人复用它发请求才被发现失败,然后重试——但重试时 SocketsHttpHandler 仍然不会做 DNS 解析,它会从连接池找另一条"看起来还活着"的旧连接,大概率仍然指向旧 IP。整个连接池都是僵尸,DNS 缓存永远不刷新。
这就是为什么 billing-gateway 在 customer-service 完成发布 18 分钟后,错误率还在大面积上涨——它在反复"尝试旧连接 → 失败 → 找另一条旧连接 → 仍然失败"的死循环里,永远不去问 K8s DNS "现在的 IP 是哪些"。
真凶 3:K8s ClusterIP 解析的语义
这里还有一层 K8s 网络的语义需要厘清。我们用的是默认的 ClusterIP Service,对应的 DNS 解析行为是:
| Service 类型 | DNS 返回 | 客户端连接的目标 |
|---|---|---|
| ClusterIP(默认) | 固定虚拟 IP(由 kube-proxy 维护) | 客户端连到 ClusterIP,kube-proxy iptables 把流量转发到真实 Pod |
| Headless Service(clusterIP: None) | 所有后端 Pod 的真实 IP 列表 | 客户端直接连 Pod IP |
| External / LoadBalancer | 外部 LB 的 IP | 外部 LB 做负载均衡 |
customer-service 是 ClusterIP——理论上 billing-gateway 总是连到同一个 ClusterIP,kube-proxy 在 iptables 层做转发。Pod 变化只更新 iptables 规则,客户端连接的 IP 不变。这种情况下,理应不存在"连接老 Pod"的问题——客户端连的就是 ClusterIP,内核会自动转发到当前活着的 Pod。
但 iptables 转发是在 TCP 连接建立时一次性决定的——SYN 包发到 ClusterIP,iptables DNAT 规则随机挑一个 Pod IP,后续这个连接的所有包都走那个 Pod。一旦连接建立,iptables 不会"中途"把已建立的连接转到别的 Pod。所以当老 Pod 被删除、iptables 规则更新后,已经建立的连接仍然指向那个不存在的 Pod IP(实际是 conntrack 表里的旧 NAT 规则)。这才是真正的根因。
简单说:
- DNS 解析返回 ClusterIP,这一步没问题
- 建立 TCP 连接时 kube-proxy DNAT 到某个 Pod IP,这一步也没问题
- 那个 Pod 后来被删除了,但 billing-gateway 的连接池里这条连接仍然在,内核 conntrack 表里的 NAT 规则也还在(默认 5 天)
- 下次发请求复用这条连接,包还是被 DNAT 到那个不存在的 Pod IP,要么 RST 要么 timeout
结论:在 K8s 里用长连接,连接池就必须有"主动老化"机制,不能依赖 DNS / iptables 来自动切流。
修法对比:5 种方案的取舍
| 方案 | 改动范围 | 风险 | 解决程度 | 选择 |
|---|---|---|---|---|
| ① 改用 IHttpClientFactory + 显式 PooledConnectionLifetime=2min | 中(Startup + 所有 HttpClient 注入点重构) | 中(需要回归测试) | 根本解决 | ✅ 主方案 |
| ② HttpClient 保持单例,只在 SocketsHttpHandler 上配 PooledConnectionLifetime | 小(只改 Startup) | 低 | 解决主要问题 | ✅ 紧急止血用 |
| ③ 把 ClusterIP Service 改成 Headless Service + 客户端做 DNS 轮询 | 大(运维 + 客户端 LB 库) | 高(影响所有调用方) | 解决但代价高 | ❌ 不值得 |
| ④ 引入 Envoy / Linkerd sidecar,客户端流量经 sidecar 代理 | 极大(全平台 mesh 改造) | 极高 | 解决,且统一治理 | ⏸ 列入长期 roadmap |
| ⑤ 业务代码加 Polly 重试 + Circuit Breaker | 小(包一层 Policy) | 低 | 缓解但不根治 | ✅ 配合 ② 一起做 |
当晚我们走的是 ② + ⑤:连夜推一个最小改动版本,把 PooledConnectionLifetime 设到 2 分钟,加 Polly 重试 3 次(指数退避)。这个版本 17:30 上预发,18:30 上生产。次日 ① 的完整重构跟上,把 HttpClient 注册全部迁到 IHttpClientFactory。
② 的最小改动代码:
// 保持单例,但显式构造 SocketsHttpHandler
builder.Services.AddSingleton(sp =>
{
var handler = new SocketsHttpHandler
{
// 关键: 连接池里的连接 2 分钟后强制关闭, 下次请求重新建立 + DNS 解析
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
// 空闲连接 90 秒后释放, 避免不必要的连接占用
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
// 限制每个 server 的并发连接数, 避免单点打爆下游
MaxConnectionsPerServer = 64,
// 自动解压响应, 减少业务代码
AutomaticDecompression = DecompressionMethods.All
};
return new HttpClient(handler)
{
BaseAddress = new Uri("http://customer-service:8080"),
Timeout = TimeSpan.FromSeconds(5)
};
});
① 的完整重构,迁移到 IHttpClientFactory:
// 注册具名 client (NamedClient)
builder.Services.AddHttpClient("customer-service", c =>
{
c.BaseAddress = new Uri("http://customer-service:8080");
c.Timeout = TimeSpan.FromSeconds(5);
c.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
MaxConnectionsPerServer = 64,
AutomaticDecompression = DecompressionMethods.All
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy() =>
HttpPolicyExtensions
.HandleTransientHttpError()
.Or<SocketException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt)));
static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy() =>
HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
// 业务代码消费方式
public class BillingService
{
private readonly IHttpClientFactory _factory;
public BillingService(IHttpClientFactory factory) => _factory = factory;
public async Task<Customer> GetCustomerAsync(long id)
{
var client = _factory.CreateClient("customer-service"); // 注意: CreateClient 是廉价操作, 每次新建无副作用
return await client.GetFromJsonAsync<Customer>($"/customers/{id}");
}
}
IHttpClientFactory 到底解决了什么
这里值得展开讲一下 IHttpClientFactory 的真实价值——因为我发现公司里很多 .NET 老同学还停留在"它不就是个 HttpClient 工厂吗"的认识上,真实的设计目的要深得多。
| 问题 | 裸 HttpClient 单例 | IHttpClientFactory |
|---|---|---|
| Socket 耗尽(频繁 new HttpClient) | 容易 | 解决(底层 Handler 复用) |
| DNS 缓存陷阱 | 严重 | 解决(默认 HandlerLifetime=2min,到期重建) |
| 每个调用方独立配置(超时/Header/重试) | 需要多个 Singleton | 具名 client 天然支持 |
| DI 集成 | 需要手动注册 | 原生支持 ITypedClient<T> |
| Polly 重试 / 熔断 | 需要手动包装 | AddPolicyHandler 一行接入 |
| 日志和 metrics | 需要手动加 DelegatingHandler | 开箱即用 |
核心点是:IHttpClientFactory 内部维护一个 Handler 池,每个 Handler 都有自己的生命周期(默认 2 分钟)。HttpClient 是廉价的壳子,真正贵的是 Handler——Factory 让你"频繁 new HttpClient"也没事,因为 Handler 是被池化和定期换新的。你拿到的 HttpClient 是"短生命周期的薄包装",Handler 才是长生命周期的连接池持有者。Handler 到期被废弃后,新的 Handler 替代上场,新的 DNS 解析自动跟上——这就完美解决了我们这次遇到的所有问题。
而且 IHttpClientFactory 提供的"Typed Client"模式让代码更优雅:
// 定义强类型客户端
public class CustomerServiceClient
{
private readonly HttpClient _http;
public CustomerServiceClient(HttpClient http) => _http = http;
public Task<Customer> GetAsync(long id) => _http.GetFromJsonAsync<Customer>($"/customers/{id}");
}
// 注册
builder.Services.AddHttpClient<CustomerServiceClient>(c =>
{
c.BaseAddress = new Uri("http://customer-service:8080");
c.Timeout = TimeSpan.FromSeconds(5);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) });
// 业务代码消费
public class BillingService
{
private readonly CustomerServiceClient _customer;
public BillingService(CustomerServiceClient customer) => _customer = customer;
// 用 _customer.GetAsync(id), 完全屏蔽 HttpClient 概念
}
SocketsHttpHandler 的 5 个关键参数
这次事故让我们把 SocketsHttpHandler 翻了个底朝天。下面这 5 个参数是 .NET 8 里影响连接行为最关键的,值得每个后端 .NET 工程师都记住:
| 参数 | 默认值 | 建议 | 说明 |
|---|---|---|---|
| PooledConnectionLifetime | Timeout.InfiniteTimeSpan | 2 ~ 5 分钟 | K8s 环境下必须设置,否则 DNS 缓存永不刷新 |
| PooledConnectionIdleTimeout | 1 分钟 | 30 秒 ~ 2 分钟 | 空闲连接保留时间,设太大浪费连接,设太小频繁重建 |
| MaxConnectionsPerServer | int.MaxValue(无限) | 32 ~ 128 | 防止把下游打爆,具体值根据下游容量定 |
| ConnectTimeout | Timeout.InfiniteTimeSpan | 3 ~ 5 秒 | 防止建立连接阶段就卡死,影响重试节奏 |
| KeepAlivePingDelay / KeepAlivePingTimeout | InfiniteTimeSpan / 20 秒 | 30s / 5s | 启用 HTTP/2 keepalive,主动探测死连接 |
特别提醒一下 PooledConnectionLifetime 不要设得太小。如果设到 30 秒,在高 QPS 场景下你会观察到连接频繁重建,握手 + TLS 握手的开销可能反过来让 P99 变差。我们做过对比测试,2 分钟是个很好的甜蜜点——既能保证 DNS 在分钟级别得到刷新,又不会引入显著的握手成本(2 分钟内的请求都复用同一条连接)。
验证:模拟 4 次 rolling update 后无残留
预发环境我们设计了一个相当严格的验证流程:
- billing-gateway 持续以 1000 QPS 发请求到 customer-service
- customer-service 每 8 分钟做一次 rolling update,持续 32 分钟做 4 次
- 每次 rolling 完成后,等 3 分钟,然后抓 billing-gateway Pod 的 ss -tn 输出,确认所有 ESTABLISHED 连接的 remote IP 都在当前 customer-service Pod IP 集合里
- 同时观察错误率曲线,验证 rolling 期间错误率峰值不超过 0.1%
实际跑下来:
| 指标 | 修复前(模拟) | 修复后 |
|---|---|---|
| rolling 后 3 分钟错误率峰值 | 32% | 0.04% |
| 残留旧 Pod IP 连接数 | 40+ 条 | 0 条 |
| P99 延迟变化(rolling 期间) | 80ms → 6.2s | 80ms → 110ms |
| 恢复到正常 P99 时间 | 未恢复(需要重启 client) | 2 分钟 |
那 0.04% 的错误率来自 rolling 瞬间正好在新旧 Pod 切换边界的请求——可以接受。修复后的 P99 110ms 比平时高一点,是因为新连接需要握手,但 2 分钟之内就回到 80ms 基线。完全可以接受。
顺手扫描:公司另外 23 个 .NET 服务的隐患
事后我们花了 3 天扫描公司所有 .NET 微服务的 HttpClient 配置,结果不太乐观:
| 分类 | 服务数 | 风险 |
|---|---|---|
| 用 IHttpClientFactory + 配了 PooledConnectionLifetime | 4 个 | 无风险 |
| 用 IHttpClientFactory 但没改默认值 | 5 个 | 低(HandlerLifetime 默认 2min,机制等效) |
| HttpClient 单例 + 没设 PooledConnectionLifetime | 11 个 | 高风险,等同事故前的 billing-gateway |
| 每次 new HttpClient(没复用) | 3 个 | 另一种坑——socket 耗尽风险 |
这意味着公司一半多的 .NET 服务都有"K8s rolling 后失联"的潜在风险。后续两周,平台组推动这 11 个高风险服务全部完成迁移,3 个不复用的服务也整改了。
立的《.NET 出站请求规约》
事后整理成 wiki,核心条款:
- 任何对外 HTTP 调用必须经 IHttpClientFactory,禁止裸 new HttpClient 或单例注册。例外:仅在测试代码、一次性脚本中允许。
- SocketsHttpHandler 的 PooledConnectionLifetime 必须显式配置,K8s 环境内调用统一设 2 分钟,跨外网调用统一设 5 分钟。
- MaxConnectionsPerServer 必须显式配置,默认 64,按下游容量调整,不允许 int.MaxValue。
- ConnectTimeout 必须设,默认 5 秒,防止建立连接卡死。
- 所有外部调用必须配 Polly:重试 3 次 + 熔断 5/30s + 超时 ≤ 总 timeout 的 1.5 倍。
- HTTP/2 长连接服务额外启用 KeepAlivePing,主动探测连接健康。
- 新服务上线前的网络压测必须包含"下游 rolling update"场景,4 次 rolling 后错误率不能超过 0.1%。
给读者的几条自查清单
如果你的 .NET 项目也在 K8s / Cloud Run / ECS 这类"对端 IP 经常变"的环境里跑,可以按这个顺序自查:
- 翻 Startup / Program.cs,搜索
new HttpClient和AddSingleton.*HttpClient。出现这两种模式,基本是有问题的。 - 如果用了 IHttpClientFactory,确认是否显式配置了 SocketsHttpHandler 的 PooledConnectionLifetime,或者明确知道默认 HandlerLifetime 2 分钟够用。
- 在测试环境对下游做一次 rolling update,然后过 5 分钟观察客户端的错误率——如果还有残留错误,基本就是连接池没刷新。
- 抓客户端 Pod 的
ss -tn,对比 remote IP 和当前下游 Pod IP 列表,确认没有"野连接"。 - 如果是高 QPS 服务,跑一次基准测试,确认 PooledConnectionLifetime=2min 不会带来明显的握手开销。
- 把 HttpClient 配置纳入服务模板(我们公司有内部 dotnet new 模板),所有新项目默认就是正确的。
这次事故让我对一句话有了新理解:"在云原生环境里,长连接不是优化,是约定。" 在容器还没普及的年代,服务端 IP 几个月不变,HttpClient 单例无脑用就行;到了 K8s + 微服务时代,IP 是分钟级别变化的,连接池必须有主动老化能力——不是"为了性能",而是为了"在 rolling 后还能找得到对方"。这是一个心智模型迁移的问题,微软在 .NET Core 2.1 引入 IHttpClientFactory 时就提示了这件事,但很多团队(包括我们)直到被事故教育才真正理解。
下次再有人在群里贴一段 builder.Services.AddSingleton<HttpClient> 的代码问"这样写对吗",别犹豫——告诉他改成 AddHttpClient,然后甩这篇文章过去。
问题本质:连接生命周期的"双视角失谐"
把整个事故的根本机制画成时序图就很清楚了——客户端和服务端对"这条连接是否还活着"持有不同的视角,而 K8s 滚动发布让两者的视角差异被放大:
给读者的具体行动清单
看完这篇的读者,如果想立刻在自己 .NET 项目里做一遍 audit,可以按下面 6 步走。每一步耗时几分钟到一小时:
第一步:grep 整个代码库,搜 AddSingleton<HttpClient> 和 new HttpClient。每一个匹配项都是潜在的雷点。统计一下数量,这个数字基本就是你团队的"DNS 缓存陷阱风险敞口"。
第二步:对每个 HttpClient 注册的地方,检查有没有显式 SocketsHttpHandler.PooledConnectionLifetime。没有的,意味着默认 InfiniteTimeSpan,在 K8s 环境下就是定时炸弹。改成 2 分钟是最小代价的修法。
第三步:在测试环境复现这个场景:启动一个 client + 一个 server,client 持续打 server,然后滚动重启 server。观察 client 端错误率曲线——如果有持续几分钟的错误,你就有问题。
第四步:检查所有"对外调用 SaaS API"的 HttpClient 配置。比如调 Stripe / Twilio / OpenAI 等,这些供应商可能也会做基础设施变更,你的 HttpClient 缓存 DNS 多久,就有多久看不到新 IP。
第五步:看你的 LB / WAF / API Gateway 是否有 idle timeout(参考 044 那篇文章里 gRPC keepalive 的对应思路)。HttpClient 的 keepalive 配置要和这些中间设备配合。
第六步:做一份 wiki,把"如何注册 HttpClient"做成公司标准模板,新人入职 onboarding 必读。这种"机制级"修补比"个案改"更可持续。
团队复盘后的工程文化讨论
这次事故复盘最有价值的不是技术修复,是组里对"我们对 .NET 框架的理解深度"的反思。过去几年我们享受着 .NET 强大的"开箱即用",写 AddSingleton<HttpClient> 觉得很自然,从来没去想过它背后的 SocketsHttpHandler 是怎么工作的、连接池怎么管理、DNS 怎么缓存。直到事故才被迫去读源码,发现这些细节决定了系统能不能在云原生环境下稳定运行。
这种"对框架的浅层使用"是个非常普遍的问题。.NET / Java / Spring / Django 这些大型框架都有几十年的演化,内部充满了"为了某种场景做的默认值"——而这些默认值在你的场景下可能完全错误。框架文档通常只讲"基本用法",对"反模式"、"陷阱"讲得很少。结果就是大家都按"基本用法"写,然后在生产上集体踩同一个坑。
组内的对策是:每个核心框架都派 1 个工程师做"深度学习者",ta 的任务是把这个框架"翻一遍"——读源码、写文档、列出所有"默认值的陷阱"。然后定期内部分享。这种"机制化的深度学习"比"出问题再查"主动得多,也是工程师团队成熟度的标志之一。
另一个意外的发现是"老员工的'我们一直这样做'是隐形毒瘤"。HttpClient 单例化是 7 年前的"最佳实践"教导,从那时起组里所有人都按这个模式写,从来没人质疑。但 7 年里 .NET 生态变了(IHttpClientFactory 2018 年出现)、部署形态变了(K8s 2020 年才主流)。"最佳实践"是有保质期的,需要定期 review 和更新。我们后来把所有"超过 3 年没更新"的工程实践全部标为"待 review",分给不同人重新评估,陆续淘汰了 17 条过时的规约。
对比:JVM 生态的对应教训
有意思的是,这个"客户端连接池缓存 DNS 不刷新"的问题不只 .NET 有,Java 生态有完全对应的版本。Java 默认的 InetAddress 缓存策略由系统属性 networkaddress.cache.ttl 控制,在某些 JVM 上默认是 -1(永久缓存),在 K8s 环境下同样灾难。这不是 .NET 的特殊问题,是"客户端长连接 + 容器化部署"通用的反模式——任何"假设 IP 不变"的客户端缓存策略,在云原生时代都要重新设计。
Go 标准库的 net/http 在这块表现稍好——它默认会做 DNS 重新解析,但需要正确配置 Transport 的 IdleConnTimeout(默认 90 秒,合理)。Python 的 requests / httpx 用法上不太容易踩这个坑(大家通常不会"一个 session 用一辈子")。但只要你在任何语言写"长连接客户端 + 重用 connection pool",都要把"DNS 何时刷新"列为关键设计决策——这是 2026 年云原生时代每个后端工程师必须有的常识。
这次事故 1 年后回头看,最大收获不是修了一个 bug,是团队建立了"框架默认值不可信"的工程怀疑精神。看到任何"自动 / 智能 / 推荐"的默认行为,先问一句"在我的具体场景下,这个默认是对的吗"。这种怀疑精神是高级工程师和初级工程师的核心差异——前者不被"框架的承诺"束缚,后者把"按教程做"当成正确性的证明。
读者最常问的几个问题
这篇内部分享给到其他组之后,被反复问到的几个问题集中答一下:
问:PooledConnectionLifetime 设 2 分钟会不会引起频繁建连开销? 实测过,在 QPS 高时影响微乎其微——单次 TCP 握手 + TLS 握手大约 30-80ms,2 分钟才发生一次,均摊到每个请求几乎为零。但对低 QPS 服务(几分钟才一个请求)确实可能让每个请求都赶上重建,这种场景可以放宽到 5-10 分钟,或者干脆禁用长连接(用短连接,反而简单)。
问:如果用 Service Mesh(Istio / Linkerd)是不是就不用管这个? 部分对。Service Mesh 的 sidecar(Envoy)会自己处理对下游的连接管理,业务代码的 HttpClient 实际是连到 sidecar(127.0.0.1)。但sidecar 自己怎么和下游建连仍然有 DNS 缓存问题,只是这个问题从"业务负责"变成"运维负责"。运维要确保 Envoy 配置了合理的 cluster DNS refresh。
问:gRPC 是不是也有这问题? 是的,本质是一样的——长连接 + 客户端缓存 DNS。但 gRPC 一般有 keepalive ping,能更早发现"对端死了"。具体见 044 那篇文章。
问:为什么 IHttpClientFactory 的 HandlerLifetime 默认是 2 分钟? 微软在设计时也是基于"DNS 在大多数场景应该在分钟级刷新"的判断。这是从云原生最佳实践反推的默认值,对 K8s 等环境基本够用。但仍然建议显式设置(不依赖默认),让代码意图明确,新人 review 时一看就懂。
问:我们公司有几百个 .NET 服务,怎么批量做 audit? 写一个 Roslyn 分析器扫所有项目,匹配几个模式:AddSingleton<HttpClient>、new HttpClient()、未设置 PooledConnectionLifetime 的 SocketsHttpHandler。然后输出报告,按优先级修。我们用这个方法 2 天扫完 80+ 个服务,识别出 23 个隐患,3 周修完。这种"基础设施级"的代码 audit 是中大型团队该投入的事。
问:这个问题在生产环境中能不能"治标"——比如配 HPA 让滚动更平滑? 治不了根。HPA 让 Pod 数动态变化更频繁(scale up / down),反而让"客户端连接到不存在 Pod"的概率更高。任何"减少滚动频率"的方案都是延缓问题,不是解决。必须从客户端连接管理上修。
事故快 1 年后回头看,这次复盘除了修了一个具体 bug,更重要的是让组里建立了"框架默认值审视"的工程文化。每个季度的 tech debt 评估会上,都会重新过一遍各核心框架的默认行为,看新版本有没有变化、看我们的用法是不是仍然合适。这种"持续 audit"的习惯让后来的好几个潜在问题在生产前就被识别——这是工程团队成熟度的可量化标志,也是从被动救火向主动治理的真实跨越,值得每个工程团队认真投入培养这种持续监督的能力,而不是依赖个别经验丰富者的临场反应来度过每次危机。
—— 别看了 · 2026