在C#中调用REST API时,HttpClient是使用频率最高的类型。它的出现替代了HttpWebRequest繁琐的配置,让发送GET、POST、上传文件、下载流都变得简洁。但正是因为用起来太简单,很多代码会直接在方法内部使用using创建实例,结果上线后偶发出现System.Net.Sockets.SocketException或System.Net.Http.HttpRequestException。理解HttpClient底层连接复用机制,是写出稳定HTTP调用代码的第一步。

为什么不能每次请求都new一个HttpClient
HttpClient内部通过SocketsHttpHandler维护一个连接池。应用程序发起请求时,它会优先从池中取出一个空闲连接,没有可用连接才执行DNS解析、TCP三次握手和TLS协商。这个过程非常耗时,尤其是TLS握手,通常需要几百毫秒。请求结束后连接默认不会立即关闭,而是进入空闲状态,等待下一次复用。
如果每次请求都使用using包裹HttpClient,调用方以为释放了资源,实际上客户端实例销毁时,底层Socket会立即关闭。在Windows和Linux上关闭后的Socket会进入TIME_WAIT状态,短时间内无法被完全回收。并发请求一多,本地可用端口被占满,新的连接建立失败,表现就是SocketException。即使端口没有耗尽,每次重新握手也会让响应时间显著增加。
// 错误示范:每次请求都创建新的HttpClient
public async Task<string> GetUserAsync(int userId)
{
using var client = new HttpClient();
var url = $"https://api.ipipp.com/users/{userId}";
return await client.GetStringAsync(url);
}
这段代码在低并发时看不出问题,但在批量同步数据或接口被频繁调用时,会逐步拖垮整个进程。解决办法并不是粗暴地改成一个全局static单例。单例虽然能复用连接,但默认不会响应DNS变更。比如后端域名从一个机房切换到另一个机房,单例HttpClient仍会连接旧IP,直到进程重启。
使用IHttpClientFactory管理客户端生命周期
ASP.NET Core和.NET 5及以上推荐使用IHttpClientFactory。它不直接创建HttpClient,而是管理内部的SocketsHttpHandler实例。工厂会为每个命名客户端维护一个handler池,handler默认每两分钟轮换一次。这样既能复用连接,又能定期刷新DNS,兼顾了性能和正确性。
注册方式有两种:命名客户端和类型化客户端。命名客户端适合在多个服务间共享配置,类型化客户端则把请求逻辑封装成强类型服务,依赖注入更清晰。
var builder = WebApplication.CreateBuilder(args);
// 命名客户端
builder.Services.AddHttpClient("api", client =>
{
client.BaseAddress = new Uri("https://api.ipipp.com/");
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
// 类型化客户端
builder.Services.AddHttpClient<OrderService>(client =>
{
client.BaseAddress = new Uri("https://api.ipipp.com/");
client.Timeout = TimeSpan.FromSeconds(15);
});
类型化客户端通常把HttpClient注入到构造函数,业务方法只关心参数和返回值。这样控制器或后台服务无需了解BaseAddress、序列化格式等HTTP细节。
public class OrderService
{
private readonly HttpClient _client;
public OrderService(HttpClient client)
{
_client = client;
}
public async Task<Order> GetOrderAsync(string orderId)
{
var json = await _client.GetStringAsync($"/orders/{orderId}");
return JsonSerializer.Deserialize<Order>(json);
}
}
使用工厂后,每次调用CreateClient或注入HttpClient得到的都是轻量级包装对象,可以安全释放,不会关闭底层Socket。真正决定连接是否复用的是handler,而handler由工厂统一调度。
请求头、超时、取消与流式处理
HTTP调用不只是发送请求和读取字符串。生产环境中必须明确超时、取消和响应读取策略,否则一个慢接口可能拖住线程,甚至造成线程池饥饿。
HttpClient.Timeout属性设置的是整个请求的超时时间,默认100秒。这个时间对用户请求太长,对后台任务又可能不够。建议根据下游接口P99延迟设置,通常10到30秒比较合理。还可以通过CancellationTokenSource实现更细粒度的取消。
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.ipipp.com/reports");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token);
using var fileStream = File.Create("daily-report.json");
await stream.CopyToAsync(fileStream, cts.Token);
HttpCompletionOption.ResponseHeadersRead表示读取到响应头后立即返回,不会把响应体缓冲到内存。下载大文件时必须使用这个选项,否则内容会先占满内存。相应地,读取流和复制文件也需要传入取消令牌,一旦超时或用户取消,可以中断IO操作。
对于上传,可以使用StreamContent或ByteArrayContent。发送JSON时建议先序列化到字符串,再使用StringContent。需要注意StringContent默认编码是text/plain,如果要发送application/json,必须设置Content-Type。
var payload = JsonSerializer.Serialize(new { name = "dotnet", count = 10 });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
using var response = await client.PostAsync("https://api.ipipp.com/orders", content, cts.Token);
自动解压也是容易忽略的优化点。如果下游支持gzip、br或deflate,可以在handler上开启AutomaticDecompression,HttpClient会自动添加Accept-Encoding头并解压响应,减少网络传输量。
连接池参数与错误重试策略
SocketsHttpHandler提供了多个连接池参数。PooledConnectionLifetime控制单个连接最长存活时间,设置过短会频繁握手,设置过长则DNS更新慢。官方建议值通常在2到10分钟之间。PooledConnectionIdleTimeout决定连接空闲多久后被释放,ConnectTimeout只控制建立TCP连接的时间,不包含TLS握手。
var handler = new SocketsHttpHandler
{
ConnectTimeout = TimeSpan.FromSeconds(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 20,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli
};
var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
MaxConnectionsPerServer限制到单个域名的并发连接数。过大会增加对下游服务器的压力,过小会限制本机吞吐。对于大部分JSON API,10到20足够。UseCookies默认会启用CookieContainer,如果服务端不需要Cookie,关闭它可以避免不必要的状态共享。
网络请求还会遇到瞬时故障,例如短暂的DNS解析失败、连接重置或HTTP 503。只重试一次往往不够,但无限重试又会扩大故障。合适的做法是重试2到3次,并增加指数退避。
public static async Task<string> GetStringWithRetryAsync(HttpClient client, string url, int maxRetries = 3)
{
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
using var response = await client.GetAsync(url);
if ((int)response.StatusCode >= 500)
{
response.EnsureSuccessStatusCode();
}
return await response.Content.ReadAsStringAsync();
}
catch (HttpRequestException) when (attempt < maxRetries - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
}
}
throw new InvalidOperationException("请求重试后仍然失败");
}
这里只针对服务端错误和HttpRequestException重试,客户端参数错误如400不应该重试。生产项目也可以引入Polly定义重试策略,把退避、熔断和超时组合起来,代码会更清晰。
HttpClientC#发送HTTP请求最佳实践修改时间:2026-09-24 05:02:38