找回密码
 立即注册
首页 业界区 业界 ASP.NET Core 外部依赖调用治理实战:HttpClientFactory ...

ASP.NET Core 外部依赖调用治理实战:HttpClientFactory、Polly 与幂等边界

訾懵 1 小时前
订单服务最容易出现的稳定性问题,不是业务代码写错,而是下游支付、库存、短信网关一抖,整个接口成功率跟着雪崩。看起来只是一次超时,实际上会引发重试风暴、线程池占满、数据库回写积压。
今天我们讨论一个问题:如何把外部依赖调用链路收敛到可控、可观测、可恢复的状态。
1. 问题背景:服务没挂,为什么成功率先掉

线上经常出现这种现象:

  • API 进程还活着,CPU 占用也不高,可能只有40%(举个例子,非真实数据)。
  • 请求延迟从 200ms 拉到 6s。
  • 失败率增多。
这类故障通常不是单点问题,大概就是遇到下面这些因素了:

  • HttpClient 使用方式不当。
  • 超时和重试没有预算控制,单次请求被放大成多次慢调用。
  • 对写操作直接重试却没有幂等约束,最终引发重复扣款或重复下单。
对于有多年经验的开发者来说,这不是 API 会不会调的问题,而是如何在高并发场景下把失败控制在局部,不让故障扩散到整条链路。
2. 原理解析:连接、超时、重试和幂等为什么必须一起设计

2.1 连接管理决定了你能扛多久

每次请求都新建 HttpClient,会导致连接池无法稳定复用,遇到峰值时容易把端口和连接资源打爆。更隐蔽的问题是连接存活太久导致 DNS 变更不生效,流量继续打到旧节点。
IHttpClientFactory 的价值不是语法糖,而是把连接池生命周期交给 SocketsHttpHandler 管理。常见配置里至少要有:

  • PooledConnectionLifetime:定期轮换连接,避免长期粘住旧地址。
  • MaxConnectionsPerServer:控制单机并发上限,避免瞬时过载。
2.2 超时预算必须先于重试策略

很多团队会先配“重试 3 次”,但没配总预算。结果是每次重试都等满超时,最后一个请求占用十几秒。
更稳妥的做法是分两层:

  • 每次尝试超时(per-try timeout):避免单次卡死。
  • 整体调用超时(overall timeout):限制整次业务调用的总耗时。
在 Resilience Pipeline 里,这两层要通过策略顺序明确表达:

  • outer timeout 放在 retry 外层,控制整次调用预算。
  • inner timeout 放在 retry 内层,控制单次 attempt。
先定预算,再谈重试次数,否则重试是放大器,不是保护器。
2.3 遵循行业共识,重试只能处理瞬时故障,不能处理业务冲突

可重试的典型对象是:网络抖动、连接中断、429、部分 5xx。不可重试的是:参数错误、鉴权失败、业务规则冲突。
如果把所有非 200 都重试,会把本来可快速失败的请求拖成长尾,最终压垮线程池和连接池。
2.4 写操作重试前必须定义幂等边界

对 POST/PUT 这类有副作用的请求,重试不是默认安全动作。支付创建、库存扣减、优惠券核销这类写操作,必须先定义幂等键和幂等存储,再启用自动重试。
简单说:

  • 没有幂等键,重试可能制造重复业务。
  • 有幂等键但没有唯一约束,仍然可能并发写穿。
3. 示例代码:从无边界调用到可恢复的稳定调用

3.1 问题写法:每次 new HttpClient + 无条件重试
  1. public sealed class PaymentGatewayCaller
  2. {
  3.     public async Task<string> CreatePaymentAsync(PaymentRequest request, CancellationToken ct)
  4.     {
  5.         using var client = new HttpClient();
  6.         client.Timeout = TimeSpan.FromSeconds(30);
  7.         for (var attempt = 0; attempt < 3; attempt++)
  8.         {
  9.             var response = await client.PostAsJsonAsync(
  10.                 "https://payment.example.com/api/payments",
  11.                 request,
  12.                 ct);
  13.             if (response.IsSuccessStatusCode)
  14.             {
  15.                 return await response.Content.ReadAsStringAsync(ct);
  16.             }
  17.         }
  18.         throw new InvalidOperationException("payment call failed after retries");
  19.     }
  20. }
复制代码
这个写法的问题很集中:

  • 连接管理不可控。
  • 所有失败都重试。
  • 写操作没有幂等键。
  • 30 秒超时会在重试后放大为更长长尾。
3.2 优化写法:Typed Client + Resilience Pipeline

先安装包:
  1. dotnet add package Microsoft.Extensions.Http.Resilience
复制代码
然后在 Program.cs 里集中配置客户端和韧性策略:
  1. using System.Net;
  2. using Microsoft.Extensions.Http.Resilience;
  3. using Polly;
  4. using Polly.Retry;
  5. using Polly.Timeout;
  6. builder.Services
  7.     .AddHttpClient<PaymentGatewayClient>(client =>
  8.     {
  9.         client.BaseAddress = new Uri("http://localhost:5001/");
  10.         client.DefaultRequestHeaders.UserAgent.ParseAdd("order-service/1.0");
  11.     })
  12.     .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
  13.     {
  14.         // 设置 DNS 重新解析间隔时间和对下游单个服务的最大并发
  15.         PooledConnectionLifetime = TimeSpan.FromMinutes(5),
  16.         MaxConnectionsPerServer = 200
  17.     })
  18.     .AddResilienceHandler("payment-pipeline", static pipeline =>
  19.     {
  20.         // Outer timeout: 限制一次完整调用(包含重试)的总预算
  21.         pipeline.AddTimeout(TimeSpan.FromSeconds(6));
  22.         pipeline.AddRetry(new HttpRetryStrategyOptions
  23.         {
  24.             MaxRetryAttempts = 3,
  25.             BackoffType = DelayBackoffType.Exponential,
  26.             Delay = TimeSpan.FromMilliseconds(200),
  27.             UseJitter = true,
  28.             ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
  29.                 .Handle<HttpRequestException>()
  30.                 //.Handle<TimeoutRejectedException>()
  31.                 .HandleResult(response =>
  32.                     response.StatusCode == HttpStatusCode.RequestTimeout ||
  33.                     response.StatusCode == HttpStatusCode.TooManyRequests ||
  34.                     (int)response.StatusCode >= 500)
  35.         });
  36.         // Inner timeout: 限制单次 attempt
  37.         pipeline.AddTimeout(TimeSpan.FromSeconds(2));
  38.         pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
  39.         {
  40.             FailureRatio = 0.5,
  41.             SamplingDuration = TimeSpan.FromSeconds(30),
  42.             // 示例值,需结合实际 QPS 调整;阈值过高会导致低流量服务不触发评估
  43.             MinimumThroughput = 8,
  44.             BreakDuration = TimeSpan.FromSeconds(15)
  45.         });
  46.     });
复制代码
MinimumThroughput 没有通用固定值。低流量服务如果 30 秒内都达不到阈值,熔断器就不会触发。上线前建议按真实 QPS 压测后再定值。
另外,pipeline 超时会抛 TimeoutRejectedException,而不是 OperationCanceledException。调用方如果要区分“主动取消”和“超时熔断”,需要分别处理两种异常。
  1. try
  2. {
  3.     var result = await _paymentGatewayClient.CreatePaymentAsync(request, idempotencyKey, ct);
  4.     return Results.Ok(result);
  5. }
  6. catch (TimeoutRejectedException)
  7. {
  8.     return Results.StatusCode(StatusCodes.Status504GatewayTimeout);
  9. }
  10. catch (OperationCanceledException) when (ct.IsCancellationRequested)
  11. {
  12.     return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
  13. }
复制代码
调用端补充幂等键校验和请求头注入:
  1. using System.Net.Http.Json;
  2. using System.Text.RegularExpressions;
  3. public sealed class PaymentGatewayClient
  4. {
  5.     private readonly HttpClient _httpClient;
  6.     private static readonly Regex IdempotencyKeyPattern =
  7.         new("^[A-Za-z0-9_-]{16,64}$", RegexOptions.Compiled);
  8.     public PaymentGatewayClient(HttpClient httpClient)
  9.     {
  10.         _httpClient = httpClient;
  11.     }
  12.     public async Task<PaymentGatewayResponse> CreatePaymentAsync(
  13.         PaymentGatewayRequest request,
  14.         string idempotencyKey,
  15.         CancellationToken ct)
  16.     {
  17.         if (string.IsNullOrWhiteSpace(idempotencyKey) ||
  18.             !IdempotencyKeyPattern.IsMatch(idempotencyKey))
  19.         {
  20.             throw new ArgumentException("idempotency key is invalid", nameof(idempotencyKey));
  21.         }
  22.         using var message = new HttpRequestMessage(HttpMethod.Post, "api/payments")
  23.         {
  24.             Content = JsonContent.Create(request)
  25.         };
  26.         message.Headers.Add("Idempotency-Key", idempotencyKey);
  27.         using var response = await _httpClient.SendAsync(
  28.             message,
  29.             HttpCompletionOption.ResponseHeadersRead,
  30.             ct);
  31.         response.EnsureSuccessStatusCode();
  32.         var payload = await response.Content.ReadFromJsonAsync<PaymentGatewayResponse>(cancellationToken: ct);
  33.         if (payload is null)
  34.         {
  35.             throw new InvalidOperationException("gateway response is empty");
  36.         }
  37.         return payload;
  38.     }
  39. }
复制代码
3.3 幂等落地

理想情况下,幂等性确实应该在被调用方里做。网关自己维护幂等记录,调用方只需要带着 Idempotency-Key 重试,网关保证同一个 key 只处理一次,调用方不需要关心细节。下面是被调用方一个最小可用的幂等方案,调用方视角的幂等设计,我懒得做了。。。
  1. app.MapPost("/api/payments", async (HttpRequest request) =>
  2. {
  3.     var idempotencyKey = request.Headers["Idempotency-Key"].FirstOrDefault();
  4.     if (string.IsNullOrWhiteSpace(idempotencyKey))
  5.     {
  6.         return Results.BadRequest(new { error = "Idempotency-Key header is required" });
  7.     }
  8.     Console.WriteLine($"[FakeGateway] key={idempotencyKey} behavior={behavior}");
  9.     // 幂等检查:已有结果直接返回,不再执行业务逻辑
  10.     if (idempotencyStore.TryGetValue(idempotencyKey, out var cached))
  11.     {
  12.         Console.WriteLine($"[FakeGateway] key={idempotencyKey} → returning cached result");
  13.         return Results.Ok(cached);
  14.     }
  15.     // 模拟行为
  16.     switch (behavior)
  17.     {
  18.         case "timeout":
  19.             await Task.Delay(TimeSpan.FromSeconds(30));
  20.             break;
  21.         case "error500":
  22.             return Results.StatusCode(500);
  23.         case "toomany":
  24.             return Results.StatusCode(429);
  25.     }
  26.     var result = new
  27.     {
  28.         paymentId = Guid.NewGuid().ToString("N"),
  29.         status = "created",
  30.         idempotencyKey
  31.     };
  32.     // 存入幂等记录,后续相同 key 直接命中缓存
  33.     idempotencyStore[idempotencyKey] = result;
  34.     return Results.Ok(result);
  35. });
复制代码
4. 总结

外部依赖调用治理不是“加个重试”这么简单,它本质上是连接管理、预算控制、失败隔离和幂等边界的组合设计。对高并发系统来说,稳定性提升往往来自这些基础动作,而不是复杂框架。先把预算和幂等做扎实,再去做精细调参,系统抗抖能力会稳定很多。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册