弹性设计:超时、重试、熔断、隔离与降级
弹性的目标不是「不出故障」,而是局部故障不扩散:某个下游变慢或挂掉时,调用方还能按可接受的质量继续服务。手段有五个——超时、重试、熔断、舱壁隔离、降级,它们解决不同的问题,缺任何一个都会留下具体的破口。
一、五件套各自解决什么
| 手段 | 解决什么 | 保护谁 | 关键参数 | 用错的反效果 |
|---|---|---|---|---|
| 超时 | 不让调用方无限等待,尽快释放线程与连接 | 调用方自身资源 | 每跳预算、连接与读超时、总预算 | 太长则资源被拖死;太短则把「慢」误判为「故障」 |
| 重试 | 掩盖瞬时故障(网络抖动、实例重启、偶发超时) | 请求成功率 | 次数、退避、抖动、重试预算、幂等性 | 放大流量,把「慢」升级为「雪崩」 |
| 熔断 | 下游持续错误时快速失败,不再消耗资源 | 调用方与下游 | 错误率阈值、最小请求数、熔断窗口、试探流量 | 小流量误触发;窗口过长导致长期不可用 |
| 舱壁隔离 | 一个依赖、租户或接口耗尽资源时,不影响其它 | 整个进程 | 池大小、队列长度、拒绝策略 | 池划分过细导致资源空转与排队 |
| 降级 | 牺牲非核心,保住核心链路 | 业务核心功能 | 触发条件、兜底数据、开关粒度 | 兜底数据被当成真实数据;开关无人回收 |
一句话记忆:超时管时间,重试管瞬时,熔断管持续,隔离管资源,降级管业务。
二、组合顺序:请求按顺序穿过五层
客户端请求
│
▼
[ 限流 ] 入口挡量(见 system-design 的限流章节)
│
▼
[ 总超时预算 ] 自入口向下逐级扣减,每层只能花自己那一份
│
▼
[ 舱壁:按依赖 / 租户分池 ] ──池满──► 快速拒绝(而不是排队到超时)
│
▼
[ 熔断 ] ──Open──► 直接走降级,不发真实请求
│
▼
[ 单次调用:连接超时 + 读超时 ]
│ 失败且可重试
▼
[ 重试:退避 + 抖动 + 预算上限 ] ──重试同样消耗总预算──► 回到熔断判定
│ 最终失败
▼
[ 降级返回兜底值 ] 并打点记录降级原因
三条顺序规则:
- 熔断在重试之外。熔断打开时不允许重试,否则试探流量被成倍放大(状态机细节可参考 middleware-interview 的熔断章节)。
- 重试共享总预算。重试不是「再来一次」,而是从同一个时间预算里再切一块,预算耗尽即失败。
- 降级是最后一步,也是最先要定的一步。先明确「失败时给用户看什么」,再回头配超时与重试;失败路径未定义时,参数只能靠猜。
三、重试放大:最容易自己造出的雪崩
重试的危险不在单次,而在层级相乘。以一个三层调用链、每层允许 3 次尝试(1 次原始 + 2 次重试)为例:
| 层级 | 单个入口请求对应的调用次数 | 相对最底层放大 |
|---|---|---|
| A(入口) | 1 | 1× |
| A 调 B,B 每请求调 1 次 C | 3 | 3× |
| B 对每请求扇出 3 个 C | 3 × 3 | 9× |
| 若 C 之下还有一层同样重试 | 3 × 3 × 3 | 27× |
下游已经因过载而变慢时,重试正好在最不该加量的时刻把流量乘以 27。四条约束把它压住:
- 只在幂等操作上重试。查询、按唯一键的幂等写可以重试;非幂等的下单、转账不能盲重试,除非带幂等键。
- 只重试明确的瞬时故障。连接失败、连接超时、502/503 可以重试;业务错误码(余额不足、参数非法)重试一百次结果一样。
- 退避加抖动。固定间隔重试会让所有客户端同时再打一次,形成脉冲,反而更伤下游。
- 重试预算。按「重试次数 / 总请求数」设上限(例如 10%),超预算直接失败,让重试量有全局天花板。
四、舱壁隔离:按什么维度分池
| 维度 | 例子 | 适用场景 |
|---|---|---|
| 按依赖 | 用户服务、支付服务各一个线程池与连接池 | 最常用:防止一个慢依赖吃掉所有线程 |
| 按租户 | 大客户独占配额,免费用户共享池 | 多租户 SaaS,避免单个客户拖垮全局 |
| 按接口 | 同步写接口与后台批量接口分池 | 防止离线任务挤压在线请求 |
| 按优先级 | 核心交易池 + 非核心任务池 | 资源紧张时先保核心 |
代价要说清楚:池是独占资源——拆成 5 个池,每个池都要留余量,总占用上升而平均利用率下降;池间不能互相借资源,突发流量下会出现「别的池空着、这个池排满」。所以分池维度不要超过 3~4 个,每个池都要有明确的容量依据与拒绝策略。
五、降级预案与开关设计
降级要事先分级,故障当天才能不假思索地执行:
| 级别 | 状态 | 动作 |
|---|---|---|
| L0 | 正常 | 全功能 |
| L1 | 单个非核心依赖异常 | 关闭该依赖相关功能(推荐位、评论、个性化),返回缺省内容 |
| L2 | 核心依赖不稳定 | 关闭非核心功能集合,缩短超时、提高熔断灵敏度,保留核心写链路 |
| L3 | 严重过载 | 只保核心写入与查询,读走缓存或静态兜底,非核心接口直接拒绝 |
开关设计要点:
- 三级粒度:全局开关(一键进 L2)、依赖级开关(关某个下游)、租户或接口级开关(先小流量验证)。
- 每个开关有主人和到期时间。没有主人的开关会腐烂成永久的隐藏行为。
- 开关要能被演练:定期在预发真实翻转,验证降级返回的数据格式端上能接受。只写不演练的降级预案等于没有预案。
- 降级要有可观测标记:在响应头或日志里标记「本次为降级结果」,避免兜底数据被当成真实数据参与业务判断(例如把降级返回的「无库存」当成真实库存)。
六、常见失败模式
- 超时层层加码:下游超时 5s、上游 10s,最坏情况线程被占 15s,连接池瞬间打满。总预算必须自上而下扣减。
- 重试与超时互不知情:单次超时 1s、重试 3 次,实际最长 3s,已超过上层的 2s 超时,于是上层又重试一次。
- 熔断阈值忽略小流量:每秒 2 个请求的服务,1 个错误就触发 50% 错误率阈值,必须设最小请求数。
- 开关只在故障当天第一次打开:常年不动的开关,谁也不敢在生产上按下去。
- 只做服务端弹性:客户端无超时、无重试上限时,用户侧会持续重发,把服务端的恢复过程拖长。
小结:弹性五件套分别管时间、瞬时故障、持续故障、资源边界与业务取舍,合起来才形成「局部故障不扩散」的效果。落地顺序是:先给每跳定超时与总预算,再定哪些操作允许重试并配套退避、抖动与预算,然后用熔断处理持续故障、用舱壁划清资源边界,最后把降级分级与开关一并设计并演练。所有配置都要能被演练、能被观测,并且有人负责回收。