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

来源:这里教程网 时间:2026-02-21 17:39:05 作者:

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

高并发时,下游服务响应变慢或超时增多,Polly 的熔断策略会快速累积失败计数,一旦达到

FailureThreshold
就跳闸。此时所有新请求都会立即抛出
BrokenCircuitException
,连重试机会都没有。

关键点在于:熔断器默认不区分异常类型,

HttpRequestException
TimeoutException
都算失败;但像 404、401 这类业务错误不该触发熔断。

HandleResult<t>()</t>
+
HttpStatusCode
判断显式排除非致命 HTTP 状态码
SamplingDuration
设得稍长(比如 30 秒),避免短时间毛刺导致误熔断
MinimumThroughput
建议设为 20+,防止低流量下因偶然失败就开闸
启用
AutomaticTransition
,让熔断器在
HalfOpen
状态自动试探,而不是靠定时器硬切
var circuitBreaker = Policy.HandleResult<HttpResponseMessage>(
        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<HttpRequestException>()
    .Or<TimeoutException>()
    .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<string>
    .Handle<Exception>()
    .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 突增时偶然暴露。

相关推荐