我们写代码调用外部API时,经常用HttpClient。但你可能遇到过这种情况:应用跑着跑着突然变慢,甚至报错,说“连接池耗尽”或者“套接字不足”。其实这不是HttpClient本身不好用,而是用错了姿势。今天咱们就聊聊这个坑,以及怎么用IHttpClientFactory在ASP.NET Core里优雅地管理并发请求。

一、问题现象与应用场景

1.1 刚上线好好的,后来就瘫了

想象一下这个场景:你的ASP.NET Core网站需要调用一个第三方支付接口,或者从天气服务获取数据。代码里很简单:

// 错误示范:每次请求都new一个HttpClient
using (var client = new HttpClient())
{
    var result = await client.GetStringAsync("https://api.example.com/data");
    // 处理结果
}

测试环境只有几个人用,一切正常。一旦上线,几十上百个用户同时请求,没一会儿网站就卡死了,日志里全是“System.Net.Http.HttpRequestException: 操作超时”或者“System.Net.Sockets.SocketException: 连接尝试失败,因为连接方在一段时间后没有正确应答”。甚至服务器端口都被占满,连远程桌面都登不上。

1.2 常见应用场景

这种问题最容易出现在需要频繁向外发HTTP请求的系统里:

  • 微服务架构里,服务间调用
  • 对接第三方API,比如支付、短信、地图
  • 数据抓取或爬虫程序
  • 实时推送或消息队列的消费者

凡是用HttpClient的地方,只要并发量上来,都可能踩坑。

二、根因分析:为什么连接池会耗尽?

2.1 HttpClient的“真面目”

很多人以为HttpClient用完就释放了,其实它背后管理了一堆底层的套接字(Socket)。每个HttpClient实例都会创建自己的连接池,当你new HttpClient()时,它会开一个新的连接池,里面可以复用连接。但麻烦在于,如果你不正确地释放或者复用实例,连接池就会失控。

2.2 using关键字惹的祸

刚才那个using (var client = new HttpClient())写法看起来没毛病,但实际每次创建新实例时,旧的HttpClient虽然被释放了,但底层的Socket不会立即关闭。操作系统有TIME_WAIT状态,一个端口用完要等一会才能重新分配。高并发下,大量短连接冒出来,端口号一下就用光了,系统反应不过来。

更严重的是,每个HttpClient实例内部会维护一个连接池,默认最大连接数是2(.NET Framework)或无限(.NET Core)。如果你频繁创建实例,每个实例都占着连接不放手,最终导致“连接池耗尽”。

2.3 长期实例的“副作用”

有人会说:那我一个HttpClient实例一直用着,不释放行不行?比如把HttpClient写成静态变量。

// 稍有改进,但仍有隐患
public static HttpClient Client = new HttpClient();

这样虽然避免了频繁创建连接池,但有一个坑:DNS变更不感知。如果API服务器的IP地址变了,这个静态HttpClient一直复用旧连接,就可能请求失败。而且默认的连接限制也会成为瓶颈。

2.4 根因总结

简单来说,HttpClient本来设计为“长期复用实例”,但开发时容易误用成“每次请求创建新实例”,导致连接池被耗尽。同时,它也没有自动处理DNS刷新和连接健康检查。因此,我们需要一个更专业的管理工具。

三、解决方案:使用IHttpClientFactory管理连接池

3.1 官方推荐的做法

从.NET Core 2.1开始,微软引入了IHttpClientFactory。它帮我们干了几件好事:

  • 自动复用HttpClient实例,一个应用里只维护少量连接池
  • 自动处理DNS刷新,定期回收旧连接
  • 可以配置超时、重试、断路器策略
  • 避免手动new出来的资源泄漏

3.2 基本用法

在ASP.NET Core的Startup.csProgram.cs里注册服务:

// Program.cs (.NET 6+)
builder.Services.AddHttpClient();

然后在需要调用的地方通过依赖注入拿到IHttpClientFactory,用它创建客户端。

public class WeatherService
{
    private readonly IHttpClientFactory _httpClientFactory;

    public WeatherService(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async Task<string> GetWeatherAsync()
    {
        var client = _httpClientFactory.CreateClient();
        // 注意:每次只创建轻量的HttpMessageHandler,不是新的连接池
        return await client.GetStringAsync("https://api.weather.com/today");
    }
}

看到没?CreateClient()返回的是复用的HttpClient,它内部用了一个HttpClientHandler池,一个域(比如同一个BaseAddress)共用一个池子。这样既避免了频繁创建新连接池,又控制了连接数量。

3.3 命名客户端与类型化客户端

当我们需要调用多个不同的API,它们有不同配置(比如超时、默认地址)时,可以用命名客户端。

// 注册具名客户端
builder.Services.AddHttpClient("Weather", c =>
{
    c.BaseAddress = new Uri("https://api.weather.com");
    c.Timeout = TimeSpan.FromSeconds(10);
});
// 使用命名客户端
var client = _httpClientFactory.CreateClient("Weather");
var result = await client.GetAsync("/today");

更优雅的是类型化客户端,把调用逻辑封装在独立的类里,通过泛型注册。

// 1. 定义服务类
public class PaymentClient
{
    public HttpClient Client { get; }

    public PaymentClient(HttpClient client)
    {
        Client = client;
        Client.BaseAddress = new Uri("https://pay.example.com");
    }

    public async Task<string> PayAsync(decimal amount)
    {
        // 发送请求
        var response = await Client.PostAsJsonAsync("/charge", new { Amount = amount });
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    }
}

// 2. 注册
builder.Services.AddHttpClient<PaymentClient>();

// 3. 注入使用
public class OrderService
{
    private readonly PaymentClient _payment;
    public OrderService(PaymentClient payment) => _payment = payment;
}

这样PaymentClientHttpClient由工厂自动管理,生命周期和依赖注入一致,完美。

四、完整示例演示:并发请求压力测试

下面我们写一个完整的ASP.NET Core 6+ Web API项目,模拟高并发调用外部接口,同时展示IHttpClientFactory的优势。

4.1 项目结构

假设我们要写一个“批量查询股票价格”的服务,并发请求不可控,所以必须用工厂来管理HttpClient。

4.2 创建服务并注册

// 1. 定义股票查询服务
public class StockService
{
    private readonly IHttpClientFactory _factory;

    public StockService(IHttpClientFactory factory)
    {
        _factory = factory;
    }

    public async Task<string> GetStockPriceAsync(string symbol)
    {
        // 使用默认客户端
        var client = _factory.CreateClient();
        // 假设有一个模拟接口
        var url = $"https://fake-stock-api.example.com/price?symbol={symbol}";
        
        // 模拟网络延迟
        await Task.Delay(100);
        var response = await client.GetAsync(url);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    }
}

// 2. 在Program.cs注册
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHttpClient(); // 注册工厂
builder.Services.AddScoped<StockService>(); // 注册服务
var app = builder.Build();
// ... 中间件配置
app.MapControllers();
app.Run();

4.3 控制器模拟高并发

写一个控制器,用一个Parallel.For模拟多个用户同时请求。

[ApiController]
[Route("[controller]")]
public class StockController : ControllerBase
{
    private readonly StockService _stockService;

    public StockController(StockService stockService)
    {
        _stockService = stockService;
    }

    [HttpGet("simulate")]
    public async Task<IActionResult> SimulateConcurrent()
    {
        var symbols = new[] { "AAPL", "GOOG", "MSFT", "AMZN", "TSLA" };
        var tasks = new List<Task<string>>();

        // 模拟30个并发请求(实际可能是上千)
        for (int i = 0; i < 30; i++)
        {
            var symbol = symbols[i % symbols.Length];
            tasks.Add(_stockService.GetStockPriceAsync(symbol));
        }

        // 等待所有请求完成
        var results = await Task.WhenAll(tasks);
        return Ok(new { count = results.Length, first = results[0] });
    }
}

此时如果不用IHttpClientFactory,跑几次就会报错。用了工厂后,底层连接池复用,性能稳定。

4.4 验证与观察

你可以运行这个项目,用浏览器或Postman访问/stock/simulate。注意观察任务管理器中的端口占用——用工厂模式时,连接数不会爆炸。你还可以使用dotnet-counters工具监视System.Net.Sockets指标,会发现打开的Socket数量很少。

五、技术优缺点

5.1 优点

  • 资源节约:共享连接池,减少端口耗尽风险,降低CPU和内存开销。
  • 自动刷新:默认每隔两分钟回收旧的HttpMessageHandler,DNS变更后能感知新IP。
  • 扩展性强:可以集成Polly做重试、熔断,或者配置代理、自定义日志。
  • 生命周期可控:工厂托管了HttpClient的生命周期,你无需担心忘记释放。

5.2 缺点

  • 学习成本:需要理解依赖注入、命名客户端等概念,新手容易困惑什么时候用CreateClient什么时候用AddHttpClient
  • 不宜再包装:如果自己又套了一层静态缓存,可能抵消工厂的好处。
  • 默认配置不一定最优:比如默认连接池大小为100,如果你的应用需要更多,需要显式调整。

六、注意事项

6.1 不要手动Dispose工厂生成的客户端

IHttpClientFactory创建的HttpClient,你不需要手动Dispose。因为工厂已经跟踪了它的使用,你一旦Dispose,这个客户端就算死掉了,下次再用会新建,反而违背复用意图。

6.2 小心长时间阻塞的请求

如果调用了client.SendAsync并让它无限等待,工厂的连接池可能被占满。建议给每个请求设置合理的超时,或者在注册客户端时统一配置。

builder.Services.AddHttpClient("TimeSensitive", client =>
{
    client.Timeout = TimeSpan.FromSeconds(5);
});

6.3 区分不同基地址的客户端

一个工厂默认会根据BaseAddress的Domain来分组。如果你调用的API域名各不相同,工厂会为每个域名维护独立的连接池,这样是合理的。但如果所有请求都到一个域名,则默认用同一个池。

6.4 避免使用IHttpClientFactory创建短命客户端

如果你在循环里每次调用_factory.CreateClient(),那无所谓,工厂内部会复用。但如果你每次CreateClient()后都用完就丢弃,那也没问题。千万不要手动Dispose它。

6.5 升级时注意版本

.NET Core 2.1引入,.NET 5/6/7/8都支持。如果你的项目还在.NET Framework 4.x,可以安装Microsoft.Extensions.Http NuGet包,但部分功能(如DNS刷新)可能受限。

七、文章总结

HttpClient连接池耗尽是一个经典的生产环境坑,根源在于我们错误地把它当成普通对象来”用完即扔“。现实中的HTTP连接是稀缺资源,必须谨慎管理。IHttpClientFactory的出现彻底解决了这个问题,它通过依赖注入的方式,为我们统一管理连接池、自动刷新DNS、支持灵活配置,让高并发场景下的HTTP调用变得可靠。

从实际效果看,使用工厂后,我们再也不用手动维护静态HttpClient,也不用担心端口耗尽。它虽然增加了一点学习成本,但带来的好处远远超过麻烦。希望这篇文章能帮你避免那个让人抓狂的“连接池耗尽”错误,写出更健壮的ASP.NET Core应用。