跳过导航

为什么重试会放大线上故障?退避、抖动与重试预算

约 4 分钟...次浏览
专栏分布式稳定性与工程实践第 2 篇

重试的出发点是提高短暂故障下的成功率,但在依赖过载时,它会把失败请求重新注入已经拥堵的系统。五层调用链若每层最多重试 3 次,最坏尝试次数可能呈乘法增长,而不是简单增加 3 次。

哪些错误可以重试

适合重试的是瞬时错误:连接重置、明确的限流响应、主从切换窗口。参数错误、权限失败、资源不存在通常不可重试。超时结果最棘手:调用方不知道服务端是否已经成功,因此写操作必须具备幂等键或可查询的操作状态。

int cappedAttempt = Math.max(0, Math.min(attempt, 10));
long baseMs = Math.max(1, base.toMillis());
long exponentialMs = baseMs > (Long.MAX_VALUE >> cappedAttempt)
        ? Long.MAX_VALUE : baseMs << cappedAttempt;
long capMs = Math.min(maxBackoff.toMillis(), exponentialMs);
long sleepMs = capMs == Long.MAX_VALUE
        ? ThreadLocalRandom.current().nextLong(Long.MAX_VALUE)
        : ThreadLocalRandom.current().nextLong(capMs + 1);

这是 full jitter 的简化形式。生产实现还应在启动时校验 base/maxBackoff 为正,处理 Duration 转毫秒溢出、中断和剩余 deadline;通常优先使用经过验证的客户端或韧性库,而不是复制一段 sleep 循环。指数退避给依赖恢复时间,随机抖动防止所有实例在相同时间再次发起请求。必须设置最大退避和总 deadline,不能让后台重试无限存活。

只在一层重试

若 SDK、业务服务、网关和客户端都重试,调用次数无法控制。通常由最了解业务幂等性和剩余预算的一层承担重试,其余层快速返回明确错误。数据库驱动内部的连接重试也要纳入整体次数。

重试预算

“每次请求最多重试 2 次”仍可能在全量失败时产生 3 倍流量。更稳妥的方式是限制时间窗口内重试流量占初始请求或成功请求的比例,例如 10%,并用令牌桶让预算随健康流量缓慢恢复。预算口径必须统一到整个调用链,而不是每个实例各自宣称 10%。预算耗尽后不再重试,让系统有恢复空间。

重试还应服从熔断和令牌桶。熔断打开后不应继续排队重试;收到可信的 Retry-After 时应解析秒数或 HTTP 日期,并仍受本地 deadline 上限约束。对多个副本的 hedged request 只适合可安全重复的操作,应在延迟分位数阈值后才发出、严格限制额外流量,并取消和观测落败请求;否则只是更隐蔽的流量放大。

HTTP 方法名不能单独证明安全。即使 GET 通常幂等,也可能触发有副作用的错误实现;POST 若使用服务端持久化的幂等键和结果回放,也可以安全重试。请求体必须可重放,幂等记录的生命周期要覆盖客户端最大重试窗口。

故障复盘应看什么

分别统计原始请求数、尝试次数、每次 attempt 的结果与延迟、重试成功率。若只统计最终请求结果,会看不到放大流量。Trace span 应标注 attempt、退避时间、幂等键和剩余 deadline。

常见错误

  • 捕获所有 Exception 后立即循环重试。
  • 固定 100ms 间隔导致同步重试波峰。
  • HTTP 请求重试但未重建已消费的请求体。
  • 超时后生成新业务流水号,造成重复扣款。
  • 在事务内 sleep 重试,长期持有数据库锁。

生产检查清单

  • 是否有明确的可重试错误白名单?
  • 写操作是否真正幂等,并保存首次执行结果?
  • 是否只有一个责任层执行重试?
  • 是否包含指数退避、随机抖动、最大次数和总 deadline?
  • 是否按比例限制重试流量?
  • 是否尊重调用链剩余预算、服务端 Retry-After 和请求体可重放性?
  • 指标能否区分逻辑请求与物理尝试次数?

重试不是免费的可靠性。只有当额外成功收益大于流量放大风险,并且依赖仍有余量时,它才是正确工具。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。