Day 23 Medium Reliability Circuit Breaker Backoff Bulkhead

可靠性 — 一个下游抖动,不让它拖垮整条链路Reliability: Circuit Breaker, Retry/Backoff, Bulkhead & Graceful Degradation

问题场景 + 需求约束

设计一条电商下单链路:下单 QPS 5 万,p99 SLO 300ms,整体可用性目标 99.95%(年停机 ~4.4 小时)。下单要同步调用约 30 个下游依赖——库存、优惠券、风控、地址、支付预授权……其中只有库存和支付是核心,其余非核心。

真正要防的不是「某个服务挂了」,而是 级联故障(cascading failure):优惠券服务一次 GC 停顿,响应从 20ms 涨到 5s。调用方同步等待,每个下单请求占着一个线程死等——几秒内线程池被占满,连根本不查优惠券的请求也排不进来。一个非核心依赖拖垮了整个下单。可靠性工程的核心命题就是:让局部故障止于局部。本期讲四件互补的武器:断路器、退避重试、舱壁、优雅降级。

高层架构

每个下游调用都穿过一层 resilience 中间层:超时 → 舱壁(独立资源池)→ 断路器(统计错误率)→ 重试(带退避)。任何一环判定失败,立刻走 fallback(缓存值 / 默认值 / 跳过)而不是死等。入口处还有一道 负载保护(load shedding),过载时优先保住核心请求。

graph LR
    IN["下单请求"]
    LS{"负载保护
过载→shed"} ORD["下单服务
编排"] BH["舱壁
每依赖独立池"] CB{"断路器
open?"} DEP["下游依赖
优惠券/风控..."] FB["Fallback
缓存/默认/跳过"] IN --> LS -->|放行| ORD --> BH --> CB LS -.->|拒绝 429| IN CB -->|closed| DEP CB -.->|open→fail fast| FB DEP -.->|超时/错误| FB classDef in fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef gate fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 classDef core fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef dep fill:#0e2030,stroke:#5eead4,color:#e8eef5 class IN in class LS,CB gate class ORD,BH core class DEP,FB dep

每个依赖被独立隔离;失败快速短路到 fallback,不让一个慢依赖耗尽全局资源

关键技术点

1. 断路器 Circuit Breaker — 给故障依赖「拉闸」

一句话 trade-off:用「对故障依赖的临时不可用」换「调用方快速失败、下游获得喘息」

原理:电闸的隐喻。断路器是个三态机:Closed(正常放行,统计失败率)→ 失败率超阈值 → Open(直接拒绝,不再发请求,立刻走 fallback)→ 冷却一段时间 → Half-Open(放少量探测请求试水)→ 成功则回 Closed,失败则退回 Open。它把「等满超时再失败」(每次浪费几秒、还在给濒死的下游加压)变成「fail fast」,同时切断了持续打击,让下游有机会恢复。

Trade-off:
# 断路器状态机 (pseudo-code)
def call(dep, req):
    if state == OPEN:
        if now() - opened_at > cooldown:   # 冷却到点
            state = HALF_OPEN               # 放一个探测
        else:
            return fallback(req)            # fail fast,不打下游
    try:
        resp = dep.invoke(req, timeout=200ms)
        on_success()                        # half-open 成功 → CLOSED
        return resp
    except (Timeout, ServerError):
        on_failure()                        # 窗口错误率超阈值 → OPEN
        return fallback(req)
现实案例:

2. 退避重试 Retry with Backoff + Jitter — 重试是把双刃剑

一句话 trade-off:用「重试带来的额外负载」换「吸收瞬时抖动」——但用错就是火上浇油

原理:瞬时故障(网络抖动、leader 选举、偶发超时)重试一次往往就好。但 naive 立即重试是放大器:下游过载 → 大量请求失败 → 所有客户端同时重试 → 下游被双倍流量彻底打死,这就是 retry storm(重试风暴)。两个解药:① 指数退避(exponential backoff),每次失败等待翻倍(100ms→200ms→400ms),给下游恢复时间;② jitter(抖动),加随机让重试时刻错开,否则所有客户端会「对齐」成同步脉冲,周期性地一起砸过去。

Trade-off:
# Full Jitter 指数退避 (AWS 推荐)
def retry(req, max_attempts=3):
    base, cap = 0.1, 2.0          # 100ms 起,封顶 2s
    for attempt in range(max_attempts):
        try:
            return call(req)       # 仅对幂等 + 可重试错误重试
        except Retryable:
            if attempt == max_attempts - 1: raise
            if not retry_budget.try_acquire(): raise  # 全局重试预算
            backoff = min(cap, base * 2 ** attempt)
            sleep(random.uniform(0, backoff))         # full jitter
现实案例:

3. 舱壁 Bulkhead — 把资源切成隔舱

一句话 trade-off:用「整体资源利用率下降」换「单依赖故障不耗尽全局资源」

原理:源自造船——船体分成多个水密隔舱,一处进水不会沉船。映射到系统:不要让所有下游调用共用同一个线程池/连接池。给每个依赖分配独立配额(如优惠券最多占 20 个线程)。这样优惠券变慢,最多占满它自己那 20 个,核心的库存、支付调用仍有线程可用——故障被关在隔舱里。这正是开头那个雪崩场景的根治方案。

Trade-off:
# 信号量舱壁:限制单依赖并发 (pseudo-code)
sem = {dep: Semaphore(limit[dep]) for dep in deps}   # 每依赖独立配额
def call(dep, req):
    if not sem[dep].try_acquire():      # 满了立即拒绝,不排队
        return fallback(req)            # 隔舱满 → 不波及其他依赖
    try:
        return dep.invoke(req, timeout=200ms)
    finally:
        sem[dep].release()
现实案例:

4. 优雅降级 + 负载保护 — 宁可残缺,不可全死

一句话 trade-off:用「功能完整性 / 结果精度」换「核心链路存活」

原理:当依赖失败或系统过载,与其返回 500、与其全盘崩溃,不如返回一个降级响应:优惠券服务挂了就按原价下单(fallback 到默认值);个性化推荐挂了就返回热门榜(fallback 到缓存/静态结果)。配套的是入口侧 load shedding(负载保护):当系统接近过载,主动拒绝一部分请求(返回 429),保住剩余请求能正常完成——部分成功远好于全体超时。拒绝时按 criticality(请求优先级)取舍:先拒非核心、保核心下单。

Trade-off:
现实案例:

扩展与优化

常见陷阱 + 面试追问

1. 重试在每层叠加 → 指数放大:A→B→C 三层,每层各重试 3 次,底层 C 实际承受 3×3×3 = 27 倍流量。规则:只在最外层重试,或全链路共享 retry budget。
2. 超时层级设反:上游超时必须 大于 下游超时之和。若上游 1s、下游 2s,上游早早超时放弃,下游还在傻算——白白浪费资源、还可能引发上游重试。超时应自上而下递减。
3. 不区分错误类型就重试:4xx(参数错、鉴权失败)重试多少次都没用,只会加压;只重试超时和 5xx,且必须确认操作幂等(重复扣款是事故)。
4. fallback 本身依赖故障组件:降级走的「缓存」如果和主路径共用同一个挂掉的 Redis,fallback 形同虚设。兜底路径必须比主路径更简单、更独立。
5. 断路器阈值拍脑袋:太敏感→正常抖动就跳闸误伤;太迟钝→形同虚设。要基于最小请求数 + 滑动窗口错误率,并想清楚 half-open 探测的并发控制(别让探测又变成惊群)。

深入资源

深入思考(点击展开答案)

1. A→B→C 三层调用,每层各配「最多重试 3 次」。C 短暂过载时,它实际承受的放大倍数是多少?为什么 retry budget 比 max-retries 更安全?

放大倍数 = 27 倍。每层重试是乘法叠加:3×3×3。一个本该 1 次的请求在最底层变成 27 次——这正是 retry storm 把过载下游彻底打死的机制,它本来只是想喘口气。

retry budget 为何更好:max-retries 是每请求的局部限制,无法约束整体放大;大面积故障时每个请求都顶满 3 次,叠加后仍是 27 倍。retry budget 用 token bucket 限制「重试量 ≤ 正常请求的 X%」,是全局封顶——故障再大,重试流量也超不过这个比例。实践上两者结合:单请求设小上限,全局再套 budget。

2. 断路器进入 Open 后冷却到点转 Half-Open,此时放探测请求。若不限制探测并发会怎样?怎么设计 Half-Open?

风险:若冷却结束的瞬间把所有积压请求都放过去探测,等于又给濒死的下游一记重击——刚要恢复又被打回 Open,陷入 open→half-open→open 的颠簸(flapping),下游永远缓不过来,这又是一次小型惊群。

设计要点:① Half-Open 只放极少量探测(如 1 个或固定小并发),其余请求继续 fail-fast 走 fallback;② 探测连续成功 N 次才回 Closed,单次成功不够(避免被偶发成功骗回);③ 探测失败立即退回 Open 并重置甚至延长冷却(可加退避,避免高频探测);④ 冷却时间本身可指数增长,故障持续越久探测越稀疏。本质:Half-Open 是「小流量试探恢复」,绝不能变成「全量复活」。

3. 为什么上游超时必须大于下游超时之和?把上游设得比下游短会发生什么连锁反应?

设短的后果:上游 1s、下游 2s。下游还在正常处理(比如 1.5s 能返回),上游已在 1s 时超时放弃。于是:① 下游白白干完活,结果没人要——纯资源浪费;② 上游把这次「超时」当失败,可能触发重试,再发一个请求给本就繁忙的下游,放大负载;③ 上游的超时会污染断路器统计,让它误判下游不健康而跳闸——明明下游没坏。

正确做法:超时预算自上而下递减。入口 300ms,留出网络与自身开销后,给下游 250ms,下游再给它的下游 200ms……每层都比父级紧。更进一步用 deadline propagation:把「绝对截止时间」透传下去,下游一看 deadline 已过就直接放弃,连算都不算。这样整条链路对「还剩多少时间」有一致认知,杜绝白做功。

4. 过载时 load shedding(主动拒绝一部分)反直觉地比「全部都收」更好。用 goodput 解释为什么,以及重试如何让不做 shedding 的系统陷入死亡螺旋。

goodput(有效吞吐)视角:throughput 是收了多少请求,goodput 是在 SLO 内成功完成多少。过载时若全收,每个请求都排长队、都变慢,最终全部超过 deadline——客户端拿到的全是超时,goodput 趋近 0,而 CPU 还全耗在这些注定失败的请求上。主动 shed 掉一部分(返回 429),剩下的请求资源充足、能在 SLO 内完成,goodput 反而更高。少做一点,成事更多。

死亡螺旋:不 shed + 客户端重试 = 正反馈。过载→变慢→超时→重试→负载更高→更慢……每轮都把系统推得更深,无法自愈。打破螺旋需要:服务端 load shedding + 客户端节流(adaptive throttling,按近期拒绝率主动减少发送)+ retry budget。三者缺一,系统就会在过载点「锁死」而非自动回弹。

5. 断路器、舱壁、重试、降级——四者能互相替代吗?用一次「优惠券服务慢到 5s」的故障说明它们各自拦在哪一环、缺一会怎样。

不能替代,它们拦在不同环节,是纵深防御:

  • 舱壁拦「资源耗尽」:优惠券慢,最多占满它专属的 20 个线程,核心调用不受影响。缺它 → 慢依赖吃光全局线程,全站雪崩(开头场景)。
  • 断路器拦「持续无效等待」:检测到优惠券错误率飙升就跳闸,后续请求 fail-fast,不再每个都干等 5s。缺它 → 即使有舱壁,那 20 个线程也一直被 5s 慢调用占满,该依赖吞吐归零。
  • 重试(+退避)处理「瞬时」抖动:如果只是偶发一次慢,退避重试能救回;但对持续故障,靠断路器及时停止重试,否则重试反而加压。
  • 降级决定「失败后给用户什么」:断路器跳闸/调用失败后,fallback 到原价下单,用户仍能完成核心流程。缺它 → 前三者都生效,但用户看到的还是下单失败。

串起来:舱壁关住故障、断路器停止无效调用、重试吸收瞬时抖动、降级兜住用户体验。任何一环缺失,这次优惠券慢调用都会在某个层面造成可感知的损害。