写在前面
.NET 里很少有哪个类型像 HttpClient 这样:API 简单到五分钟就能上手,却又被全社区的工程师集体用错了十年。它的每一个“坑”——socket 耗尽、DNS 不刷新、超时分不清——背后其实都是同一个设计张力的不同投影:
HTTP 连接是昂贵资源,必须复用;而复用又与世界的变化(DNS、故障、超时)天然冲突。
理解了这层张力,HttpClient 的“前世今生”就不再是一堆要背诵的最佳实践,而是一条清晰的演进脉络:从无脑 new、到静态单例、到 IHttpClientFactory、再到 .NET 8 的标准韧性管线,每一步都是在重新回答“怎么既复用、又保鲜”。
一、前世:using (var client = new HttpClient()) 为什么是反模式
先看那段几乎每个 .NET 新手都写过的代码:
| |
它看起来人畜无害,但在高并发下,服务会以 Unable to connect to the remote server / SocketException 集体阵亡。根因不是 HttpClient 有 bug,而是你误用了 TCP。
先把一次 TCP 连接的生命周期拉出来看:
| |
图里的报文标志:SYN = synchronize(同步,建立连接用)、ACK = acknowledgment(确认收到)、FIN = finish(结束,关闭连接用);
ESTABLISHED表示连接已建立。
关键:TIME_WAIT 只落在"主动关闭连接"的那一端。 HTTP 客户端通常是自己发请求、用完就关,于是客户端几乎总是那个主动关闭方——也就总是 TIME_WAIT 的承担方。
为什么主动关闭方要留在 TIME_WAIT? 具体时长由操作系统 TCP 实现决定,核心原因有两个:
- 让旧报文自然消亡:等待足够长时间,让旧连接中延迟的报文失效,避免污染将来复用同一四元组的新连接。
- 可靠收尾:最后那个 ACK 可能丢。若丢了,对端会重发 FIN;主动关闭方待在 TIME_WAIT 里才能再回一个 ACK,让对端正常关闭。若主动关闭方立刻消失,对端就只能超时强退。
代价就是:TIME_WAIT 期间,这个本地端口被占用,同一个四元组(源 IP:源端口 → 目的 IP:目的端口)不能立刻被新连接复用。
问题在于:反复创建并释放拥有独立 handler 的 HttpClient 会频繁关闭连接;高连接创建率会消耗有限且可配置的临时端口,并留下大量 TIME_WAIT,最终可能导致新连接失败。
这是 .NET 团队当年在官方文档里专门加警告的著名陷阱。Dispose 不但没帮你,反而加速了耗尽,因为它把本该复用的连接主动掐断了。
排障信号:
netstat -an | findstr TIME_WAIT(Windows)或ss -tan state time-wait(Linux)可以辅助观察TIME_WAIT;还要结合进程、目的端、端口范围和连接创建率,单看数量不能证明根因。
二、第一次救赎:静态单例与 DNS 陈旧
既然不能每次 new,那就复用——进程内共享一个静态 HttpClient:
| |
socket 耗尽立刻消失,因为连接被池化复用、不再频繁关闭。但这套方案运行一段时间后,会在某些场景下出现一种更隐蔽的故障:服务偶尔连不上,且恰好发生在对端故障转移之后。
这就是 DNS 陈旧(DNS staleness)。HttpClient 在创建新连接时解析 DNS,不会主动跟踪 DNS 记录的 TTL;现有连接可继续复用原 IP,直到连接被替换。对端故障转移或地址变化后,过长的连接生命周期因此可能延迟切换。
于是我们陷入了一个死结:
- 复用连接 → socket 不耗尽,但 DNS 不刷新;
- 每次新建 → DNS 是新的,但 socket 耗尽。
这是 HttpClient 演进的核心驱动力——如何在“连接复用”和“端点保鲜”之间找到平衡。后续所有方案(IHttpClientFactory、SocketsHttpHandler.PooledConnectionLifetime)本质上都在回答这一个问题。
三、原理:HttpClient 其实是个“门面”
要看懂后面的方案,先得看清 HttpClient 的分层。它本身几乎是空的——真正干活的是它持有的 HttpMessageHandler:
| |
这张图藏着三层关键设计:
HttpClient 是门面,不是实现。 你调用的
GetAsync只是把请求顺着一条 handler 链往下传,最后由“主处理器”(primary handler)真正发到网络上。这解释了为什么“换一个 handler”就能换一套行为——比如测试时换成 mock handler,连线都不用发。DelegatingHandler就是出站中间件。 它和 ASP.NET Core 的入站中间件是同一个思想,只不过方向相反——请求在这里被一层层加工:加日志、加重试、加 Authorization 头。横向关注点(cross-cutting concerns)从业务代码里剥离,干净地组合进管线。连接池不在 HttpClient 里,而在主处理器里。
HttpClient本身相对轻量,但只有共享或由工厂池化底层 handler,创建多个 client 才不会同时创建多个独立连接池;不能脱离 handler 生命周期说“可随意创建”。
四、SocketsHttpHandler:现代连接池的真正主角
从 .NET Core 2.1 起,所有平台的默认主处理器都是 SocketsHttpHandler(在此之前是基于各平台原生栈的 HttpClientHandler)。它用纯托管代码重写了传输层,带来几个关键能力:
- 连接池化:通过
PooledConnectionLifetime、PooledConnectionIdleTimeout、MaxConnectionsPerServer精细控制连接的生命周期与并发上限。 - HTTP/2 多路复用:一个 TCP 连接上跑多条并发流,连接不再是“一个请求一个”的粗粒度资源,连接池的数学模型因此改变。
- HTTP/3 (QUIC):现代 .NET 可在受支持的操作系统和服务端上使用;它避免 TCP 层跨流队头阻塞,但应用仍需正确协商版本,不能理解为所有请求都会自动升级。
- 低分配:基于
Span<T>/Memory<T>的实现,请求路径上的内存分配大幅下降。
PooledConnectionLifetime 限制池化连接的寿命。寿命到期且连接完成正在处理的请求后,它会被移出池;随后创建新连接时重新解析 DNS。默认值是 InfiniteTimeSpan,示例中的 15 分钟只是任意值,应按端点变化频率选择:
| |
注意 disposeHandler: false——HttpClient 被 dispose 时不去动底层 handler,避免又把连接池掐死。这是 .NET 官方文档里和 IHttpClientFactory 并列推荐的两种方案之一,特别适合库代码、非 DI 场景或对性能极其敏感的路径。
五、IHttpClientFactory:DI 时代的解法
IHttpClientFactory(.NET Core 2.1,Microsoft.Extensions.Http)用另一套思路解决同一个问题。它的核心招式一句话能说清:
创建瞬态的
HttpClient,但让它共享一个按固定生命周期轮换的 handler 池。
工厂把“昂贵的 handler”和“廉价的 HttpClient”彻底解耦:
HttpClient每次CreateClient都新建一个(瞬态),随便用、随便 dispose;- 但这些 HttpClient 背后挂的是池化的 handler,handler 的
HandlerLifetime默认 2 分钟,到期后旧 handler 退役、新 handler 上岗——退役与重建的时机正好让 DNS 自然刷新。
工厂创建的 HttpClient 被释放时不会销毁池化 handler,因此不会拆掉共享连接池。这纠正了“HttpClient 绝对不能 dispose”的误区;HttpRequestMessage 和 HttpResponseMessage 仍应由各自的所有者单独释放。
三种用法,复杂度递增:
| |
类型化 client 把“基础地址、默认头、handler 管线”全部封装在 DI 里,业务代码只管调用——这是 ASP.NET Core 应用里最干净的写法,也是后续挂载韧性 handler 的入口。
一个要权衡的成本:
HandlerLifetime设得过短会让 handler 频繁轮换,设得过长又可能延迟端点变化。默认值是 2 分钟,但是否合适要结合 DNS/服务发现变化频率评估。需要 Cookie 时还要注意 handler 池会共享并在轮换时丢弃CookieContainer,官方建议此类场景谨慎使用工厂。
六、DelegatingHandler:把横向关注点编进管线
回到第三节那张管线图。DelegatingHandler 让你能在请求“出门”前和响应“回来”后插入逻辑——加 traceId、统一鉴权、结构化日志。写一个自定义 handler 很直观:
| |
注意 handler 的顺序即语义:先注册的在外层、先执行。把“日志”放在最外层,就能记录到含重试在内的完整耗时;把“鉴权”放在韧性层之内,就能让重试带上最新的 token。这种“顺序敏感的洋葱模型”和 ASP.NET Core 中间件如出一辙。
七、韧性:从手写重试到 .NET 8 标准管线
网络请求天生不可靠:会超时、会抖动、会对端短暂故障。所以一个生产级 HTTP 客户端必须自带韧性(resilience):重试、熔断、超时、降级。这条线同样经历了一次范式升级。
早期:手写 try/catch + for 循环。 散落在各处、难以一致、几乎必然漏掉某种异常类型。
Polly 时代:策略即对象。 Policy.Handle<HttpRequestException>().OrResult(...).WaitAndRetryAsync(...) 把“什么算可重试、退避多久、最多几次”抽象成可组合的策略对象,挂到管线上。这是巨大进步,但策略组合的写法仍有学习成本。
.NET 8:标准韧性管线。 Microsoft.Extensions.Http.Resilience 在 Polly v8 的 ResiliencePipeline 之上,提供了一行就能挂载的“出厂合理”配置:
| |
AddStandardResilienceHandler 默认从外到内组合五个策略:rate limiter → total timeout → retry → circuit breaker → attempt timeout。熔断依据采样窗口内的失败比例和最小吞吐量,而不是简单的“连续失败”。这些是通用起点;标准重试默认覆盖所有 HTTP 方法,非幂等请求应禁用重试或使用幂等键。
还有 AddStandardHedgingHandler()——对冲(hedging):发出第一个请求后,若它在指定时间内没返回,就并行再发一个。这是一种针对尾延迟(tail latency) 的武器:在 p99 抖动严重的分布式系统里(想想 Jeff Dean 那张著名的“延迟尾部放大”图),对冲能把慢请求的尾部显著拉平,代价是多打了一些请求。
韧性的设计思想值得单独点出来:它是一组横向关注点,应当作为管线的一部分组合进去,而不是用 try/catch 在每个调用点重复实现。 这正是把“网络不可靠”从业务逻辑里剥离出来的关键一步。
八、设计思想:HttpClient 教会了我们什么
回头看,HttpClient 的演进浓缩了几个 .NET 生态里反复出现的设计哲学:
“门面 + 可组合管线”。 HttpClient 本身是薄门面,真正的架构是那条 handler 链。同样的哲学你能在 ASP.NET Core(入站中间件)、日志(
ILoggerProvider链)、依赖注入里反复看到——把核心逻辑做成管线,把变化点做成可插拔的 handler / provider。“池化昂贵资源,按生命周期保鲜”。 连接池的核心张力是“复用 vs 新鲜”。解法不是二选一,而是给资源加生命周期——让池里的连接 / handler 定期轮换,既享受复用的性能,又定期接触真实世界(刷新 DNS)。这是一个可以推广到连接池、缓存、长连接的通用模式。
“让正确的事更容易”。
IHttpClientFactory+ 类型化 client +AddStandardResilienceHandler提供可靠起点,但仍需处理 Cookie、幂等性、超时预算、DNS 变化和业务错误分类。“横向关注点即中间件”。 鉴权、日志、追踪、韧性,都不该污染业务代码。把它们做成
DelegatingHandler、按顺序组合进管线,业务方法里就只剩下纯粹的领域调用。
九、生产实践与排障清单
把上面的原理落到生产线,常用的判断与排查:
- 端口耗尽:
netstat/ss统计连接状态并结合进程、目的端和连接创建率定位;高频创建并释放独立 handler/client 是常见原因,但代理、NAT 和其他短连接客户端也可能造成同类症状。 - 连接复用率低:检查是否“每请求一个 client”;HTTP/2 场景下注意
MaxConnectionsPerServer的语义变化(多路复用下不需要太多连接)。 - 超时分不清:
HttpClient.Timeout默认 100 秒,几乎一定要显式覆盖。注意超时抛的是TaskCanceledException;.NET 5 起它会包一个TimeoutException作为InnerException,借此区分“客户端超时”和“外部CancellationToken主动取消”。 - DNS 故障转移失效:云上 / K8s 场景,确认
HandlerLifetime(工厂)或PooledConnectionLifetime(长生命周期 client)符合端点变化频率;hedging 解决的是尾延迟,不能替代连接轮换。 - gRPC:
Grpc.Net.Client使用 .NET HTTP 栈,可共享连接管理经验;但 gRPC 自带重试/对冲配置,流式调用和提交点也有特殊语义,不能把 HTTP 重试策略原样套用。
十、决策清单:我该用哪一种
| 场景 | 推荐 |
|---|---|
| 库代码 / 非 DI / 极致性能 | 静态 HttpClient + SocketsHttpHandler{ PooledConnectionLifetime } |
| ASP.NET Core 应用 | IHttpClientFactory + 类型化 client + AddStandardResilienceHandler() |
| 需要拉平尾延迟 | 评估后用 AddStandardHedgingHandler();不要与标准 handler 直接叠加,并确保请求可安全重复 |
| 应避免 | 每请求创建并释放带独立 handler 的 HttpClient;测试替身或明确不发送网络请求等场景另当别论 |
结语
HttpClient 的“难”,不在 API,而在它把一个分布式系统的本质矛盾——网络不可靠、资源要复用、世界会变化——直接暴露给了每一个写业务代码的人。从 socket 耗尽到韧性管线,它的每一次演进都在把这个矛盾往框架深处藏一点,让业务代码更干净一点。
当你下一次写出 builder.Services.AddHttpClient<T>().AddStandardResilienceHandler() 时,你其实正站在一条十年的演进线上——背后是无数人踩过的 TIME_WAIT、调过的 DNS、修过的尾延迟。理解了这条脉络,HttpClient 就从一个“容易踩坑的类型”,变成一个“设计优雅的分布式客户端”。