c# Polly 熔断、重试和降级策略在高并发中的应用

Polly策略应按重试→熔断→降级顺序组合,即Policy.WrapAsync(fallback, circuitBreaker, retry),确保重试先执行、熔断监控重试结果、降级兜底最终失败;需统一异常处理谓词、启用日志回调并单独标注Context。

熔断器在高并发下频繁触发,CircuitState 变成 Open 后请求全被拒绝

高并发时,下游服务响应变慢或超时增多,Polly 的熔断策略会快速累积失败计数,一旦达到 FailureThreshold 就跳闸。此时所有新请求都会立即抛出 BrokenCircuitException,连重试机会都没有。

关键点在于:熔断器默认不区分异常类型,HttpRequestExceptionTimeoutException 都算失败;但像 404、401 这类业务错误不该触发熔断。

  • HandleResult() + HttpStatusCode 判断显式排除非致命 HTTP 状态码
  • SamplingDuration 设得稍长(比如 30 秒),避免短时间毛刺导致误熔断
  • MinimumThroughput 建议设为 20+,防止低流量下因偶然失败就开闸
  • 启用 AutomaticTransition,让熔断器在 HalfOpen 状态自动试探,而不是靠定时器硬切
var circuitBreaker = Policy.HandleResult(
        r => !r.IsSuccessStatusCode && r.StatusCode is not (HttpStatusCode.NotFound or HttpStatusCode.Unauthorized))
    .CircuitBreakerAsync(
        handledEventsAllowedBeforeBreaking: 10,
        durationOfBreak: TimeSpan.FromMinutes(1),
        samplingDuration: TimeSpan.FromSeconds(30),
        minimumThroughput: 20,
        automaticTransition: true);

重试策略在并发激增时引发雪崩,下游压力反而更大

多个线程/请求同时失败,若都按相同间隔重试(尤其是固定延迟),容易形成“重试风暴”,把本已吃紧的下游彻底压垮。

Polly 默认的 WaitAndRetryAsync 如果没加退避和抖动,就是典型风险点。比如 5 次重试全卡在 100ms,第 2 轮所有请求几乎同时砸过去。

  • 必须用 WaitAndRetryAsync 的指数退避重载,例如 Backoff.DecorrelatedJitterBackoffV2
  • 设置 maxRetryCount ≤ 3,高并发场景下重试次数宁少勿多
  • TimeoutException 和连接级异常优先重试,对 500 错误可考虑降级而非重试
  • 结合 Context 传递请求 ID,在日志里标记是否为重试请求,方便定位放大效应
var retryPolicy = Policy
    .Handle()
    .Or()
    .WaitAndRetryAsync(
        retryCount: 3,
        sleepDurationProvider: (retryAttempt, context) =>
            Backoff.DecorrelatedJitterBackoffV2(
                medianFirstRetryDelay: TimeSpan.FromMilliseconds(100),
                retryCount: 3)[retryAttempt]);

降级逻辑写在 FallbackAsync 里,但实际没生效

常见误区是把降级当成“兜底打印日志”或返回空对象,结果上游调用方没做 null 检查直接 NRE;或者降级函数本身也抛异常,导致 fallback 链路中断。

更隐蔽的问题是:fallback 执行时仍处于原始请求的 CancellationToken 生命周期内,如果原请求已超时,fallback 可能被取消——而你根本没意识到它没跑完。

  • fallback 函数体内必须用 try/catch 包住所有逻辑,尤其涉及 IO 或外部调用
  • 不要在 fallback 里复用原请求的 CancellationToken,改用 CancellationToken.None 或独立超时控制
  • 降级返回值需与主逻辑类型严格一致,避免隐式转换失败;必要时用 ResultSelector 统一包装
  • 对核心接口,降级建议返回缓存快照(如 Redis 中的 GetAsync("fallback:user:123")),而非硬编码默认值
var fallbackPolicy = Policy
    .Handle()
    .FallbackAsync(
        fallbackAction: async (ct) =>
        {
            try
            {
                // 注意:这里用 CancellationToken.None,避免被上游超时干扰
                return await _cache.GetStringAsync("fallback:config", CancellationToken.None) 
                       ?? "default_config";
            }
            catch
            {
                return "fallback_failed"; // 真正的保底
            }
        },
        onFallbackAsync: (ex, ct) => Log.Warning(ex.Exception, "Fallback triggered"));

三种策略组合后执行顺序混乱,熔断器没等重试就提前介入

Polly 策略组合不是简单叠加,而是按 WrapAsync 的嵌套顺序执行:最外层策略最先拦截,最内层最后生效。如果把熔断器包在重试外面,那只要第一次失败就进熔断,重试根本不会发生。

正确顺序永远是:重试 → 熔断 → 降级。即重试策略要最靠近业务调用,熔断器监控重试后的整体成败,降级则兜住整个链路的最终失败。

  • Policy.WrapAsync(retry, circuitBreaker, fallback) 是错的;必须是 Policy.WrapAsync(fallback, circuitBreaker, retry)
  • 所有策略的异常/结果处理谓词必须对齐,比如重试只捕获网络异常,熔断器却统计所有异常,会导

    致状态不一致
  • 调试时开启 onRetry/onBreak/onFallback 回调,打日志确认各策略触发时机和上下文
  • 高并发下建议给每个策略单独配 Context 标签(如 "retry-v1"),避免日志混在一起无法归因

真正容易被忽略的是:当重试策略内部抛出未被捕获的异常(比如自定义策略里忘了 await),整个 wrapper 会直接崩溃,熔断和降级全部失效——这种问题在线上只会在 CPU 突增时偶然暴露。