「不要每次 new 一个 HttpClient,会耗尽端口」这句话我说了三年,但一直说不清为什么。2026 年 3 月底有个接口偶发超时,我又怀疑到它头上,干脆把 Microsoft.Extensions.Http 8.0.0 的源码翻了一遍。下面是看下来的笔记,其中三处是我自己的推测,我没有把握它们是准确解读。

一、从 CreateClient 开始

入口是 DefaultHttpClientFactory.CreateClient(string name)。它做的事比我想的简单:从 _handlers 这个 ConcurrentDictionary 里按 name 取 Lazy<ActiveHandlerTrackingEntry>,取不到就新建一个;然后拿里面的 Handler 去 new 一个 HttpClient,并把 disposeHandler 参数设成 false

这一句是关键:每次 CreateClient 都会 new 一个 HttpClient,但底层 Handler 是共用的。HttpClient 本身很轻,真正贵的是它下面那条连接。所以「HttpClient 要实现 IDisposable 所以要复用」这个常见说法,其实把账算错了对象——要复用的是 Handler。

二、两分钟是谁定的

Handler 的生命周期在 HttpClientFactoryOptions 里,默认值就是硬编码的两分钟:构造 ActiveHandlerTrackingEntry 时传 options.HandlerLifetime,然后启动一个定时器,到期把这条 entry 从活跃集合挪走。改法大家都熟:

services.AddHttpClient("order")
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));

为什么要有这个到期机制?不是为了省资源,而是为了 DNS。Handler 底层的连接池是长连接,如果永远不过期,域名换了 IP 它也发现不了。所以这个两分钟本质是「强制定期重建连接,让 DNS 有机会被重新解析」。设成 Timeout.InfiniteTimeSpan 就等于放弃这一点——我见过有人这么配,他的服务确实在 DNS 变更之后连了两天旧 IP 才恢复。

三、过期 Handler 为什么不能立刻 Dispose

这是我这次最想搞清楚的地方。到期之后 entry 并没有被直接 Dispose,而是包成 ExpiredHandlerTrackingEntry 放进另一个集合,用 WeakReference 盯着。原因是:此刻可能还有请求正在用这个 Handler——HttpClient 已经被交给业务代码了,工厂并不知道那一笔请求有没有走完。

做法是这样:新建 Handler 时会套一层 LifetimeTrackingHttpMessageHandler,它自己带引用计数。每个还在途的请求都算一个引用,计数归零之后再真正 Dispose 底层连接。所以你会在源码里看到「过期」和「释放」是两个分开的步骤,中间间隔取决于最后一个请求什么时候结束。

(清理定时器我记得是每隔几秒扫一次那个过期集合,具体间隔我没在源码里找到明确的常量,可能记错了,也可能它用的是另一套调度。这一条请当存疑。)

四、连接池到底在哪一层

不在 Microsoft.Extensions.Http 里。这个包只负责「Handler 的生老病死」,真正的连接池在 SocketsHttpHandler 里,由 HttpConnectionPoolManager 按 origin(协议 + host + port)分池管理。想调连接数上限,要去 SocketsHttpHandler.MaxConnectionsPerServer;想让连接自己也定期轮换,看 PooledConnectionLifetimePooledConnectionIdleTimeout

services.AddHttpClient("order")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        MaxConnectionsPerServer = 64,
        PooledConnectionLifetime = TimeSpan.FromMinutes(10),
        PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1)
    });

五、端口耗尽那个说法

把上面串起来,端口耗尽的逻辑就通了:每次 new 一个 HttpClient 且不复用,等于每次新建一个连接池,请求结束连接进 TIME_WAIT;Windows 上动态端口范围默认 49152–65535 共 16384 个,TIME_WAIT 持续时间按 240 秒算,持续 68 次/秒 左右的新连接就会把端口占满。

这个算法是我从几篇资料里拼出来的,两个数字都没在我自己的机器上实测过——我只做过一次「循环 new 一万个 HttpClient 然后发请求」的粗糙实验,确实在几千次之后开始抛 HttpRequestException,但没有数出精确的阈值。所以这部分请当参考,别当结论。

六、看完之后我改了什么

改了两处。一是把项目里两处手动 new 的 HttpClient 换成类型化客户端(typed client);二是给一个调用外部接口的客户端加上了 MaxConnectionsPerServer = 32——之前它默认无上限,压测时把对方服务的连接池挤满了,这个锅当时甩给了对方,现在想想挺不好意思的。

还有一处我没查出结果:那个偶发超时的接口,最后定位到是对方服务在 GC,跟 HttpClient 没关系。我这次读源码算是误打误撞,但笔记留着不亏。