【服务治理实战】Polly框架下的熔断降级与流量控制策略解析
1. 从“保险丝”到“交通管制”服务治理三板斧的通俗理解大家好我是老张在微服务架构里摸爬滚打了十来年。今天咱们不聊那些高大上的理论就说说当你的系统面对突如其来的流量洪峰或者某个依赖服务突然“摆烂”时你手头能立刻用上的几样“保命”工具。这就像家里电路你得有保险丝熔断出门遇上堵车你得知道绕路或者错峰出行降级和限流。在.NET Core的世界里Polly就是这样一个帮你轻松实现这些“保命”操作的强大库。想象一下你负责一个电商系统用户下单后需要调用支付服务、库存服务和积分服务。某个促销日支付服务因为外部原因响应变得极慢甚至超时。如果放任不管用户的下单请求线程会全部卡在等待支付服务响应上很快耗尽服务器的线程池资源导致整个下单功能乃至整个系统瘫痪。这就是可怕的“雪崩效应”。服务治理的核心目标就是防止这种连锁故障。服务熔断就是那个“保险丝”。当检测到对某个服务的调用失败率比如超时、异常达到一个阈值时Polly的熔断器会“跳闸”在接下来的一段时间内所有对该服务的调用会立即失败不再真正发起网络请求。这给了故障服务喘息恢复的时间也避免了调用方资源的无谓消耗。等过了设定的时间窗口熔断器会进入“半开”状态试探性地放一个请求过去如果成功了就闭合熔断器恢复正常如果失败了就继续保持断开状态。服务降级则是“Plan B”。当服务熔断触发或者系统资源整体紧张时我们不能直接给用户抛一个冷冰冰的错误。这时候就需要有备选方案。比如当积分服务不可用时下单流程可以正常完成只是暂时不给用户增加积分并友好提示“积分服务暂不可用稍后将为您补发”。Polly的降级策略允许你定义一个备用的返回值或执行一个备用的方法。服务限流好比是“交通信号灯”或“高速公路收费站”。你的服务处理能力是有限的每秒最多能处理1000个请求QPS。如果瞬间涌来2000个请求超出的那1000个要么排队等待如果队列未满要么直接被拒绝返回“系统繁忙”以保证系统在最大负载下也能稳定运行不被打垮。Polly的舱壁隔离Bulkhead策略就是用来做这个的它可以限制并发执行的数量和队列等待的数量。把这“三板斧”组合起来用你的微服务系统就具备了基本的弹性能力。下面我就带你用Polly在.NET Core项目里把这些策略实实在在地落地。2. Polly实战入门快速配置熔断与降级光说不练假把式咱们直接上代码。首先在你的ASP.NET Core Web API项目中通过NuGet安装必要的包Install-Package Microsoft.Extensions.Http.Polly这个包提供了与IHttpClientFactory集成的Polly策略扩展用起来非常方便。2.1 基础策略代码搭建假设我们有一个需要调用外部天气服务的场景。我们先在Startup.cs或Program.cs中配置一个具名的HttpClient并为其添加Polly策略。// 在服务容器中注册HttpClient工厂 services.AddHttpClient(); // 注册一个名为“WeatherService”的HttpClient并配置Polly策略 services.AddHttpClient(WeatherService, client { client.BaseAddress new Uri(https://api.weather.example.com/); client.Timeout TimeSpan.FromSeconds(5); // 设置基础超时 }) .AddPolicyHandler(GetFallbackPolicy()) // 添加降级策略 .AddPolicyHandler(GetCircuitBreakerPolicy()) // 添加熔断策略 .AddPolicyHandler(GetRetryPolicy()); // 添加重试策略可选这里我们看到了策略的组合。AddPolicyHandler可以链式调用多个策略会按添加顺序包裹执行就像洋葱一样。接下来我们看看这几个策略函数具体怎么实现。2.2 核心策略详解与代码实现首先是降级策略Fallback。它的作用是当主调用失败时提供一个托底的响应。private static IAsyncPolicyHttpResponseMessage GetFallbackPolicy() { // 定义降级后的响应内容 HttpResponseMessage fallbackResponse new HttpResponseMessage { StatusCode HttpStatusCode.OK, // 即使出错也返回200但内容提示降级 Content new StringContent({\message\: \天气服务暂时不可用请稍后再试。\}, Encoding.UTF8, application/json) }; // 创建降级策略当发生任何异常时执行降级 return PolicyHttpResponseMessage .HandleException() // 处理所有异常 .OrResult(r !r.IsSuccessStatusCode) // 或者处理不成功的HTTP状态码如500, 404等 .FallbackAsync(fallbackResponse, onFallbackAsync: async (outcome, context) { // 这里可以记录日志或执行一些降级时的逻辑 var exception outcome.Exception; var result outcome.Result; _logger.LogWarning($服务降级触发。异常{exception?.Message} 结果状态码{result?.StatusCode}); await Task.CompletedTask; }); }我在这里加了一个小技巧.OrResult(r !r.IsSuccessStatusCode)。很多教程只处理异常但有时候下游服务返回的是HTTP 500错误并没有抛出CLR异常。加上这个条件就能更全面地捕获故障。然后是熔断策略Circuit Breaker。这是防止雪崩的关键。private static IAsyncPolicyHttpResponseMessage GetCircuitBreakerPolicy() { return PolicyHttpResponseMessage .HandleException() .OrResult(r r.StatusCode HttpStatusCode.InternalServerError || r.StatusCode HttpStatusCode.RequestTimeout || r.StatusCode HttpStatusCode.ServiceUnavailable) .CircuitBreakerAsync( handledEventsAllowedBeforeBreaking: 3, // 连续失败3次后熔断 durationOfBreak: TimeSpan.FromSeconds(30), // 熔断持续时间30秒 onBreak: (outcome, breakDelay) { // 熔断器打开时触发 _logger.LogError($熔断器开启将阻断调用30秒。原因{outcome.Exception?.Message ?? outcome.Result.StatusCode.ToString()}); }, onReset: () { // 熔断器关闭时触发 _logger.LogInformation(熔断器重置流量恢复。); }, onHalfOpen: () { // 熔断器半开时触发30秒后尝试放一个请求 _logger.LogInformation(熔断器半开正在试探性请求。); } ); }这里有几个参数很关键handledEventsAllowedBeforeBreaking这个数字别设太小比如1次失败就熔断太敏感了也别太大否则失去保护意义。根据服务特性2-5次是比较常见的范围。durationOfBreak熔断持续时间。太短可能下游还没恢复太长影响用户体验。可以设置一个相对保守的值比如30秒。onHalfOpen这是一个非常重要的状态。熔断时间过后不会一下子全部放开流量而是先进入“半开”状态允许一个试探请求通过。如果成功则关闭熔断器如果失败则再次进入熔断周期。这避免了在服务尚未完全恢复时被流量再次冲垮。最后是重试策略Retry。对于网络抖动等暂时性故障重试可能就解决了。private static IAsyncPolicyHttpResponseMessage GetRetryPolicy() { // 使用指数退避重试等待时间 2^重试次数 秒 return PolicyHttpResponseMessage .HandleException() .OrResult(r r.StatusCode HttpStatusCode.InternalServerError) .WaitAndRetryAsync( retryCount: 2, // 最多重试2次即最多调用3次 sleepDurationProvider: retryAttempt TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // 第1次重试等2秒第2次等4秒 onRetry: (outcome, delay, retryCount, context) { _logger.LogWarning($第{retryCount}次重试等待{delay.TotalSeconds}秒后执行。原因{outcome.Exception?.Message}); } ); }注意重试策略要谨慎使用对于非幂等的操作比如创建订单、支付重试可能导致业务重复。通常只对幂等的GET请求使用重试。另外重试会增加下游服务的负载如果下游已经瘫痪重试只会雪上加霜。因此重试策略通常与熔断策略结合并且放在熔断策略的内层先重试重试都失败了再触发熔断计数。3. 进阶配置化封装与策略组合的艺术把策略硬编码在代码里每次调整参数都要重新编译发布这显然不“优雅”。更好的做法是将其配置化。我在实际项目中通常会创建一个JSON配置文件来管理所有策略参数。3.1 创建配置文件与实体类新建一个pollySettings.json文件{ PollyPolicies: { WeatherService: { TimeoutSeconds: 5, RetryCount: 2, CircuitBreaker: { FailureThreshold: 3, SamplingDurationSeconds: 10, MinimumThroughput: 5, DurationOfBreakSeconds: 30 }, FallbackMessage: 服务暂时不可用请稍后重试。, Bulkhead: { MaxParallelization: 15, MaxQueuingActions: 10 } }, PaymentService: { TimeoutSeconds: 10, RetryCount: 0, // 支付服务不重试 CircuitBreaker: { FailureThreshold: 2, SamplingDurationSeconds: 60, MinimumThroughput: 10, DurationOfBreakSeconds: 60 }, FallbackMessage: 支付通道繁忙建议稍后在我的订单中查看支付状态。 } } }注意看我为不同的服务WeatherService,PaymentService配置了不同的策略。支付服务PaymentService的熔断更敏感失败2次就熔断且绝对不设置重试RetryCount: 0因为支付操作必须保证幂等性由业务自己处理网络层面的重试风险极高。对应的配置实体类public class PollyPolicySettings { public Dictionarystring, ServicePolicyConfig PollyPolicies { get; set; } } public class ServicePolicyConfig { public int TimeoutSeconds { get; set; } public int RetryCount { get; set; } public CircuitBreakerConfig CircuitBreaker { get; set; } public string FallbackMessage { get; set; } public BulkheadConfig Bulkhead { get; set; } } public class CircuitBreakerConfig { public int FailureThreshold { get; set; } // 失败次数阈值 public int SamplingDurationSeconds { get; set; } // 采样时长高级熔断器用 public int MinimumThroughput { get; set; } // 最小吞吐量高级熔断器用 public int DurationOfBreakSeconds { get; set; } // 熔断时长 } public class BulkheadConfig { public int MaxParallelization { get; set; } public int MaxQueuingActions { get; set; } }3.2 构建动态策略工厂接下来我们创建一个策略工厂类根据配置动态生成策略组合。public static class PollyPolicyFactory { public static IAsyncPolicyHttpResponseMessage CreatePolicyBundle(ServicePolicyConfig config, ILogger logger) { if (config null) return Policy.NoOpAsyncHttpResponseMessage(); // 策略执行顺序从外到内依次是 Fallback - CircuitBreaker - Retry - Timeout // 但通常我们按逻辑包裹Timeout - Retry - CircuitBreaker - Fallback // Polly的Handler执行顺序是“先进后出”即最后添加的策略最先执行。 var timeoutPolicy Policy.TimeoutAsyncHttpResponseMessage(TimeSpan.FromSeconds(config.TimeoutSeconds)); var retryPolicy PolicyHttpResponseMessage .HandleException() .OrResult(r !r.IsSuccessStatusCode) .WaitAndRetryAsync(config.RetryCount, retryAttempt TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), onRetry: (outcome, delay, retryCount, ctx) { logger.LogWarning($重试 {retryCount}/{config.RetryCount} 原因{outcome.Exception?.Message}); }); var circuitBreakerPolicy PolicyHttpResponseMessage .HandleException() .OrResult(r r.StatusCode HttpStatusCode.InternalServerError) .AdvancedCircuitBreakerAsync( failureThreshold: config.CircuitBreaker.FailureThreshold / 100.0, // 百分比如0.5代表50% samplingDuration: TimeSpan.FromSeconds(config.CircuitBreaker.SamplingDurationSeconds), minimumThroughput: config.CircuitBreaker.MinimumThroughput, durationOfBreak: TimeSpan.FromSeconds(config.CircuitBreaker.DurationOfBreakSeconds), onBreak: (outcome, state, duration, context) { logger.LogError($熔断器开启状态{state} 持续时间{duration.TotalSeconds}秒); }, onReset: (context) { logger.LogInformation(熔断器重置。); }, onHalfOpen: () { logger.LogInformation(熔断器半开尝试恢复。); } ); var fallbackResponse new HttpResponseMessage(HttpStatusCode.OK) { Content new StringContent(${{\code\:503,\msg\:\{config.FallbackMessage}\}}, Encoding.UTF8, application/json) }; var fallbackPolicy PolicyHttpResponseMessage .HandleException() .OrResult(r !r.IsSuccessStatusCode) .FallbackAsync(fallbackResponse, onFallbackAsync: async (outcome, ctx) { logger.LogWarning($服务降级执行。); await Task.CompletedTask; }); var bulkheadPolicy Policy.BulkheadAsyncHttpResponseMessage( maxParallelization: config.Bulkhead?.MaxParallelization ?? 100, maxQueuingActions: config.Bulkhead?.MaxQueuingActions ?? 50, onBulkheadRejectedAsync: context { logger.LogError($请求被限流策略拒绝已超出最大并发或队列长度。); return Task.CompletedTask; }); // 策略组合Wrap 方法从右到左执行即 fallbackPolicy 包裹 circuitBreakerPolicy再包裹 retryPolicy... // 顺序很重要通常我们希望超时 - 重试 - 熔断 - 降级 - 舱壁舱壁也可以放最外层限制总并发 // 这里我们将舱壁放在最外层因为它限制的是总并发数。 return Policy.WrapAsync(bulkheadPolicy, fallbackPolicy, circuitBreakerPolicy, retryPolicy, timeoutPolicy); } }这里我使用了AdvancedCircuitBreakerAsync它比基础的CircuitBreakerAsync更强大。基础版只关心连续失败次数而高级版关注的是在滑动时间窗口内的失败比例。比如配置“在10秒内至少需要5次调用如果失败率超过50%就熔断”。这更能适应流量波动避免在低流量时段因零星失败就触发熔断。3.3 在DI容器中集成配置化策略最后在Program.cs中读取配置并注册服务var builder WebApplication.CreateBuilder(args); // 加载Polly配置 builder.Configuration.AddJsonFile(pollySettings.json, optional: false, reloadOnChange: true); builder.Services.ConfigurePollyPolicySettings(builder.Configuration.GetSection(PollyPolicies)); // 注册一个通用的策略提供器 builder.Services.AddSingletonIPollyPolicyProvider, ConfiguredPollyPolicyProvider(); // 注册HttpClient并应用策略 builder.Services.AddHttpClient(WeatherService, client { client.BaseAddress new Uri(https://api.weather.example.com/); }) .AddPolicyHandler((services, request) { var provider services.GetRequiredServiceIPollyPolicyProvider(); return provider.GetPolicyForService(WeatherService); }); builder.Services.AddHttpClient(PaymentService, client { client.BaseAddress new Uri(https://api.payment.example.com/); }) .AddPolicyHandler((services, request) { var provider services.GetRequiredServiceIPollyPolicyProvider(); return provider.GetPolicyForService(PaymentService); });IPollyPolicyProvider是一个自定义接口其实现类ConfiguredPollyPolicyProvider会从IOptionsPollyPolicySettings中读取配置并调用上面的PollyPolicyFactory.CreatePolicyBundle来生成策略。这样我们就实现了完全配置化的、可针对不同服务精细化调整的Polly策略管理。4. 流量控制实战错峰、限流与削峰除了针对单个服务调用的弹性策略在面对全局性的高并发场景如秒杀、抢购时我们还需要从流量入口进行控制。这通常被称为“流量整形”目标是把一道剧烈的脉冲流量整形成一道平缓的河流让系统能够从容处理。4.1 客户端错峰策略错峰的核心思想是让请求不要在同一时刻到达。对于客户端比如手机APP或浏览器我们可以在发起请求时加入一个随机延迟。public class PeakShiftingHttpMessageHandler : DelegatingHandler { private readonly int _maxDelayMilliseconds; private readonly Random _random new Random(); public PeakShiftingHttpMessageHandler(int maxDelayMilliseconds) { _maxDelayMilliseconds maxDelayMilliseconds; } protected override async TaskHttpResponseMessage SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // 生成一个 0 到 _maxDelayMilliseconds 之间的随机延迟 int delay _random.Next(0, _maxDelayMilliseconds 1); if (delay 0) { await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } // 延迟结束后继续执行实际的HTTP请求 return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); } } // 使用方式在注册HttpClient时添加这个Handler services.AddHttpClient(FlashSaleService) .AddHttpMessageHandler(() new PeakShiftingHttpMessageHandler(maxDelayMilliseconds: 3000)) // 最大随机延迟3秒 .AddPolicyHandler(/* 其他Polly策略 */);这个简单的Handler会在每次请求前随机睡眠一段时间。假设有1万个客户端同时触发请求如果最大延迟设为3秒那么这1万个请求就会均匀地分布在0到3秒内到达服务端瞬间QPS从1万降到了约3333。这能极大地缓解服务端的瞬时压力。但要注意这牺牲了部分用户的响应速度需要根据业务场景权衡。4.2 服务端限流与削峰策略服务端除了使用Polly的Bulkhead进行并发限制更常见的做法是使用令牌桶或漏桶算法进行限流。这里我们可以结合Polly和内存缓存如IMemoryCache或分布式缓存如IDistributedCache实现一个简单的限流中间件。// 一个基于内存缓存实现的简单令牌桶限流中间件 public class RateLimitingMiddleware { private readonly RequestDelegate _next; private readonly IMemoryCache _cache; private readonly int _maxRequestsPerMinute; private readonly string _policyName; public RateLimitingMiddleware(RequestDelegate next, IMemoryCache cache, string policyName, int maxRequests) { _next next; _cache cache; _maxRequestsPerMinute maxRequests; _policyName policyName; } public async Task InvokeAsync(HttpContext context) { var clientIp context.Connection.RemoteIpAddress?.ToString(); var endpoint context.Request.Path; var cacheKey $rate_limit:{_policyName}:{clientIp}:{endpoint}; var requestLog _cache.GetOrCreateListDateTime(cacheKey, entry { entry.AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(2); return new ListDateTime(); }); // 清理一分钟前的记录 requestLog.RemoveAll(t t DateTime.UtcNow.AddMinutes(-1)); if (requestLog.Count _maxRequestsPerMinute) { context.Response.StatusCode StatusCodes.Status429TooManyRequests; await context.Response.WriteAsync(请求过于频繁请稍后再试。); return; } // 记录本次请求时间 requestLog.Add(DateTime.UtcNow); _cache.Set(cacheKey, requestLog, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(2) }); await _next(context); } } // 在Program.cs中使用 app.UseMiddlewareRateLimitingMiddleware(GlobalAPI, 100); // 全局API每分钟最多100次请求对于更复杂的场景比如秒杀我们可以采用“概率请求”和“公平性队列”结合的方式削峰。概率请求在客户端不是每个用户的每次点击都直接发起抢购请求而是按照一个概率比如10%决定是否真正发起请求。这能直接砍掉90%的无效流量。但单纯的概率不公平可能让某些用户永远抢不到。公平性队列服务端收到请求后不直接处理业务而是将请求放入一个消息队列如RabbitMQ、Kafka。服务端的工作线程以恒定的、系统能承受的速度从队列中消费请求进行处理。这样无论入口流量多大处理速度都是平稳的。超额的请求会在队列中排队等待如果队列满了后续请求直接返回“活动太火爆请稍候”。这就是典型的“削峰填谷”。我们可以用Polly来配合实现客户端的概率请求public class ProbabilisticRequestPolicy { private readonly double _requestProbability; // 请求概率0.1 代表10% private readonly Random _random new Random(); public ProbabilisticRequestPolicy(double probability) { _requestProbability probability; } public async TaskHttpResponseMessage ExecuteAsync(FuncTaskHttpResponseMessage action) { // 生成一个0-1之间的随机数 if (_random.NextDouble() _requestProbability) { // 未中签直接返回“未命中”的降级响应 return new HttpResponseMessage(HttpStatusCode.OK) { Content new StringContent({\code\: 200, \msg\: \请求已接收正在排队处理...\}, Encoding.UTF8, application/json) }; } // 中签执行真正的请求 return await action().ConfigureAwait(false); } } // 在Controller或Service中使用 public class FlashSaleService { private readonly IHttpClientFactory _httpClientFactory; private readonly ProbabilisticRequestPolicy _probabilityPolicy; public FlashSaleService(IHttpClientFactory httpClientFactory) { _httpClientFactory httpClientFactory; _probabilityPolicy new ProbabilisticRequestPolicy(0.1); // 10%的中签率 } public async Taskstring TrySeckillAsync(string productId) { var result await _probabilityPolicy.ExecuteAsync(async () { var client _httpClientFactory.CreateClient(SeckillService); var response await client.PostAsync($api/seckill/{productId}, null); return response; }); return await result.Content.ReadAsStringAsync(); } }在实际的秒杀系统中这个概率请求策略通常会结合服务端下发的令牌或活动状态来动态调整形成一个完整的从客户端到服务端的流量控制体系。Polly在这里的角色更偏向于在服务间调用层面提供弹性和隔离而全局的流量整形则需要架构层面的设计两者相辅相成共同保障系统在高并发下的稳定运行。