.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 连接关闭后并不会立刻消失。 主动关闭方会进入 TIME_WAIT 状态,持续约 2×MSL(Linux 约 60 秒,Windows 约 240 秒),目的是让网络上残留的报文自然消亡,保证同一个四元组(源 IP:源端口 → 目的 IP:目的端口)能被安全复用。
问题在于:new HttpClient() + Dispose 会把底层 TCP 连接主动关闭,于是每来一个请求,你就消耗一个临时源端口、把它踢进 TIME_WAIT。而操作系统的临时端口范围是有限的(Linux 约 32768–60999,Windows 约 49152–65535,几万个)。在每秒成百上千请求的压力下,几分钟内端口就会被耗尽——新连接无源端口可用,于是报 SocketException。
这是 .NET 团队当年在官方文档里专门加警告的著名陷阱。Dispose 不但没帮你,反而加速了耗尽,因为它把本该复用的连接主动掐断了。
排障信号:
netstat -an | findstr TIME_WAIT(Windows)或ss -tan | grep TIME-WAIT | wc -l(Linux)能看到成千上万的 TIME_WAIT 指向同一个目的端——就是这个反模式的铁证。
二、第一次救赎:静态单例与 DNS 陈旧
既然不能每次 new,那就复用——进程内共享一个静态 HttpClient:
| |
socket 耗尽立刻消失,因为连接被池化复用、不再频繁关闭。但这套方案运行一段时间后,会在某些场景下出现一种更隐蔽的故障:服务偶尔连不上,且恰好发生在对端故障转移之后。
这就是 DNS 陈旧(DNS staleness)。HttpClient 一旦建立连接,就会把 DNS 解析出的 IP 缓存住,此后一直复用这个 IP。当对端服务做了故障转移、蓝绿切换、K8s Pod 重建(IP 变了),你的 client 还在傻傻地连旧 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 和连接池。 所以“复用 HttpClient”真正要复用的,是 handler。这一认知直接决定了后面所有的正确用法。
四、SocketsHttpHandler:现代连接池的真正主角
从 .NET Core 2.1 起,所有平台的默认主处理器都是 SocketsHttpHandler(在此之前是基于各平台原生栈的 HttpClientHandler)。它用纯托管代码重写了传输层,带来几个关键能力:
- 连接池化:通过
PooledConnectionLifetime、PooledConnectionIdleTimeout、MaxConnectionsPerServer精细控制连接的生命周期与并发上限。 - HTTP/2 多路复用:一个 TCP 连接上跑多条并发流,连接不再是“一个请求一个”的粗粒度资源,连接池的数学模型因此改变。
- HTTP/3 (QUIC):.NET 6+ 支持(需显式开启),基于 UDP,把传输层握手与队头阻塞问题一并优化。
- 低分配:基于
Span<T>/Memory<T>的实现,请求路径上的内存分配大幅下降。
而 PooledConnectionLifetime 正是解开第二节那个死结的钥匙:它让池化连接按固定周期轮换。 连接不再“活到天荒地老”(默认 InfiniteTimeSpan,这正是 DNS 陈旧的根源),而是每隔一段时间(比如 5–15 分钟)被关闭、重建——重建时自然重新解析 DNS。于是“复用”与“保鲜”第一次被调和:
| |
注意 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 时用了 disposeHandler: false,所以 dispose 工厂创建的 HttpClient 是安全且廉价的——它只释放这一次 message,不碰池化 handler。这纠正了一个流传甚广的误区:“HttpClient 绝对不能 dispose”。对工厂创建的 client,你完全可以 using var resp = ...。
三种用法,复杂度递增:
| |
类型化 client 把“基础地址、默认头、handler 管线”全部封装在 DI 里,业务代码只管调用——这是 ASP.NET Core 应用里最干净的写法,也是后续挂载韧性 handler 的入口。
一个要权衡的成本:工厂抽象带来极小的额外开销,
HandlerLifetime设得过短会让 handler 频繁重建、抵消池化收益。默认 2 分钟对绝大多数场景都合适,别瞎调。
六、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 组合了一条合理的管线:每次尝试有超时(attempt timeout)→ 失败按指数退避重试 → 连续失败触发熔断(circuit breaker)保护下游。默认值是 .NET 团队基于大量实践调出来的“安全默认”,开箱即用;也可通过 HttpStandardResilienceOptions 精细覆盖。
还有 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,本质上是在引导用户走向“开箱即正确”的默认路径——你只要照着模板写,就不会 socket 耗尽、不会 DNS 陈旧、不会裸奔无重试。好的框架用合理默认值把最佳实践固化下来。“横向关注点即中间件”。 鉴权、日志、追踪、韧性,都不该污染业务代码。把它们做成
DelegatingHandler、按顺序组合进管线,业务方法里就只剩下纯粹的领域调用。
九、生产实践与排障清单
把上面的原理落到生产线,常用的判断与排查:
- socket 耗尽:
netstat/ss统计TIME_WAIT数;根因几乎都是某处还在new HttpClient()。修法:换工厂或静态单例。 - 连接复用率低:检查是否“每请求一个 client”;HTTP/2 场景下注意
MaxConnectionsPerServer的语义变化(多路复用下不需要太多连接)。 - 超时分不清:
HttpClient.Timeout默认 100 秒,几乎一定要显式覆盖。注意超时抛的是TaskCanceledException;.NET 6+ 起它会包一个TimeoutException作为InnerException,借此区分“客户端超时”和“外部CancellationToken主动取消”。 - DNS 故障转移失效:云上 / K8s 场景,确认
HandlerLifetime(工厂)或PooledConnectionLifetime(单例)已设成合理值;要求高的场景可叠加 hedging。 - gRPC:.NET 的 gRPC 客户端底层就是 HttpClient,同样走工厂与连接池,韧性机制完全通用。
十、决策清单:我该用哪一种
| 场景 | 推荐 |
|---|---|
| 库代码 / 非 DI / 极致性能 | 静态 HttpClient + SocketsHttpHandler{ PooledConnectionLifetime } |
| ASP.NET Core 应用 | IHttpClientFactory + 类型化 client + AddStandardResilienceHandler() |
| 需要拉平尾延迟 | 叠加 AddStandardHedgingHandler() |
| 永远别用 | 每请求 new HttpClient()(无论是否 using) |
结语
HttpClient 的“难”,不在 API,而在它把一个分布式系统的本质矛盾——网络不可靠、资源要复用、世界会变化——直接暴露给了每一个写业务代码的人。从 socket 耗尽到韧性管线,它的每一次演进都在把这个矛盾往框架深处藏一点,让业务代码更干净一点。
当你下一次写出 builder.Services.AddHttpClient<T>().AddStandardResilienceHandler() 时,你其实正站在一条十年的演进线上——背后是无数人踩过的 TIME_WAIT、调过的 DNS、修过的尾延迟。理解了这条脉络,HttpClient 就从一个“容易踩坑的类型”,变成一个“设计优雅的分布式客户端”。