.NET HttpClient 单例 + K8s 滚动发布失联 4 小时复盘:SocketsHttpHandler 默认 PooledConnectionLifetime 灾难

.NET 8 计费网关上游服务做了次普通滚动发布,我们的 HttpClient 单例对着已经回收的旧 Pod IP 持续报错 18 分钟。SocketsHttpHandler 默认 PooledConnectionLifetime 是无限,DNS 永不刷新。完整复盘 + 6 步自查清单。

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 经常变"的环境里跑,可以按这个顺序自查:

  1. 翻 Startup / Program.cs,搜索 new HttpClientAddSingleton.*HttpClient。出现这两种模式,基本是有问题的。
  2. 如果用了 IHttpClientFactory,确认是否显式配置了 SocketsHttpHandler 的 PooledConnectionLifetime,或者明确知道默认 HandlerLifetime 2 分钟够用。
  3. 在测试环境对下游做一次 rolling update,然后过 5 分钟观察客户端的错误率——如果还有残留错误,基本就是连接池没刷新。
  4. 抓客户端 Pod 的 ss -tn,对比 remote IP 和当前下游 Pod IP 列表,确认没有"野连接"。
  5. 如果是高 QPS 服务,跑一次基准测试,确认 PooledConnectionLifetime=2min 不会带来明显的握手开销。
  6. 把 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()、未设置 PooledConnectionLifetimeSocketsHttpHandler。然后输出报告,按优先级修。我们用这个方法 2 天扫完 80+ 个服务,识别出 23 个隐患,3 周修完。这种"基础设施级"的代码 audit 是中大型团队该投入的事。

问:这个问题在生产环境中能不能"治标"——比如配 HPA 让滚动更平滑? 治不了根。HPA 让 Pod 数动态变化更频繁(scale up / down),反而让"客户端连接到不存在 Pod"的概率更高。任何"减少滚动频率"的方案都是延缓问题,不是解决。必须从客户端连接管理上修。

事故快 1 年后回头看,这次复盘除了修了一个具体 bug,更重要的是让组里建立了"框架默认值审视"的工程文化。每个季度的 tech debt 评估会上,都会重新过一遍各核心框架的默认行为,看新版本有没有变化、看我们的用法是不是仍然合适。这种"持续 audit"的习惯让后来的好几个潜在问题在生产前就被识别——这是工程团队成熟度的可量化标志,也是从被动救火向主动治理的真实跨越,值得每个工程团队认真投入培养这种持续监督的能力,而不是依赖个别经验丰富者的临场反应来度过每次危机。

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

Java 服务每周 OOM Metaspace 续命 22 个月的复盘:CGLIB ClassLoader 锁死 + 5 种修法

2026-5-26 11:06:30

技术教程

TypeScript 5.5 升级把 VSCode 智能提示卡到 8 秒:类型实例化爆炸 6 天复盘

2026-5-26 11:16:30

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