HttpClient 的前世今生:从 Socket 耗尽到 .NET 8 韧性管线

.NET 里很少有哪个类型像 HttpClient 这样:API 简单到五分钟就能上手,却又被全社区的工程师集体用错了十年。它的每一个“坑”——socket 耗尽、DNS 不刷新、超时分不清——背后其实都是同一个设计张力的不同投影:

HTTP 连接是昂贵资源,必须复用;而复用又与世界的变化(DNS、故障、超时)天然冲突。

理解了这层张力,HttpClient 的“前世今生”就不再是一堆要背诵的最佳实践,而是一条清晰的演进脉络:从无脑 new、到静态单例、到 IHttpClientFactory、再到 .NET 8 的标准韧性管线,每一步都是在重新回答“怎么既复用、又保鲜”。

一、前世:using (var client = new HttpClient()) 为什么是反模式

先看那段几乎每个 .NET 新手都写过的代码:

1
2
3
4
5
public async Task<string> GetAsync(string url)
{
    using var client = new HttpClient();          // ← 灾难的起点
    return await client.GetStringAsync(url);
}

它看起来人畜无害,但在高并发下,服务会以 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

1
private static readonly HttpClient _client = new();

socket 耗尽立刻消失,因为连接被池化复用、不再频繁关闭。但这套方案运行一段时间后,会在某些场景下出现一种更隐蔽的故障:服务偶尔连不上,且恰好发生在对端故障转移之后。

这就是 DNS 陈旧(DNS staleness)HttpClient 一旦建立连接,就会把 DNS 解析出的 IP 缓存住,此后一直复用这个 IP。当对端服务做了故障转移、蓝绿切换、K8s Pod 重建(IP 变了),你的 client 还在傻傻地连旧 IP,于是请求超时或被拒。

于是我们陷入了一个死结:

  • 复用连接 → socket 不耗尽,但 DNS 不刷新;
  • 每次新建 → DNS 是新的,但 socket 耗尽。

这是 HttpClient 演进的核心驱动力——如何在“连接复用”和“端点保鲜”之间找到平衡。后续所有方案(IHttpClientFactorySocketsHttpHandler.PooledConnectionLifetime)本质上都在回答这一个问题。

三、原理:HttpClient 其实是个“门面”

要看懂后面的方案,先得看清 HttpClient 的分层。它本身几乎是空的——真正干活的是它持有的 HttpMessageHandler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
HttpClient                      ← 薄薄的门面(Facade),公开 GetAsync/PostAsync
HttpMessageHandler 管线          ← 真正的架构在这里
   │  DelegatingHandler:日志 / 追踪
   │  DelegatingHandler:韧性(重试 / 熔断 / 超时)
   │  DelegatingHandler:认证 / 签名
SocketsHttpHandler(主处理器)   ← 连接池、HTTP/2、HTTP/3,真正收发字节
TCP / HTTP/2 / HTTP/3

这张图藏着三层关键设计:

  1. HttpClient 是门面,不是实现。 你调用的 GetAsync 只是把请求顺着一条 handler 链往下传,最后由“主处理器”(primary handler)真正发到网络上。这解释了为什么“换一个 handler”就能换一套行为——比如测试时换成 mock handler,连线都不用发。

  2. DelegatingHandler 就是出站中间件。 它和 ASP.NET Core 的入站中间件是同一个思想,只不过方向相反——请求在这里被一层层加工:加日志、加重试、加 Authorization 头。横向关注点(cross-cutting concerns)从业务代码里剥离,干净地组合进管线。

  3. 连接池不在 HttpClient 里,而在主处理器里。 这是最容易被忽略、却最关键的一点:HttpClient 是廉价的、可随意创建的;昂贵的是它背后的 handler 和连接池。 所以“复用 HttpClient”真正要复用的,是 handler。这一认知直接决定了后面所有的正确用法。

四、SocketsHttpHandler:现代连接池的真正主角

从 .NET Core 2.1 起,所有平台的默认主处理器都是 SocketsHttpHandler(在此之前是基于各平台原生栈的 HttpClientHandler)。它用纯托管代码重写了传输层,带来几个关键能力:

  • 连接池化:通过 PooledConnectionLifetimePooledConnectionIdleTimeoutMaxConnectionsPerServer 精细控制连接的生命周期与并发上限。
  • HTTP/2 多路复用:一个 TCP 连接上跑多条并发流,连接不再是“一个请求一个”的粗粒度资源,连接池的数学模型因此改变。
  • HTTP/3 (QUIC):.NET 6+ 支持(需显式开启),基于 UDP,把传输层握手与队头阻塞问题一并优化。
  • 低分配:基于 Span<T> / Memory<T> 的实现,请求路径上的内存分配大幅下降。

PooledConnectionLifetime 正是解开第二节那个死结的钥匙:它让池化连接按固定周期轮换。 连接不再“活到天荒地老”(默认 InfiniteTimeSpan,这正是 DNS 陈旧的根源),而是每隔一段时间(比如 5–15 分钟)被关闭、重建——重建时自然重新解析 DNS。于是“复用”与“保鲜”第一次被调和:

1
2
3
4
5
6
// 现代“静态单例”的正确写法:复用 client,但让连接定期轮换以刷新 DNS
private static readonly SocketsHttpHandler _handler = new()
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(15)
};
private static readonly HttpClient _client = new(_handler, disposeHandler: false);

注意 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 = ...

三种用法,复杂度递增:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1) 基础用法:从工厂拿一个默认 client
public class Foo(IHttpClientFactory factory)
{
    public async Task<string> Get() =>
        await factory.CreateClient().GetStringAsync("https://api.example.com");
}

// 2) 命名 client:预配置一组客户端
builder.Services.AddHttpClient("github", c =>
{
    c.BaseAddress = new("https://api.github.com");
    c.DefaultRequestHeaders.UserAgent.ParseAdd("my-blog");
});
// 取用:factory.CreateClient("github")

// 3) 类型化 client(最推荐):把配置和调用封装进一个类型
builder.Services.AddHttpClient<GitHubApi>(c =>
    c.BaseAddress = new("https://api.github.com"));

public class GitHubApi(HttpClient client)   // HttpClient 由工厂注入,已预配置
{
    public Task<Repo?> GetRepo(string owner, string name) =>
        client.GetFromJsonAsync<Repo>($"repos/{owner}/{name}");
}

类型化 client 把“基础地址、默认头、handler 管线”全部封装在 DI 里,业务代码只管调用——这是 ASP.NET Core 应用里最干净的写法,也是后续挂载韧性 handler 的入口。

一个要权衡的成本:工厂抽象带来极小的额外开销,HandlerLifetime 设得过短会让 handler 频繁重建、抵消池化收益。默认 2 分钟对绝大多数场景都合适,别瞎调。

六、DelegatingHandler:把横向关注点编进管线

回到第三节那张管线图。DelegatingHandler 让你能在请求“出门”前和响应“回来”后插入逻辑——加 traceId、统一鉴权、结构化日志。写一个自定义 handler 很直观:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class CorrelationIdHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var id = Guid.NewGuid().ToString("N");
        request.Headers.TryAddWithoutValidation("X-Correlation-Id", id);

        var response = await base.SendAsync(request, ct);   // 交给下一层
        response.Headers.TryAddWithoutValidation("X-Correlation-Id", id);
        return response;
    }
}

// 注册到某个命名 / 类型化 client 的管线
builder.Services.AddHttpClient<GitHubApi>(...)
    .AddHttpMessageHandler<CorrelationIdHandler>();

注意 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 之上,提供了一行就能挂载的“出厂合理”配置:

1
2
builder.Services.AddHttpClient<GitHubApi>(...)
    .AddStandardResilienceHandler();   // 自带:尝试超时 + 重试 + 熔断,比例与退避都已调好

AddStandardResilienceHandler 组合了一条合理的管线:每次尝试有超时(attempt timeout)→ 失败按指数退避重试连续失败触发熔断(circuit breaker)保护下游。默认值是 .NET 团队基于大量实践调出来的“安全默认”,开箱即用;也可通过 HttpStandardResilienceOptions 精细覆盖。

还有 AddStandardHedgingHandler()——对冲(hedging):发出第一个请求后,若它在指定时间内没返回,就并行再发一个。这是一种针对**尾延迟(tail latency)**的武器:在 p99 抖动严重的分布式系统里(想想 Jeff Dean 那张著名的“延迟尾部放大”图),对冲能把慢请求的尾部显著拉平,代价是多打了一些请求。

韧性的设计思想值得单独点出来:它是一组横向关注点,应当作为管线的一部分组合进去,而不是用 try/catch 在每个调用点重复实现。 这正是把“网络不可靠”从业务逻辑里剥离出来的关键一步。

八、设计思想:HttpClient 教会了我们什么

回头看,HttpClient 的演进浓缩了几个 .NET 生态里反复出现的设计哲学:

  1. “门面 + 可组合管线”。 HttpClient 本身是薄门面,真正的架构是那条 handler 链。同样的哲学你能在 ASP.NET Core(入站中间件)、日志(ILoggerProvider 链)、依赖注入里反复看到——把核心逻辑做成管线,把变化点做成可插拔的 handler / provider。

  2. “池化昂贵资源,按生命周期保鲜”。 连接池的核心张力是“复用 vs 新鲜”。解法不是二选一,而是给资源加生命周期——让池里的连接 / handler 定期轮换,既享受复用的性能,又定期接触真实世界(刷新 DNS)。这是一个可以推广到连接池、缓存、长连接的通用模式。

  3. “让正确的事变简单”。 IHttpClientFactory + 类型化 client + AddStandardResilienceHandler,本质上是在引导用户走向“开箱即正确”的默认路径——你只要照着模板写,就不会 socket 耗尽、不会 DNS 陈旧、不会裸奔无重试。好的框架用合理默认值把最佳实践固化下来。

  4. “横向关注点即中间件”。 鉴权、日志、追踪、韧性,都不该污染业务代码。把它们做成 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 就从一个“容易踩坑的类型”,变成一个“设计优雅的分布式客户端”。