HTTP 客户端与弹性模式

系统讲解 .NET HTTP 客户端的最佳实践,覆盖 HttpClient 生命周期陷阱、IHttpClientFactory 注册与命名客户端、Polly 重试熔断超时策略、负载均衡与故障转移,以及日志与性能监控的落地。

1. HttpClient 的生命周期陷阱

一句话总结: HttpClient 是轻量对象但底层 Socket 昂贵,反复 new 会耗尽连接;静态共享又导致 DNS 不刷新,IHttpClientFactory 是官方解法。

HttpClient 本身不是重量级对象,但它维护的连接池与底层 Socket 很昂贵。常见的两个极端都有问题:每次都 new HttpClient 会反复建连、耗尽 Socket 端口;长期静态共享又会让 DnsRefreshTimeout 过期后依旧连到旧 IP。IHttpClientFactory 同时解决两者:复用连接的同时,按周期轮换 HttpClientHandler。

// 反例 1:每次 new,Socket 泄漏
for (int i = 0; i < 1000; i++)
{
    using var client = new HttpClient();
    await client.GetStringAsync(url); // 每次都建立新 TCP 连接
}

// 反例 2:静态单例,DNS 永不刷新
private static readonly HttpClient _shared = new HttpClient();
// 运行几天后,目标服务换 IP,这里还在连旧地址

// 正例:IHttpClientFactory
builder.Services.AddHttpClient();
方案连接复用DNS 刷新依赖注入
每次 new否是否
静态单例是否否
IHttpClientFactory是是是

避坑: 不要为了「优雅」在短生命周期组件里 new HttpClient。即使 Dispose 也未必立刻释放底层连接(TIME_WAIT 状态会残留)。工厂模式把连接池托管起来,并且能配合 AddHttpMessageHandler 插入横切逻辑。

2. IHttpClientFactory 与命名客户端

一句话总结: AddHttpClient 提供默认、命名与类型化三种客户端,每种可绑定独立基址、超时与消息处理器,是微服务间调用的标准姿势。

AddHttpClient("orders") 注册一个命名客户端,每个名字拥有独立的配置与处理器管线。类型化客户端把 HttpClient 封装进业务类,避免字符串魔法,并天然配合 DI。无论哪种,都通过 IHttpClientFactory.CreateClient(name) 获取。

// 命名客户端
builder.Services.AddHttpClient("orders", client =>
{
    client.BaseAddress = new Uri("https://orders.internal.example.com");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
    client.Timeout = TimeSpan.FromSeconds(10);
});

// 类型化客户端
builder.Services.AddHttpClient<IOrderGateway, OrderGateway>(client =>
{
    client.BaseAddress = new Uri("https://orders.internal.example.com");
});

public class OrderGateway(HttpClient http) : IOrderGateway
{
    public async Task<Order?> GetAsync(int id, CancellationToken ct)
        => await http.GetFromJsonAsync<Order>($"/api/orders/{id}", ct);
}

// 消费命名客户端
public class OrderService(IHttpClientFactory factory)
{
    public async Task<Order?> GetAsync(int id, CancellationToken ct)
    {
        var http = factory.CreateClient("orders");
        return await http.GetFromJsonAsync<Order>($"/api/orders/{id}", ct);
    }
}
方式适用优点
默认客户端通用外部调用最少配置
命名客户端多下游服务按名隔离配置
类型化客户端封装下游 SDK强类型 + DI

避坑: 命名客户端名拼错会在运行时才暴露。团队项目优先类型化客户端,把 URL、处理器、解析逻辑收进一个类,测试时直接 mock 这个类,不用碰真实网络。

3. Polly 重试策略

一句话总结: 瞬时故障(超时、5xx、网络抖动)值得重试,但要带退避、抖动与重试计数,避免「重试风暴」放大故障。

Polly 是 .NET 弹性库的事实标准。重试(Retry)适合幂等且故障瞬时的场景:指数退避加随机抖动,避免所有客户端在同一时刻重试造成惊群。重试要判断异常类型与响应状态码,只重试「可恢复」的失败。

// 引入 Microsoft.Extensions.Http.Resilience 或 Polly
builder.Services.AddHttpClient("payments", client =>
{
    client.BaseAddress = new Uri("https://payments.internal.example.com");
}).AddResilienceHandler("retry-pipeline", pipeline =>
{
    pipeline.AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromMilliseconds(200),
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true, // 随机抖动防止惊群
        ShouldHandle = args => args.Outcome.Exception is HttpRequestException ||
                               args.Outcome.Result?.StatusCode == HttpStatusCode.ServiceUnavailable
                                   ? PredicateResult.True()
                                   : PredicateResult.False()
    });
});
// 手动等价逻辑(示意)
int attempts = 0;
while (attempts < 3)
{
    try
    {
        return await http.GetAsync(url, ct);
    }
    catch (HttpRequestException) when (attempts++ < 2)
    {
        await Task.Delay(TimeSpan.FromMilliseconds(200 * (1 << attempts)));
    }
}
参数建议
MaxRetryAttempts2~3 次,别无限重试
BackoffTypeExponential 指数退避
UseJittertrue,加抖动
ShouldHandle只处理瞬时故障
幂等性重试前确认请求安全(GET 天然幂等)

避坑: 写操作(POST/PUT)重试前必须确认幂等,否则重试会重复下单。可以在请求头带 Idempotency-Key,服务端据此去重。重试次数太多会叠加延迟——3 次指数退避就可能让用户等待数秒。

4. 熔断与超时

一句话总结: 重试管「瞬时的失败」,熔断管「持续的故障」——失败率达到阈值就快速失败,给下游恢复时间;超时管「永不返回的请求」。

熔断器(Circuit Breaker)三个状态:Closed 正常放行 → Open 故障率高时拒绝请求 → Half-Open 过一段时间放一个探测请求,成功则复位。这避免了重试在持续故障下雪上加霜。超时(Timeout)给每个请求一个硬上限,防止连接池被慢请求占满。

builder.Services.AddHttpClient("search", client =>
{
    client.Timeout = TimeSpan.FromSeconds(5); // 全局超时
}).AddResilienceHandler("cb-pipeline", pipeline =>
{
    pipeline.AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = 2,
        BackoffType = DelayBackoffType.Exponential
    });

    pipeline.AddCircuitBreaker(new CircuitBreakerStrategyOptions
    {
        FailureRatio = 0.5,          // 失败率超 50%
        MinimumThroughput = 10,      // 至少 10 次请求才统计
        SamplingDuration = TimeSpan.FromSeconds(10),
        BreakDuration = TimeSpan.FromSeconds(15), // 熔断 15 秒
        ShouldHandle = args => args.Outcome.Result?.StatusCode >= 500
                                   ? PredicateResult.True()
                                   : PredicateResult.False()
    });

    pipeline.AddTimeout(TimeSpan.FromSeconds(3));
});
模式状态作用
重试—瞬时故障自愈
熔断Closed/Open/Half-Open持续故障快速失败
超时每请求上限防止慢请求拖垮线程池
舱壁独立连接池隔离下游故障

避坑: 熔断阈值设置要看基线流量:最低吞吐设太高,小流量下永远触发不了熔断;设太低又会误判。熔断打开时应让调用方收到快速失败的明确信号,而不是默默空转。

5. 负载均衡与故障转移

一句话总结: 多实例下游需要负载均衡把请求分散到健康实例,故障转移在实例不可用时切换到其他实例,二者组合保证可用性。

IHttpClientFactory 的命名客户端天然支持给一个逻辑名配置多个基址。结合 HttpClientHandler 或自定义 DelegatingHandler,可以按轮询或随机策略选目标,并在失败后重试到下一个实例。

// 多个下游实例:轮询分发
builder.Services.AddHttpClient("payments")
    .AddHttpMessageHandler(() => new RoundRobinHandler(
        ["https://payments-1.internal", "https://payments-2.internal"]));

public class RoundRobinHandler(string[] urls) : DelegatingHandler
{
    private int _index;

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var i = Interlocked.Increment(ref _index);
        request.RequestUri = new Uri(new Uri(urls[i % urls.Length]), request.RequestUri!.PathAndQuery);
        return await base.SendAsync(request, ct);
    }
}

// 故障转移:结合重试,失败后换下一个实例
// 配合 Polly 的 ShouldHandle,5xx 时重试且 RoundRobin 已切换到下一实例
策略实现场景
轮询Interlocked 计数器均匀分布
随机Random 选下标避免惊群
权重轮询按容量加权异构实例
一致性哈希请求特征取模会话粘性

避坑: 负载均衡 + 重试要小心重试放大:每个实例都试一遍,最终请求数变成 N 倍。给整体重试设上限,并让熔断器在「所有实例都失败」时快速失败。实例列表的增删应由服务发现(如 Consul/Service Discovery)驱动,别写死。

6. 日志与性能监控

一句话总结: HTTP 客户端要记录「谁调谁、耗时、状态码、重试次数」,用结构化日志与指标把失败与慢调用变成可观测信号。

默认的 AddHttpClient 不产生详细日志,需要开启请求日志。Microsoft.Extensions.Http 内置日志处理器会输出请求/响应摘要;配合 ILogger 结构化字段,可以按 traceId、目标主机聚合耗时与错误率。

builder.Services.AddHttpClient("billing")
    .AddHttpMessageHandler(() => new TimingHandler());

public class TimingHandler(ILogger<TimingHandler> logger) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var sw = Stopwatch.StartNew();
        var response = await base.SendAsync(request, ct);
        sw.Stop();

        logger.LogInformation(
            "HTTP {Method} {Path} -> {StatusCode} in {ElapsedMs}ms (attempts hidden)",
            request.Method, request.RequestUri?.PathAndQuery,
            (int)response.StatusCode, sw.ElapsedMilliseconds);

        return response;
    }
}

// 打开内置 HTTP 日志(appsettings.json)
// "Logging": { "LogLevel": { "System.Net.Http.HttpClient": "Information" } }
指标采集方式
请求耗时Stopwatch + 结构化日志
状态码分布计数器(Prometheus 等)
重试/熔断次数Polly 内置 Telemetry
下游错误率按目标主机聚合

避坑: 日志里不要打完整请求体——可能包含敏感数据。记录 traceId、目标、状态码、耗时即可。日志级别别设成 Debug 打满生产,用采样或只记录慢请求(如 > 1s)与失败请求。

7. 弹性编排与真实场景

一句话总结: 把重试、熔断、超时、舱壁组合成「弹性管线」,按业务价值为不同下游分配不同策略,避免一刀切。

真实系统里不同下游的故障特征不同:内部订单服务瞬时抖动多,可以多重试;第三方支付服务失败成本高,应快速失败并告警。Polly 的 ResiliencePipeline 允许把多个策略按顺序编排进一条管线。

builder.Services.AddHttpClient("orders").AddResilienceHandler("orders-pipeline", p =>
{
    // 顺序:重试 → 熔断 → 超时
    p.AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true
    });

    p.AddCircuitBreaker(new CircuitBreakerStrategyOptions
    {
        FailureRatio = 0.3,
        MinimumThroughput = 20,
        SamplingDuration = TimeSpan.FromSeconds(30),
        BreakDuration = TimeSpan.FromSeconds(10)
    });

    p.AddTimeout(TimeSpan.FromSeconds(8));
});

// 针对第三方支付:少重试、快失败
builder.Services.AddHttpClient("payment").AddResilienceHandler("payment-pipeline", p =>
{
    p.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 1, Delay = TimeSpan.FromMilliseconds(100) });
    p.AddTimeout(TimeSpan.FromSeconds(3));
});
下游策略组合
内部订单3 次重试 + 熔断 + 8s 超时
第三方支付1 次重试 + 3s 超时 + 立即告警
批量导入指数退避重试,容忍更长等待
实时推荐超时 1s,失败直接降级为缓存结果

避坑: 弹性策略要分环境调参:压测环境把阈值调低验证熔断路径,生产环境根据基线流量设置。策略组合的先后顺序影响行为——重试放在熔断前,可以让瞬时故障不触发熔断;超时放最后兜底所有策略的执行时间。

8. 总结

主题要点
生命周期用 IHttpClientFactory,别每次 new 也别静态单例
命名/类型化客户端类型化客户端封装下游,测试友好
重试指数退避 + 抖动,只重试瞬时故障
熔断持续故障快速失败,Half-Open 探测恢复
超时给每个请求硬上限
负载均衡轮询/随机多实例,故障转移到健康实例
监控结构化日志记录耗时与状态码,采集错误率

HTTP 客户端的弹性不是「加个重试」就完事,而是分层设计:超时防止单请求失控,重试吸收瞬时抖动,熔断遏制持续故障,负载均衡分散单点压力,监控让每层都可观测。把这些策略放进 IHttpClientFactory 的处理器管线里,业务代码保持纯净,弹性能力集中治理——这正是现代 .NET 微服务调用的标准形态。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排