在分布式系统里,“调用失败”不是异常,而是常态。网络会抖、依赖会慢、对端会短暂挂掉。如果你的代码只会 try/catch 然后把异常往上抛,那一次偶发的 503 就会变成一次真实的业务损失——订单失败、请求超时、用户体验坍塌。
Polly 就是 .NET 生态用来回答这个问题的答案。它是事实标准的韧性(resilience)框架,把“重试、熔断、超时、降级、限流”这些原本散落各处的容错逻辑,抽象成可组合、可配置、可观测的策略积木。
但 Polly 在 v8 做了一次近乎重写的范式升级:从“以 Policy 为中心”迁到“以 ResiliencePipeline 为中心”。这次重构不是换一套 API 那么简单,而是重新定义了“.NET 应用该如何做韧性工程”。这篇文章我们就把 Polly 的前世今生、七大策略、管线组合、可观测性,以及它背后的设计思想一次讲透。
顺带:这篇和《HttpClient 的前世今生》是姊妹篇——HttpClient 负责“怎么把请求发出去”,Polly 负责“请求失败了怎么办”,两者在 .NET 8 通过
Microsoft.Extensions.Http.Resilience最终合流。
一、为什么需要韧性:故障是分布式系统的常态
进入具体策略之前,先建立心智模型。分布式系统的故障大致分三类,每一类需要完全不同的对策:
| 故障类型 | 典型场景 | 正确对策 |
|---|---|---|
| 瞬时故障 (transient) | 网络抖动、短暂 5xx、限流 429 | 重试——大概率自愈 |
| 持续故障 (sustained) | 依赖真的挂了、磁盘满了 | 熔断——停止徒劳重试,保护下游 |
| 自身过载 (overload) | 自己被打爆、连接池耗尽 | 限流 / 舱壁——主动收缩,保住容量 |
如果你对这三类故障用同一招(比如“一律重试三次”),就会在最坏情况下火上浇油:对端已经挂了你还猛重试,把下游彻底压垮,同时耗光自己的线程和连接。
Polly 的价值正在于此:它把不同故障对应的策略做成可组合的积木,让你按需拼装。理解了这一点,下面的 API 都是好理解的“积木说明书”。
边界提示:本文讨论的所有策略都挂在调用方一侧——HttpClient 对自己的出站调用做保护,下游服务器并不知情。所以这是“我调别人时给自己加韧性”,与服务端的入站限流 / 降级是两套东西。
二、前世今生:v7 到 v8 的范式重构
这是理解现代 Polly 最关键的一节。我们对比两段代码。
v6/v7:以 Policy 为中心。
| |
它的特征与局限:
- 同步 / 异步分裂:
Policy与PolicyAsync是两套世界,泛型Policy<T>又是一套,心智负担重。 - 结果与异常用不同 API:
.Handle<>()管异常,.OrResult(...)管返回值,谓词表达割裂。 - 手动
PolicyWrap组合:顺序靠参数位置,缺乏统一的管线抽象。 - 几乎无可观测性:重试了几次、熔断是否打开,全靠你自己打日志,韧性是个“黑盒”。
- 配置与代码耦合:阈值写死在代码里,改配置要重新发布。
v8:以 ResiliencePipeline 为中心。
| |
驱动这次重构的有四股力量:
- 统一:一个
ResiliencePipeline同时支持同步/异步、泛型化,策略即插即用。 - 可观测:内建 metrics(
Pollymeter)与 telemetry,韧性第一次“看得见”。 - 配置驱动:options 对象 + 校验 + 注册表 + 运行时热更新,配置与代码解耦。
- 性能:管线编译一次反复复用,大幅减少每次执行的分配。
所以 v8 不是“换个写法”,而是把韧性从“手写策略”提升为“可运维的工程基础设施”。
三、核心心智:Strategy 与 Pipeline
整个 v8 只有两个核心概念:
- Strategy(策略):单一韧性能力(重试、熔断、超时……),是管线的积木。
- Pipeline(管线):有序组合的策略链,外层策略包裹内层策略。
执行模型是经典的“洋葱”,但比一句话更能帮助理解的是它的代码形态。每个策略本质是这样一个函数(伪代码):
| |
next() 就是“内层那一段”:调用 next = 控制权往内走,next 返回 = 控制权往回走。整条管线是一摞这样的函数嵌套——下行做门禁、上行做决策,真正的判断几乎都在拿到结果之后。
顺手认出这个模式:它就是责任链(Chain of Responsibility)——和《设计模式实战(六)》里讲过的 ASP.NET Core 中间件同源。严格说,Polly 是责任链的“装饰器链”变体:经典 GoF 责任链是“某个 handler 处理掉就结束”的单向链;这里是双向洋葱,每个节点都参与(既预处理又后处理),请求一路下到真正的调用、结果再原路返回穿过所有节点。入站中间件、出站
DelegatingHandler、Polly 管线——同一个模式的三次复用。
以标准顺序 TotalTimeout( Retry( CircuitBreaker( AttemptTimeout( Exec ) ) ) )(顺序的讲究见第五节)为例,看一次完整生命周期:
| |
两个最能帮你“看透”的认知:
- 重试不是“从管顶重来”,而是 Retry 策略在上行阶段重新调用它的内层
next。所以总计时器跨所有重试持续在跑(它在 Retry 外面,只启动一次),而单次计时器每次重试都重新建(它在 Retry 里面)——这正是“总超时在外、单次超时在内”的运行时含义。 - 熔断开路时,它在下行就短路:状态为开时,熔断策略根本不调
next,直接返回失败——下游一个请求都收不到。这就是“保护下游”的运行时含义。
v8 还在底层引入了一个关键抽象:Outcome<T>——用“结果对象”统一表达“成功值 / 异常”。过去异常控制流和返回值检查是两条路,现在统一成一条:策略拿到的是 Outcome<T>,用同一套 ShouldHandle 谓词决定它算不算“需要处理的故障”。这是 v8 一切简洁性的根基。
四、七大韧性策略
这是 Polly 的核心武器库。其中重试、熔断、超时是“三大件”,必须吃透;其余四个按场景补充。
4.1 重试(Retry)——对抗瞬时故障
最常用,也最容易被滥用。
- 退避(backoff):
Constant/Linear/Exponential三种。绝对不要“立即重试”,否则瞬时故障下会形成同步重试风暴。 - 必须加抖动(Jitter):
UseJitter = true。给每次退避叠加一个随机量,避免大量客户端在同一时刻集体重试(“惊群效应”)。 - 精确的谓词:
ShouldHandle决定“什么算可重试”。不是所有异常都该重试——4xx 业务错误重试一百次结果也一样,只会浪费配额。 - 必须有上限:
MaxRetryAttempts必须封顶,否则一次失败被放大成 N 倍流量。 - 幂等性:重试
GET很安全;重试POST必须带幂等键(Idempotency-Key),否则可能产生重复下单。
4.2 熔断(Circuit Breaker)——在持续故障下保护下游
当依赖真的挂了,重试只会火上浇油。熔断器的职责是主动停止调用,给下游喘息。
三态循环:Closed(正常)→ Open(熔断,请求直接快速失败,不真正调用)→ Half-Open(试探性放行少量请求)→ 判定恢复则 Closed、否则重新 Open。
v8 的熔断是高级实现:它基于“采样窗口内的失败率”(FailureRatio + MinimumThroughput + SamplingDuration),而不是简单的“连续失败 N 次”。这更贴近真实流量——偶尔一两次抖动不会误开熔断,但持续的失败率上升会被及时捕捉。
作用域:熔断是“按下游聚合”的,不是单请求、也不是全 App 一个。 这是最容易误解的地方:
- 熔断器是有状态机,状态挂在一条
ResiliencePipeline实例上,被“经过它的所有调用”共享。失败率是跨请求聚合统计的,不是每个请求自己单独算。 - 但粒度是按下游 / 按 pipeline 隔离:你给
OrderApi和InventoryApi各配一套,各有各的熔断器,一个挂了不会误伤另一个。绝不该让全 App 共用一个 client/pipeline。 - 单个请求的重试触发不了熔断:虽然标准顺序下每次重试尝试都会被熔断器记一个采样,但开路有吞吐量门槛(
MinimumThroughput,标准默认要求窗口内至少约 100 次吞吐)+ 失败率门槛(约 10%)。一个请求的几次重试远凑不齐——要开路,得跨请求集体证明“这个下游不行了”。
和重试对照着看,本质就清晰了:
| 重试(Retry) | 熔断(Circuit Breaker) | |
|---|---|---|
| 状态 | 无状态,每次调用自己循环 | 有状态,跨请求共享 |
| 作用域 | 单次调用内部 | 整条 pipeline(按下游) |
| 判断依据 | “这一次”失败 → 再来 | “一段时间内多次调用”的失败率 |
重试是“单兵作战”,熔断是“全队观察”。
哲学点:熔断不只是“保护下游不被打爆”,更是“给上游一个快速失败(fail-fast)”——与其让请求挂 30 秒超时,不如毫秒级返回失败,把容量留给其他能成功的请求。
4.3 超时(Timeout)——给每次调用一个上限
没有超时的重试等于无限挂起。Polly 提供两种模式:
- Optimistic(乐观 / 协作式):通过
CancellationToken通知委托“该取消了”。要求被调方响应取消(HttpClient 就响应),推荐默认。 - Pessimistic(悲观 / 强制式):用
Task.WhenAny强制隔离,即使委托不响应取消也不会无限拖。用于调用无法响应取消的旧代码。
通常配两层:总超时(包住整个重试过程)+ 每次尝试超时(包住单次调用)。两层都不可少。
4.4 对冲(Hedging)——对抗尾延迟
针对 p99 抖动:发出第一个请求,若在 Delay 内没返回,就并行再发一个,谁先回用谁。在 p99 严重的系统里(想想 Jeff Dean 那张“延迟尾部放大”图),对冲能把慢请求的尾部显著拉平。代价是多打流量,只适合幂等或廉价的调用。它对应 HttpClient 侧的 AddStandardHedgingHandler。
4.5 限流(Rate Limiter)——控制自身或下游速率
基于 System.Threading.RateLimiting(令牌桶 / 并发限制器)。客户端侧限流,既是保护下游别被自己打爆,也是保护自己别把下游配额用光。
4.6 舱壁(Bulkhead)——故障隔离与容量保护
限制并发执行数,超出时快速拒绝而非排队堆积。它的核心价值是隔离:把不同依赖放进不同“舱室”,一个慢依赖拖不垮整艘船——避免一个慢下游占满线程池/连接池,导致整个进程假死。
4.7 降级(Fallback)——优雅失败
所有策略都兜不住时,返回一个默认值、缓存或兜底逻辑,让用户看到“降级但可用”的结果,而不是刺眼的 500。这是用户体验的最后一道防线。
五、组合:管线顺序就是语义
策略本身只是积木,怎么排列它们决定了语义。这是 Polly 最容易被用错的地方。.NET 8 标准韧性管线给出的“出厂正确顺序”(由外到内)是:
| |
每一层的位置都有理由:
- 限流/舱壁在最外:在进入任何重试逻辑前先控制并发与速率,防止“重试放大流量”冲垮自己。
- 总超时包住重试:否则 3 次重试 × 每次挂 30 秒,一个请求能拖 90 秒。
- 重试在熔断之外:每次重试尝试都会经过熔断器;一旦熔断打开,重试会立刻拿到 fast-fail,不真正调用下游,而重试自身的失败计数仍能正常累计。
- 每次尝试超时在最内:它只约束单次调用,不约束整个重试链。
对冲变体(AddStandardHedgingHandler)则把“重试 + 熔断 + 单次超时”整体塞进对冲策略的内部,外面包一层总超时。
这个“顺序敏感的洋葱模型”,和
DelegatingHandler、ASP.NET Core 中间件完全同构。理解了管线的语义,就理解了 .NET 一大类框架的设计语言。
六、配置驱动:Options 与 Registry
v8 把配置从代码里解放出来。options 对象带校验,命名管线集中注册、可热更新:
| |
好处是:阈值集中在配置里,运维可调;options 在启动时校验,配错了启动就报错而不是上线才暴雷;结合 IOptionsMonitor 还能运行时热更新。韧性配置和业务代码彻底解耦。
七、可观测性:v8 最大的飞跃
这可能是 v8 最被低估的进步。开启一行 .EnableTelemetry(),Polly 就会通过 System.Diagnostics.Metrics(meter 名 Polly)和结构化日志,把韧性事件全部上报:
- 重试了几次、每次因为什么失败;
- 熔断器的状态切换(Closed → Open → Half-Open);
- 超时、限流、舱壁拒绝的计数。
这些指标可以直接进 OpenTelemetry / Prometheus / Grafana 仪表盘。
为什么这很重要?看不见的韧性是危险的。 一个配错的熔断器可能静默地把一半流量快速失败,而你在告警里只看到“错误率升高”却无从定位。v8 把可观测性作为一等公民,让韧性可监控、可调优、可归因——这是它从“库”升级为“基础设施”的标志。
八、与 HttpClient 合流:Microsoft.Extensions.Http.Resilience
回到姊妹篇。.NET 8 的 Microsoft.Extensions.Http.Resilience 是 Polly v8 与 IHttpClientFactory 的官方桥梁:
| |
AddStandardResilienceHandler / AddStandardHedgingHandler 本质上就是把上面那条标准 v8 管线接进 HttpClient 的 DelegatingHandler 出站管线。于是你在工厂和类型化 client 的基础上,免费获得 Polly v8 的全部策略与可观测性——两大生态在此统一。这也是为什么现在绝大多数应用不再手写 ResiliencePipelineBuilder,而是直接用标准 handler。
九、设计思想:Polly 教会了我们什么
回头看,Polly 的演进浓缩了几个值得带走的思想:
- “故障是常态,韧性是工程义务”。 在分布式系统里,容错不是可选优化,而是基本功。把重试/熔断当业务逻辑的一部分来设计,而不是事后补丁。
- “策略即管线,组合优于硬编码”。 和 HttpClient、ASP.NET Core 一脉相承——把横向关注点拆成可插拔的策略,按顺序组合,而不是在每个调用点重复
try/catch。 - “Outcomes over exceptions”。 v8 用
Outcome<T>统一表达成败,摆脱“用异常做控制流”的旧范式。这是更健康、更易组合的失败模型。 - “可观测的韧性”。 看不见就调不了。把可观测性内置进框架,让韧性从黑盒变成仪表盘上的一等指标。
- “对下游负责”。 熔断、限流、舱壁,本质都是“自我约束以保护整个系统”。一个有责任感的客户端,应当主动限制自己,而不是无限重试把下游压垮。
十、决策与避坑清单
按故障选策略:
| 你遇到的问题 | 用 |
|---|---|
| 偶发网络抖动 / 短暂 5xx | Retry + Jitter |
| 依赖持续故障 | Circuit Breaker |
| 请求偶发慢 / p99 高 | Hedging |
| 自己被打爆 / 保护下游配额 | Rate Limiter |
| 一个慢依赖拖垮全盘 | Bulkhead |
| 想给用户兜底体验 | Fallback |
| 任何调用都必须有 | Timeout |
常见坑:
- 重试风暴:无 jitter + 无退避,瞬时故障下集体同步重试。
- 重试不可幂等的
POST不带幂等键 → 重复下单。 - 有重试、有单次超时,却忘了总超时 → 单请求拖到天荒地老。
- 熔断阈值设太敏感(正常抖动就开)或太迟钝(形同虚设)。
- 把 4xx 业务错误当成“可重试故障”。
- 韧性策略没开 telemetry,上线即黑盒,出事无从定位。
结语
Polly 从一个“重试库”长成了“.NET 韧性工程的基础设施”。v8 的 ResiliencePipeline 范式重构,让韧性统一、可配置、可观测——这三点,恰恰是生产级分布式系统最需要的。
当你下一次写出 AddStandardResilienceHandler() 时,背后是一套经过千锤百炼的韧性工程语言:重试带抖动、熔断保护下游、超时封顶延迟、一切皆可观测。理解了这套语言,你写出的就不再是“能跑的代码”,而是“对下游负责、对自己克制、对故障坦然”的分布式客户端。