ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#弹性策略实战:Polly重试、熔断、超时与隔离解析

C#弹性策略实战:Polly重试、熔断、超时与隔离解析 做后端和上位机开发的兄弟应该都有过这种体验某个接口天天好好的突然某天线上日志开始疯狂报HttpRequestException后台一整片红不是超时就是连接被重置。你第一反应是去查代码可Code Review半天发现业务逻辑压根没毛病最后只能眼巴巴等它自己恢复。这种问题不是业务bug而是典型的瞬态故障。C#生态里Polly就是专门用来对付这类问题的老牌类库它把重试、熔断、超时、隔离、兜底这些策略统一收敛起来让业务代码不用自己去折腾那堆try-catch和Thread.Sleep。这篇文章我会从核心原理讲到实操落地再把实际项目中踩过的坑和排查心得整理一遍适合刚开始接触Polly的C#开发者也适合已经在用但想系统梳理一遍的老手。1. 既然有try-catch为什么还需要Polly1.1 瞬态故障的杀伤力先别急着写代码先想清楚一个问题你的服务去调用另一个服务对方只是在那几秒暂时连不上你的代码应该怎么反应如果直接把异常抛给用户用户可能在支付页面上看着接口报错然后疯狂点刷新。如果完全不管线上监控持续报错运维大半夜被叫起来。成熟的做法是在网络调用外层加一套自动恢复机制让系统自己扛过这几秒的抖动而不是把问题立刻暴露给终端用户。很多人第一反应是我手动写个重试不就行了吗类似下面这样for (int i 1; i 3; i) { try { return await httpClient.PostAsync(url, content); } catch (HttpRequestException) { if (i 3) { throw; } Thread.Sleep(1000); } }这段代码看上去能跑但问题很明显重试逻辑和业务代码搅在一起换一个接口调用就要复制粘贴一遍等你想改成指数退避或者加上熔断代码量直接失控更关键的是你很难在多个接口之间统一重试次数、退避策略和日志埋点。每个开发写出来的重试可能都不一样线上行为不可预期。Polly就是针对这个痛点设计的。它把重试、熔断、超时这些弹性策略从业务代码里剥离出来让策略成为可复用、可组合、可测试的独立单元。1.2 Polly的核心模型策略包裹委托Polly的核心思想用一句话概括先定义策略再用策略包住要执行的委托。什么叫“包住委托”看个最朴素的例子var retryPolicy Policy .HandleHttpRequestException() .RetryAsync(3); await retryPolicy.ExecuteAsync(() httpClient.PostAsync(url, content));执行的时候你真正想做的业务动作只是httpClient.PostAsync那一步但Polly会在调用前后替你处理异常捕获、次数判断、暂停等待这些逻辑。业务代码不感知策略的存在策略也不替业务做任何决定两者职责分得很清楚。可以把它想象成汽车里的安全带和安全气囊。你平时开车只需要握方向盘安全系统在碰撞瞬间自动介入不会影响你的驾驶操作。策略就好比这层安全壳平时不吭声该弹出来的时候自己弹出来。Polly还允许你把多个策略嵌套成一条链路这个能力叫PolicyWrap。比如先做超时控制超时后重试重试耗尽后熔断熔断打开后快速失败。每一层只负责一件事组合起来就是一套完整的服务治理方案。1.3 不只重试Polly策略全景很多初学者以为Polly就是重试库实际上重试只是它的一小块。Polly提供了一整套瞬态故障处理策略大致有这几类策略类型主责典型场景Retry 重试失败后再次尝试网络抖动、临时超时、服务重启窗口CircuitBreaker 熔断连续失败后快速失败保护下游下游服务已经过载继续重试只会更糟Timeout 超时限制调用最大耗时防止慢接口拖垮整个应用Bulkhead 隔离限制并发调用量保护线程池、连接池不被单一下游打爆Fallback 兜底失败后返回替代结果缓存旧数据、默认值、降级响应Cache 缓存减少真实远端调用低频变化数据的短期缓存PolicyWrap 组合把多个策略组合嵌套让不同策略各司其职用的时候要提醒自己Polly不是万金油。有些故障根本不该用重试去掩盖比如数据字段长度超限、业务规则冲突、参数校验失败这类问题重试一百次结果都一样反而会把日志刷得更乱。所以在动手配策略之前先判断一下这个失败是不是瞬态的值得不值得用弹性策略。2. 核心策略逐个拆解与选型建议2.1 Retry重试先想清楚能不能重试Retry是最直观、最常用也最容易滥用的策略。基本配置有三个要素捕获什么异常或结果、重试多少次、每次间隔多久。先看一个带退避的完整配置var retryPolicy Policy .HandleHttpRequestException() .OrResultHttpResponseMessage(r (int)r.StatusCode 500) .WaitAndRetryAsync( retryCount: 3, sleepDurationProvider: attempt TimeSpan.FromSeconds(Math.Pow(2, attempt)), onRetry: (outcome, timespan, ctx) { Console.WriteLine($[重试] 第{ctx[RetryAttempt]}次等待{timespan.TotalMilliseconds}ms); });WaitAndRetryAsync里的sleepDurationProvider接收的是当前第几次重试从1开始。如果每次固定等1秒那第1次重试前等1秒第2次前再等1秒。如果采用指数退避就是第1次等2秒第2次等4秒第3次等8秒这个增速对保护下游非常关键。生产环境里指数退避还经常要加一个随机抖动避免大量客户端在同一时刻一起重试形成所谓的“重试风暴”。加抖动的写法很简答给固定值里塞一个小范围随机数sleepDurationProvider: attempt TimeSpan.FromSeconds(Math.Pow(2, attempt)) TimeSpan.FromMilliseconds(Random.Shared.Next(0, 80))这个随机量不要太大几十毫秒到一百多毫秒足够打散并发重试了。如果你有几千个客户端同时触发重试不加抖动的后果就是下游服务平台反而被重试流量打得更死。但重试最重要的是一个问题操作本身是不是幂等的下单、转账、生成文件这之类写操作很可能是服务端已经处理成功只是响应包在半路丢了客户端没收到。这时候你再重试一次等于让用户下了两次单、转了两笔钱。所以只有接口支持幂等键、或者操作本身天然幂等比如纯查询的时候才适合放心重试。2.2 CircuitBreaker熔断别把下游打死重试和熔断看起来都在处理失败其实目的完全相反。重试是“我再试一次”熔断是“我不试了快速失败让你缓缓”。熔断策略可以类比家里的保险丝。电流一旦异常保险丝先跳闸把电路断开避免火灾过一会儿你再合闸看看是彻底坏了还是暂时过载。Polly的熔断器也是这个思路用状态机管理三种状态关闭Closed一切正常请求正常放行。打开Open连续失败次数超标直接快速失败不再发起真实调用。半开HalfOpen熔断时间到了放一小部分试探请求进去看看下游恢复没有。成功了就关闭熔断器失败则重新进入打开状态。配置一个经典熔断策略var circuitBreaker Policy .HandleHttpRequestException() .CircuitBreakerAsync( handledEventsAllowedBeforeBreaking: 5, durationOfBreak: TimeSpan.FromSeconds(30), onBreak: (ex, span) Console.WriteLine($[熔断] 触发断30秒异常{ex.Message}), onReset: () Console.WriteLine([熔断] 已重置为关闭状态), onHalfOpen: () Console.WriteLine([熔断] 半开试探中));这里有个经验教训熔断阈值不能只看连续失败次数要结合流量来看。高QPS服务瞬时失败5次可能只是正常波动低QPS服务连续5次失败可能已经说明下游彻底挂了。所以新版本的Polly在CircuitBreakerStrategyOptions里提供了更科学的参数比如最小采样数量、采样时间窗口和失败比例。后面实操章节我会专门说。熔断器打开期间请求会以极快的速度失败这样下游才有喘息机会。很多团队只配重试不配熔断结果下游一抖动重试流量疯狂涌入反而把下游打得更惨。正确的姿势通常是用熔断管住“要不要继续发起调用”用重试管住“单次调用失败后要不要立即再来一次”。2.3 Timeout超时没有超时的调用会拖垮线程池远程调用不设超时就像借钱不打欠条最后吃亏的是自己。但超时策略在Polly里有两种完全不同的实现用错了代价不小。// 乐观超时依赖CancellationToken协作取消 Policy.TimeoutAsync(10, TimeoutStrategy.Optimistic); // 悲观超时主动终止未完成的任务 Policy.TimeoutAsync(10, TimeoutStrategy.Pessimistic);乐观超时要求你执行的委托能响应CancellationToken也就是说调用链路上要能传播取消信号。它不会额外占用线程去盯着任务超时靠取消令牌机制触发代价低是优先选择。悲观超时则不管你的委托能不能响应取消Polly会自己搞一个任务等在那里到时间直接强制终止。这个能力听起来很爽但它背后要占用额外的线程资源去监管任务并发一高线程池很容易被拖垮。而且被强制终止的任务可能留下未清理的资源比如数据库连接没释放。我见过一些老项目图省事全部用悲观超时结果线上服务线程数飙到离谱最后排查才发现就是这里的问题。我的建议很简单只要你的代码能支持CancellationToken就用乐观超时悲观超时留给那些没法改动的第三方SDK调用。2.4 Bulkhead隔离给线程池上保险Bulkhead隔离翻译过来就是“舱壁”。这个词来自船舶设计船底被分隔成多个独立防水隔舱一个舱进水其他舱还能撑住船不会立刻沉。放在程序里Bulkhead限制的是同时执行的最大并发量以及排队等待的最大数量。比如某个下游接口特别慢如果所有请求都直接堆上去线程池和连接池会被它一个人吃光其他正常接口也跟着遭殃。用Bulkhead把这个慢调用限制在最多12个并发、超过24个排队就直接拒绝其他服务就能幸免。var bulkhead Policy.BulkheadAsync( maxParallelization: 12, maxQueuingActions: 24, onBulkheadRejected: ctx Console.WriteLine([隔离] 排不进去了拒绝新请求));这个策略在BFF或网关层很有用但值得注意的是桌面应用和上位机程序也经常忽略它。比如WinForm主线程被一个慢速PLC通信卡住界面直接假死用户体验很差。如果引入Bulkhead把耗时操作隔离到受控的并发槽位里主线程就能保持响应配合async/await体验会好很多。2.5 Fallback兜底 Cache缓存故障时的最后防线Fallback就是给失败留后手。当重试耗尽、熔断打开或者超时发生Fallback能返回一个替代结果而不是让异常直接捅到用户面前。var fallback Policy .HandleException() .FallbackAsync( fallbackAction: ct Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)), onFallbackAsync: (ex, ct) { Console.WriteLine($[兜底] 本次失败{ex.Exception.Message}); return Task.CompletedTask; });兜底不是让你返回一个“什么都没做”的空壳而是要给用户一个明确且安全的回应。比如返回缓存里的旧数据返回标准的错误结构体或者提示用户稍后再试。重点在于系统不崩溃、调用方不懵。Cache缓存则是在故障发生之前就减少压力。对低频变化的数据做短期内存缓存可以极大减少远端调用量让重试和熔断本身都少有机会触发。Polly的V7版本里Cache策略还需要配合Polly.Caching.Memory使用但说实话生产项目里我一般直接用MemoryCache策略层更方便。如果你只是想让某个慢查询结果少打几次后端直接用IMemoryCache就够了不必为了“用上Polly”硬套Cache。选型建议概括一下不要所有策略全都叠上。通常先上Retry和Timeout观察日志确实有连续下游故障再加CircuitBreaker并发压力大再考虑Bulkhead。Fallback更多是产品层的降级策略不是给每个调用都配的。故障类型推荐策略不推荐的策略偶发网络抖动Retry Timeout无脑重试十几遍下游持续过载CircuitBreaker Fallback只重试不熔断慢接口拖垮线程池Timeout Bulkhead无限期等待业务逻辑错误不处理Retry重试也无用3. 实操在项目里落地Polly3.1 包与版本选择Polly在2023年之后主推的是8.x版本API结构和老版本差别很大。8.x的核心包改成了Polly.Core推荐用ResiliencePipeline代替原来的Policy类所有策略选项用Options类显式配置整体更统一也更适合依赖注入。安装包的命令dotnet add package Polly.Core dotnet add package Microsoft.Extensions.Http.Resilience如果你只是给HttpClient加弹性策略第二个包就够了它内部会依赖Polly.Core。如果项目还在用老的7.x版本网上教程特别多写法也成熟暂时不想升级也没关系老版本依然稳定。但我建议新项目直接按8.x来毕竟老API要维护到什么时候不好说而且8.x对异步支持和依赖注入友好得多。3.2 在ASP.NET Core容器里注册策略假设你要注册一个全局的默认弹性策略给所有下游调用共享。在用依赖注入的项目里典型做法是这样builder.Services .AddResiliencePipeline(default-pipeline, pipelineBuilder { pipelineBuilder .AddRetry(new RetryStrategyOptions { MaxRetryAttempts 3, Delay TimeSpan.FromSeconds(2), BackoffType DelayBackoffType.Exponential, ShouldHandle new PredicateBuilder() .HandleHttpRequestException() .HandleTaskCanceledException() }) .AddCircuitBreaker(new CircuitBreakerStrategyOptions { FailureRatio 0.5, MinimumThroughput 10, SamplingDuration TimeSpan.FromSeconds(30), BreakDuration TimeSpan.FromSeconds(30) }) .AddTimeout(TimeSpan.FromSeconds(10)); });这里我把熔断参数改成了更符合生产节奏的配置30秒采样窗口内最少要有10次请求其中失败比例超过50%熔断器才打开断开30秒。比起“连续失败5次就断开”这种比例加样本量的方式在流量波动时更稳。注册完成以后在业务代码里拿到管线实例再执行var pipelineProvider app.Services.GetRequiredServiceResiliencePipelineProviderstring(); ResiliencePipeline pipeline pipelineProvider.GetPipeline(default-pipeline); var result await pipeline.ExecuteAsync(async token { return await httpClient.GetAsync(url, token); }, cancellationToken);3.3 配合HttpClientFactory使用用HttpClientFactory有一个很舒服的姿势直接在AddHttpClient后面接AddResilienceHandler策略会以DelegatingHandler的方式作用到每次请求上不需要手动调用ExecuteAsync。builder.Services .AddHttpClient(orderApi, client { client.BaseAddress new Uri(https://api.example.com); client.Timeout TimeSpan.FromSeconds(15); }) .AddResilienceHandler(order-pipeline, builder { builder .AddRetry(new RetryStrategyOptions { MaxRetryAttempts 2, Delay TimeSpan.FromSeconds(1), BackoffType DelayBackoffType.Exponential }) .AddTimeout(TimeSpan.FromSeconds(5)); });这样每次使用IHttpClientFactory创建名称为orderApi的客户端时所有发出去的请求都自动套上重试和超时。相比在业务代码里手动拿ResiliencePipeline来执行这个方式更无侵入调用方完全感知不到策略的存在测试和替换策略也更方便。3.4 桌面程序和上位机同样能用Polly不是ASP.NET Core的专属控制台、WinForm、WPF和基于.NET的上位机程序里一样能直接使用。区别只是没有依赖注入容器时你需要自己维护一个静态或单例的管线实例。比如上位机去读一个仪表的扭矩值串口或者网络通信偶发超时很常见。这类读操作可以用短期重试兜住抖动var readPipeline new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { MaxRetryAttempts 2, Delay TimeSpan.FromMilliseconds(300) }) .AddTimeout(TimeSpan.FromSeconds(2)) .Build(); var value await readPipeline.ExecuteAsync(async token { return await device.ReadTorqueAsync(token); }, cancellationToken);但要特别提醒上位机里涉及下发的写操作比如改设备参数、发送控制指令千万不能盲目套重试。一旦设备已经执行了“写入”但你没收到确认再重试等于把同一个指令下了两遍轻则参数重复重则设备状态错乱。这个原则和前面讲的幂等性问题是一回事。3.5 一次完整策略执行的调用细节实际调用时有一个重要细节CancellationToken要一路传给ExecuteAsync让超时和取消能协同工作。否则乐观超时无法拦截未响应Token的调用外部取消信号也传不到委托里。var response await pipeline.ExecuteAsync(async token { var res await httpClient.GetAsync(https://api.example.com/status, token); res.EnsureSuccessStatusCode(); return res; }, cancellationToken);另外别在ExecuteAsync外面再套一个try-catch把所有异常吞掉否则策略是否生效、兜底是否触发、哪里出了问题全都被你抹掉了。正确的做法是保留异常上下文让上层框架或者全局日志处理器去统一观察。4. 常见问题与排查技巧实录4.1 异常类型捕获窄了重试永远不触发这是我最常被问的问题之一。配置看起来没问题日志却显示没有重试。仔细看才发现策略里捕获的是HttpRequestException但实际抛出来的是TaskCanceledException或OperationCanceledException。在HTTP调用场景下超时很多时候会表现为TaskCanceledException因为HttpClient超时底层靠CancellationToken实现。如果你只写了HandleHttpRequestException()超时异常根本不会被策略捕获。用PredicateBuilder把该处理的异常都放进去new PredicateBuilder() .HandleHttpRequestException() .HandleTaskCanceledException() .HandleTimeoutException()如果业务里还有自定义异常也可以继续.HandleMyCustomException()。判断的依据很简单这个异常代表的是不是一种值得重试的瞬态故障。4.2 熔断状态不可见出了问题一脸懵熔断器触发时如果没有日志你只能看到“接口突然全部失败”但不知道是下游真挂了还是你的熔断策略起作用了。所以在配置熔断策略时务必把状态变化事件挂上日志.AddCircuitBreaker(new CircuitBreakerStrategyOptions { OnOpened args Console.WriteLine($[熔断打开] 原因{args.Outcome.Exception?.Message}持续{args.BreakDuration}), OnClosed args Console.WriteLine([熔断关闭] 恢复成功), OnHalfOpened args Console.WriteLine([熔断半开] 开始试探) })能把这些信息推到监控系统最好至少要能在日志里查到。否则故障复盘时连“熔断什么时候触发、持续了多久、有没有自动恢复”都说不清楚那就白上Polly了。4.3 手动重试和Polly策略叠加请求量暴涨有个项目出了事故下游服务已经过载他们为了稳妥在业务代码里写了一个for循环循环里又调用了套了Polly重试的HttpClient。一算最坏情况for循环3次乘上Polly重试3次单次业务操作最多能发出9个真实请求。下游被这波流量直接压垮。排查技巧是在重试的回调日志里加上当前重试次数和调用来源看到RetryAttempt出现了两次不同层级的增长基本就是叠加了。解决办法很简单统一在一个入口用Polly别在外部再手动套循环。4.4 v7和v8混合使用类型对不上很多老项目从7.x往8.x迁移时会同时出现Policy和ResiliencePipeline两种类型。如果只在博客里抄代码很容易出现“Policy转ResiliencePipeline转不过去”的编译错误。要明确一点v7的AsyncPolicy和v8的ResiliencePipeline是两个体系没有直接的互相转换接口。如果你正在用Microsoft.Extensions.Http.Resilience那背后的管线一定是v8体系如果你还在用Polly.Extensions.Http那多半是v7。新老代码共用会让排查很痛苦建议迁移时按模块推进先确认包版本再决定API写法。不要试图“混合使用”。4.5 策略嵌套顺序影响结果别乱WrapPolicyWrap的设计看起来只是简单的组合但顺序不同行为完全不同。举个例子// Fallback在外层Retry在内层 Policy.Wrap(fallback, retry); // Retry在外层Fallback在内层 Policy.Wrap(retry, fallback);外层策略先执行。第一种组合里Retry先把3次机会用完最终抛异常时Fallback才接住返回兜底结果。第二种组合里Fallback先执行直接把异常吞掉返回兜底结果Retry根本看不到异常于是永远不会触发重试。所以组合策略时要先想清楚因果顺序兜底通常在最外层负责兜整个链路的失败熔断和重试放在它里面负责处理中间的瞬时波动超时放在比较靠内最好紧挨着真正执行调用的那一层。顺序排反了策略等于没配。4.6 悲观超时导致线程池被吃满再提醒一次悲观超时的问题。有些第三方SDK不支持CancellationToken你没法用乐观超时于是选择了悲观超时。这在调用量小的时候没事一旦并发上千额外管理任务的线程会把线程池吃满后续请求全部排队等线程。如果实在避免不了悲观超时至少要控制并发量配合Bulkhead限制同时进入的调用数量别让悲观超时的代价被无限放大。5. 写在最后的个人体会最后分享一点我自己这几年的实际体会。Polly这个库本身不复杂API学一遍很快就会真正难的是想清楚自己的业务到底需不需要某个策略、合不合适上重试。我见过最极端的情况是有人把Retry、CircuitBreaker、Timeout、Bulkhead、Fallback全叠上去出问题以后排查了好几天大家先吵是哪一层兜住的。所以我现在的习惯是极简起步先给关键调用配上Retry和Timeout线上日志确认真的存在连续下游故障再把熔断加进去一步一步来。策略不是越多越好就像安全气囊不能替你做驾驶决策。希望这篇能把Polly的原理和操作一次讲透也让你少踩几个我踩过的坑。
返回列表