HttpClient 长连接不是永久连接:DNS、连接池与安全重试的工程边界


一个接口客户端可能同时遇到两类相反的问题:每次请求新建连接导致端口与握手成本过高;客户端长期复用旧连接,服务器换 IP 后仍向旧地址发送请求。解决办法不是在“每次 new”和“永久不变”之间二选一,而是区分客户端、Handler、连接和单次请求的寿命。

谁真正拥有连接池

在现代 .NET 中,SocketsHttpHandler 管理连接池。长期复用 HttpClient 时,可以通过 PooledConnectionLifetime 限制连接的存活时间;DNS 在建立连接时解析,客户端不会根据 DNS TTL 主动更新已经存在的连接。另一种方式是使用 IHttpClientFactory 管理可复用 Handler。Microsoft 的使用指南说明了这两条路径。

这意味着三个时间不应混淆:DNS 记录的缓存时间、连接寿命,以及一次请求的超时。把请求超时设成 3 秒,不会让连接每 3 秒更新;把连接寿命设成 2 分钟,也不意味着 2 分钟一到就杀掉仍在进行的下载。

对一个内网服务,若节点预计每几小时变更一次,可以先选分钟级连接寿命,再结合故障切换目标测试;若节点必须在十几秒内切换,仅靠分钟级连接更新显然不够。具体值取决于服务发现、DNS 缓存、负载均衡和连接成本,不能照抄示例数字。

先有请求预算,再配置韧性策略

假设页面允许用户等待 8 秒,客户端给每次尝试 5 秒,并重试两次,最坏时间已经超过 15 秒,还没算退避等待。总预算必须包含排队、所有尝试、重试间隔和业务解析;用户取消也要传到底层。

下面使用 .NET 8 和 Microsoft.Extensions.Http.Resilience 的标准 Handler API,展示一种面向只读查询的命名客户端配置。需要兼容的 Microsoft.Extensions.Http、依赖注入与 Resilience 包;代码未在配套工程编译,不作为版本锁定文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;

var services = new ServiceCollection();
services.AddHttpClient("catalog-read", client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
// 由韧性管道管理总时限,调用方仍应传取消令牌。
client.Timeout = Timeout.InfiniteTimeSpan;
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
ConnectTimeout = TimeSpan.FromSeconds(2),
MaxConnectionsPerServer = 16,
UseCookies = false
})
.AddStandardResilienceHandler(options =>
{
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8);
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
options.Retry.MaxRetryAttempts = 2;
options.Retry.Delay = TimeSpan.FromMilliseconds(200);
options.Retry.DisableForUnsafeHttpMethods();
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(10);
options.RateLimiter.DefaultRateLimiterOptions.PermitLimit = 16;
options.RateLimiter.DefaultRateLimiterOptions.QueueLimit = 0;
});

标准韧性 Handler 提供总超时、单次超时、重试、熔断和并发限制,并可禁止对不安全方法重试。默认可重试的响应范围不等于所有非成功响应;实际行为应以所用版本配置为准。官方韧性说明列出了策略及 API。

示例限制 16 个并发请求且不额外排队,是为了让压力暴露在调用边界,而不是隐藏在几千项等待里。HTTP/2 可以在一条连接复用多个流,所以 MaxConnectionsPerServer 不应直接当成跨协议的业务并发上限;应用仍需要独立的并发控制。

熔断也不是业务失败的修复器。它减少对明显不健康依赖的继续调用,却可能让恢复初期请求快速失败。前台要能表达“暂时不可用”,后台要保留可恢复状态,而不是用无限重试把失败藏起来。

安全、幂等和可重放不是同一件事

HTTP 的安全方法关注请求是否以只读语义定义,幂等关注重复执行的预期服务端效果。PUT、DELETE 虽通常具有幂等语义,却不属于安全方法;上面 DisableForUnsafeHttpMethods 的策略故意比“只排除非幂等方法”更保守。RFC 9110 的方法语义是这一判断的依据。

再看一个订单创建请求:服务端提交了订单,响应在网络中丢失。客户端看到的是超时,无法判断“尚未提交”和“已经提交但没有收到结果”。仅在客户端重试 POST 并不能消除这种不确定性。

可以把稳定的操作 ID 作为服务端协议的一部分:服务端对租户、操作 ID 建唯一约束,在同一事务中记录请求摘要与业务结果;相同 ID、相同摘要返回已有结果;相同 ID、不同摘要拒绝。去重记录保留多久,应覆盖客户端允许重试的最长窗口。

即使业务幂等,还要考虑请求体能否重新发送。一次性的流、已经读取的文件句柄和动态生成内容,不一定能无成本重放。手写重试循环时,不要重复发送已经使用的 HttpRequestMessage;应每次构造新的请求,并确保请求体来源可重建。

响应体与客户端共享状态同样需要边界

常规小响应可以完整读入内存;大文件使用 ResponseHeadersRead 时,SendAsync 完成只代表响应头已到,后续读取应带独立的取消和时间预算。总超时策略的具体覆盖范围也要验证,不能假设返回 Stream 后的全部消费仍受同一个超时保护。

响应和流要及时释放,否则连接可能长时间被占用。只检查 StatusCode,却把未读响应保留到下一轮重试,是一种容易被低并发测试掩盖的资源泄漏方式。

共享客户端上也不要为每个请求反复修改 DefaultRequestHeaders.Authorization。并发请求可能属于不同用户,应在单个 HttpRequestMessage 上设置认证头。Cookie 则需要按身份边界隔离;示例关闭 Cookie,是因为假设服务采用无状态令牌认证,不代表所有应用都可以这样设置。

观测要同时看逻辑请求和实际尝试

业务日志通常一条记录对应一次“加载目录”,网络层却可能已经进行了三次尝试。若只按最终成功率统计,服务端短时故障与重试放大都会被掩盖。建议为逻辑请求保留一个关联 ID,每次尝试记录独立序号,分别计算逻辑成功率、平均尝试数、限流拒绝数和最终耗时。

假设每秒有 100 个逻辑请求,故障时每个请求都执行初始尝试加两次重试,依赖可能面对每秒 300 次请求。若上游同时也重复整个业务操作,放大还会继续相乘。退避加随机抖动能减少同步重试的尖峰,但不能补足持续不足的服务器容量,因此还需要有界并发与拒绝策略。

故障分类也会改变处理方式:TLS 证书配置错误通常需要修复部署,404 可能是业务不存在,401 可能需要一次凭据恢复,而 503 可能允许稍后再试。它们不能都被压缩成“网络异常”。日志应保留安全的状态码、阶段和关联信息,同时避免写入令牌、Cookie 或敏感请求体。

诊断 DNS 切换时,先检查服务端是否仍接收旧连接,再检查新连接解析到了哪里。已经存在的连接继续访问旧节点,不等于 DNS 解析器仍返回旧地址;如果只查看域名解析结果,可能错误地把连接复用问题归因于 DNS。将解析、建连和业务响应分开记录,更容易缩小问题范围。

怎样验证连接与重试确实按预期工作

搭建两个可区分身份的测试服务 A、B,返回节点名并记录请求 ID。用受控 DNS 或测试域名切换地址,在持续请求下观察何时出现新连接。分别记录建立连接、请求耗时与节点身份;不要只用一次 ping 推断 HttpClient 的连接选择。

再让服务在提交后主动断开连接,确认写接口没有被通用查询重试策略自动重放。对支持操作 ID 的接口连续发送同一操作,验证只产生一份业务结果,而不是仅检查两次都返回 200。

最后注入 429、503、慢响应体和长时间连接建立,统计每个逻辑请求的实际尝试数、总耗时与并发峰值。若调用方、网关和客户端库分别重试三次,最坏放大可能远超想象,应指定唯一负责重试的层。本文未执行这些网络实验,数字均是预算示例,而非实测结论。


 上一篇
Room 离线优先同步:本地事务、Outbox 与旧响应覆盖新编辑的竞态 Room 离线优先同步:本地事务、Outbox 与旧响应覆盖新编辑的竞态
离线保存与后台上传之间存在崩溃窗口,上传响应还可能覆盖用户的新编辑。本文以笔记同步为例设计本地版本、待发送记录和条件确认,说明 Room 事务能保证什么,以及跨设备冲突为什么需要服务端参与。
2026-10-18
下一篇 
WPF 批量数据为什么卡顿:从 Dispatcher 队列到 MVVM 更新预算 WPF 批量数据为什么卡顿:从 Dispatcher 队列到 MVVM 更新预算
后台线程生成数据很快,界面仍可能被成千上万次通知拖住。本文拆解 Dispatcher、集合通知与虚拟化的不同成本,用限量批处理和过期结果隔离设计可取消的数据导入流程,并给出可复现的性能验证方法。
2026-10-18
  目录