设计一条电商下单链路:下单 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,不让一个慢依赖耗尽全局资源
一句话 trade-off:用「对故障依赖的临时不可用」换「调用方快速失败、下游获得喘息」。
原理:电闸的隐喻。断路器是个三态机:Closed(正常放行,统计失败率)→ 失败率超阈值 → Open(直接拒绝,不再发请求,立刻走 fallback)→ 冷却一段时间 → Half-Open(放少量探测请求试水)→ 成功则回 Closed,失败则退回 Open。它把「等满超时再失败」(每次浪费几秒、还在给濒死的下游加压)变成「fail fast」,同时切断了持续打击,让下游有机会恢复。
# 断路器状态机 (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)
HystrixCommand,错误率超阈值即跳闸(How it Works)。一句话 trade-off:用「重试带来的额外负载」换「吸收瞬时抖动」——但用错就是火上浇油。
原理:瞬时故障(网络抖动、leader 选举、偶发超时)重试一次往往就好。但 naive 立即重试是放大器:下游过载 → 大量请求失败 → 所有客户端同时重试 → 下游被双倍流量彻底打死,这就是 retry storm(重试风暴)。两个解药:① 指数退避(exponential backoff),每次失败等待翻倍(100ms→200ms→400ms),给下游恢复时间;② jitter(抖动),加随机让重试时刻错开,否则所有客户端会「对齐」成同步脉冲,周期性地一起砸过去。
# 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
一句话 trade-off:用「整体资源利用率下降」换「单依赖故障不耗尽全局资源」。
原理:源自造船——船体分成多个水密隔舱,一处进水不会沉船。映射到系统:不要让所有下游调用共用同一个线程池/连接池。给每个依赖分配独立配额(如优惠券最多占 20 个线程)。这样优惠券变慢,最多占满它自己那 20 个,核心的库存、支付调用仍有线程可用——故障被关在隔舱里。这正是开头那个雪崩场景的根治方案。
# 信号量舱壁:限制单依赖并发 (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()
一句话 trade-off:用「功能完整性 / 结果精度」换「核心链路存活」。
原理:当依赖失败或系统过载,与其返回 500、与其全盘崩溃,不如返回一个降级响应:优惠券服务挂了就按原价下单(fallback 到默认值);个性化推荐挂了就返回热门榜(fallback 到缓存/静态结果)。配套的是入口侧 load shedding(负载保护):当系统接近过载,主动拒绝一部分请求(返回 429),保住剩余请求能正常完成——部分成功远好于全体超时。拒绝时按 criticality(请求优先级)取舍:先拒非核心、保核心下单。
放大倍数 = 27 倍。每层重试是乘法叠加:3×3×3。一个本该 1 次的请求在最底层变成 27 次——这正是 retry storm 把过载下游彻底打死的机制,它本来只是想喘口气。
retry budget 为何更好:max-retries 是每请求的局部限制,无法约束整体放大;大面积故障时每个请求都顶满 3 次,叠加后仍是 27 倍。retry budget 用 token bucket 限制「重试量 ≤ 正常请求的 X%」,是全局封顶——故障再大,重试流量也超不过这个比例。实践上两者结合:单请求设小上限,全局再套 budget。
风险:若冷却结束的瞬间把所有积压请求都放过去探测,等于又给濒死的下游一记重击——刚要恢复又被打回 Open,陷入 open→half-open→open 的颠簸(flapping),下游永远缓不过来,这又是一次小型惊群。
设计要点:① Half-Open 只放极少量探测(如 1 个或固定小并发),其余请求继续 fail-fast 走 fallback;② 探测连续成功 N 次才回 Closed,单次成功不够(避免被偶发成功骗回);③ 探测失败立即退回 Open 并重置甚至延长冷却(可加退避,避免高频探测);④ 冷却时间本身可指数增长,故障持续越久探测越稀疏。本质:Half-Open 是「小流量试探恢复」,绝不能变成「全量复活」。
设短的后果:上游 1s、下游 2s。下游还在正常处理(比如 1.5s 能返回),上游已在 1s 时超时放弃。于是:① 下游白白干完活,结果没人要——纯资源浪费;② 上游把这次「超时」当失败,可能触发重试,再发一个请求给本就繁忙的下游,放大负载;③ 上游的超时会污染断路器统计,让它误判下游不健康而跳闸——明明下游没坏。
正确做法:超时预算自上而下递减。入口 300ms,留出网络与自身开销后,给下游 250ms,下游再给它的下游 200ms……每层都比父级紧。更进一步用 deadline propagation:把「绝对截止时间」透传下去,下游一看 deadline 已过就直接放弃,连算都不算。这样整条链路对「还剩多少时间」有一致认知,杜绝白做功。
goodput(有效吞吐)视角:throughput 是收了多少请求,goodput 是在 SLO 内成功完成多少。过载时若全收,每个请求都排长队、都变慢,最终全部超过 deadline——客户端拿到的全是超时,goodput 趋近 0,而 CPU 还全耗在这些注定失败的请求上。主动 shed 掉一部分(返回 429),剩下的请求资源充足、能在 SLO 内完成,goodput 反而更高。少做一点,成事更多。
死亡螺旋:不 shed + 客户端重试 = 正反馈。过载→变慢→超时→重试→负载更高→更慢……每轮都把系统推得更深,无法自愈。打破螺旋需要:服务端 load shedding + 客户端节流(adaptive throttling,按近期拒绝率主动减少发送)+ retry budget。三者缺一,系统就会在过载点「锁死」而非自动回弹。
不能替代,它们拦在不同环节,是纵深防御:
串起来:舱壁关住故障、断路器停止无效调用、重试吸收瞬时抖动、降级兜住用户体验。任何一环缺失,这次优惠券慢调用都会在某个层面造成可感知的损害。