---
URL: "/zh-CN/blog/2026-04/HttpClientFactory-Polly.html"
LLMS_URL: "/zh-CN/blog/2026-04/HttpClientFactory-Polly.md"
lastUpdated: true
commentabled: true
recommended: true
title: "ASP.NET Core 外部依赖调用治理实战"
description: "HttpClientFactory、Polly 与幂等边界"
date:

pageClass: "blog-page-class"
cover: "/covers/dotnet.svg"
---

---
lastUpdated: true
commentabled: true
recommended: true
title: ASP.NET Core 外部依赖调用治理实战
description: HttpClientFactory、Polly 与幂等边界
date: 2026-04-03 09:29:00 
pageClass: blog-page-class
cover: /covers/dotnet.svg
---

订单服务最容易出现的稳定性问题，不是业务代码写错，而是下游支付、库存、短信网关一抖，整个接口成功率跟着雪崩。看起来只是一次超时，实际上会引发重试风暴、线程池占满、数据库回写积压。

今天我们讨论一个问题：如何把外部依赖调用链路收敛到可控、可观测、可恢复的状态。

## 问题背景：服务没挂，为什么成功率先掉 ##

线上经常出现这种现象：

- API 进程还活着，CPU 占用也不高，可能只有40%（举个例子，非真实数据）。
- 请求延迟从 200ms 拉到 6s。
- 失败率增多。

这类故障通常不是单点问题，大概就是遇到下面这些因素了：

- HttpClient 使用方式不当。
- 超时和重试没有预算控制，单次请求被放大成多次慢调用。
- 对写操作直接重试却没有幂等约束，最终引发重复扣款或重复下单。

对于有多年经验的开发者来说，这不是 API 会不会调的问题，而是如何在高并发场景下把失败控制在局部，不让故障扩散到整条链路。

## 原理解析：连接、超时、重试和幂等为什么必须一起设计 ##

### 连接管理决定了你能扛多久 ###

每次请求都新建 HttpClient，会导致连接池无法稳定复用，遇到峰值时容易把端口和连接资源打爆。更隐蔽的问题是连接存活太久导致 DNS 变更不生效，流量继续打到旧节点。

IHttpClientFactory 的价值不是语法糖，而是把连接池生命周期交给 SocketsHttpHandler 管理。常见配置里至少要有：

- PooledConnectionLifetime：定期轮换连接，避免长期粘住旧地址。
- MaxConnectionsPerServer：控制单机并发上限，避免瞬时过载。

### 超时预算必须先于重试策略 ###

很多团队会先配“重试 3 次”，但没配总预算。结果是每次重试都等满超时，最后一个请求占用十几秒。

更稳妥的做法是分两层：

- 每次尝试超时（per-try timeout）：避免单次卡死。
- 整体调用超时（overall timeout）：限制整次业务调用的总耗时。

在 Resilience Pipeline 里，这两层要通过策略顺序明确表达：

- outer timeout 放在 retry 外层，控制整次调用预算。
- inner timeout 放在 retry 内层，控制单次 attempt。

先定预算，再谈重试次数，否则重试是放大器，不是保护器。

### 遵循行业共识，重试只能处理瞬时故障，不能处理业务冲突 ###

可重试的典型对象是：网络抖动、连接中断、429、部分 5xx。不可重试的是：参数错误、鉴权失败、业务规则冲突。

如果把所有非 200 都重试，会把本来可快速失败的请求拖成长尾，最终压垮线程池和连接池。

### 写操作重试前必须定义幂等边界 ###

对 POST/PUT 这类有副作用的请求，重试不是默认安全动作。支付创建、库存扣减、优惠券核销这类写操作，必须先定义幂等键和幂等存储，再启用自动重试。

简单说：

- 没有幂等键，重试可能制造重复业务。
- 有幂等键但没有唯一约束，仍然可能并发写穿。

## 示例代码：从无边界调用到可恢复的稳定调用 ##

### 问题写法：每次 new HttpClient + 无条件重试 ###

```C#
public sealed class PaymentGatewayCaller
{
    public async Task<string> CreatePaymentAsync(PaymentRequest request, CancellationToken ct)
    {
        using var client = new HttpClient();
        client.Timeout = TimeSpan.FromSeconds(30);

        for (var attempt = 0; attempt < 3; attempt++)
        {
            var response = await client.PostAsJsonAsync(
                "https://payment.example.com/api/payments",
                request,
                ct);

            if (response.IsSuccessStatusCode)
            {
                return await response.Content.ReadAsStringAsync(ct);
            }
        }

        throw new InvalidOperationException("payment call failed after retries");
    }
}
```

这个写法的问题很集中：

- 连接管理不可控。
- 所有失败都重试。
- 写操作没有幂等键。
- 30 秒超时会在重试后放大为更长长尾。

### 优化写法：Typed Client + Resilience Pipeline ###

先安装包：

```bash
dotnet add package Microsoft.Extensions.Http.Resilience
```

然后在 Program.cs 里集中配置客户端和韧性策略：

```C#:Program.cs
using System.Net;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using Polly.Retry;
using Polly.Timeout;

builder.Services
    .AddHttpClient<PaymentGatewayClient>(client =>
    {
        client.BaseAddress = new Uri("http://localhost:5001/");
        client.DefaultRequestHeaders.UserAgent.ParseAdd("order-service/1.0");
    })
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        // 设置 DNS 重新解析间隔时间和对下游单个服务的最大并发
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
        MaxConnectionsPerServer = 200
    })
    .AddResilienceHandler("payment-pipeline", static pipeline =>
    {
        // Outer timeout: 限制一次完整调用（包含重试）的总预算
        pipeline.AddTimeout(TimeSpan.FromSeconds(6));

        pipeline.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Exponential,
            Delay = TimeSpan.FromMilliseconds(200),
            UseJitter = true,
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .Handle<HttpRequestException>()
                //.Handle<TimeoutRejectedException>()
                .HandleResult(response =>
                    response.StatusCode == HttpStatusCode.RequestTimeout ||
                    response.StatusCode == HttpStatusCode.TooManyRequests ||
                    (int)response.StatusCode >= 500)
        });

        // Inner timeout: 限制单次 attempt
        pipeline.AddTimeout(TimeSpan.FromSeconds(2));

        pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,
            SamplingDuration = TimeSpan.FromSeconds(30),
            // 示例值，需结合实际 QPS 调整；阈值过高会导致低流量服务不触发评估
            MinimumThroughput = 8,
            BreakDuration = TimeSpan.FromSeconds(15)
        });
    });
```

MinimumThroughput 没有通用固定值。低流量服务如果 30 秒内都达不到阈值，熔断器就不会触发。上线前建议按真实 QPS 压测后再定值。

另外，pipeline 超时会抛 TimeoutRejectedException，而不是 OperationCanceledException。调用方如果要区分“主动取消”和“超时熔断”，需要分别处理两种异常。

```C#
try
{
    var result = await _paymentGatewayClient.CreatePaymentAsync(request, idempotencyKey, ct);
    return Results.Ok(result);
}
catch (TimeoutRejectedException)
{
    return Results.StatusCode(StatusCodes.Status504GatewayTimeout);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
```

调用端补充幂等键校验和请求头注入：

```C#
using System.Net.Http.Json;
using System.Text.RegularExpressions;

public sealed class PaymentGatewayClient
{
    private readonly HttpClient _httpClient;
    private static readonly Regex IdempotencyKeyPattern =
        new("^[A-Za-z0-9_-]{16,64}$", RegexOptions.Compiled);

    public PaymentGatewayClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<PaymentGatewayResponse> CreatePaymentAsync(
        PaymentGatewayRequest request,
        string idempotencyKey,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(idempotencyKey) ||
            !IdempotencyKeyPattern.IsMatch(idempotencyKey))
        {
            throw new ArgumentException("idempotency key is invalid", nameof(idempotencyKey));
        }

        using var message = new HttpRequestMessage(HttpMethod.Post, "api/payments")
        {
            Content = JsonContent.Create(request)
        };

        message.Headers.Add("Idempotency-Key", idempotencyKey);

        using var response = await _httpClient.SendAsync(
            message,
            HttpCompletionOption.ResponseHeadersRead,
            ct);

        response.EnsureSuccessStatusCode();
        var payload = await response.Content.ReadFromJsonAsync<PaymentGatewayResponse>(cancellationToken: ct);

        if (payload is null)
        {
            throw new InvalidOperationException("gateway response is empty");
        }

        return payload;
    }
}
```

### 幂等落地 ###

理想情况下，幂等性确实应该在被调用方里做。网关自己维护幂等记录，调用方只需要带着 Idempotency-Key 重试，网关保证同一个 key 只处理一次，调用方不需要关心细节。下面是被调用方一个最小可用的幂等方案，调用方视角的幂等设计，我懒得做了。。。

```C#
app.MapPost("/api/payments", async (HttpRequest request) =>
{
    var idempotencyKey = request.Headers["Idempotency-Key"].FirstOrDefault();

    if (string.IsNullOrWhiteSpace(idempotencyKey))
    {
        return Results.BadRequest(new { error = "Idempotency-Key header is required" });
    }

    Console.WriteLine($"[FakeGateway] key={idempotencyKey} behavior={behavior}");

    // 幂等检查：已有结果直接返回，不再执行业务逻辑
    if (idempotencyStore.TryGetValue(idempotencyKey, out var cached))
    {
        Console.WriteLine($"[FakeGateway] key={idempotencyKey} → returning cached result");
        return Results.Ok(cached);
    }

    // 模拟行为
    switch (behavior)
    {
        case "timeout":
            await Task.Delay(TimeSpan.FromSeconds(30));
            break;
        case "error500":
            return Results.StatusCode(500);
        case "toomany":
            return Results.StatusCode(429);
    }

    var result = new
    {
        paymentId = Guid.NewGuid().ToString("N"),
        status = "created",
        idempotencyKey
    };

    // 存入幂等记录，后续相同 key 直接命中缓存
    idempotencyStore[idempotencyKey] = result;

    return Results.Ok(result);
});
```

## 总结 ##

外部依赖调用治理不是“加个重试”这么简单，它本质上是连接管理、预算控制、失败隔离和幂等边界的组合设计。对高并发系统来说，稳定性提升往往来自这些基础动作，而不是复杂框架。先把预算和幂等做扎实，再去做精细调参，系统抗抖能力会稳定很多。
