下游数据库或微服务出现一次短暂的延迟尖峰,应用里的重试逻辑本该把这次抖动盖过去。但在大多数生产架构里,重试恰恰是把 5秒 的小故障拖成 45分钟 级联故障的那个机制。 亚马逊 Builders' Library 的记录指出,重试本质上是自私的:客户端重试一次失败调用,等于宣称自己的请求足够重要,可以从一个已经在挣扎求生的依赖身上索取更多资源。 如果你的 HTTP 客户端用的是固定延迟、没有抖动(jitter)的标准指数退避,或者在技术栈的多层都开了重试,那你建起来的不是韧性,而是一台同步负载放大器。 同步羊群与分层放大 多数工程师知道立即重试(延迟为0)很危险,于是配置标准指数退避:等1秒,再等2秒,再等4秒。但 Marc Brooker 在 AWS 架构博客关于指数退避与抖动的研究中证明,标准指数退避只降低了单个客户端的重试频率,并没有打破客户端之间的同步。 当下游服务卡住 500毫秒 , 1000个 并发请求会在同一毫秒一起失败: 1000个客户端全部睡 1000毫秒 (2^0 × 1000)。 整整一秒后,1000个客户端齐步醒来,连同正常流入的流量一起猛砸正在恢复的服务器。 服务器再次崩溃,同步波在 2000毫秒 4000毫秒 处重复。 常见错误:无抖动退避与盲目重试 一个典型的 HTTP 或 RPC 调用封装,往往能顺利通过大多数代码评审:最大重试3次,基础延迟1000毫秒,循环里按尝试次数算出延迟再睡过去。看起来很有韧性,但在高负载下正是同步重试风暴的源头。 生产中的后果很直接。当一次发布或数据库锁让延迟越过 2000毫秒 的超时阈值,调用方实例会保持 socket 打开等待,然后在+1秒、+2秒、+4秒处打出同步重试波。即便你重启了过载的下游 pod,在途重试的积压也会在新实例缓存预热完成前把它们压垮——谷歌 SRE 手册在讨论级联故障时描述过这种经典的正反馈循环。 改进方案:全抖动加预算 要让重试在生产中变得安全,需要三个控制手段协同工作: 全抖动 :把延迟随机化到0和指数上限之间(sleep = random(0, min(cap, base × 2^attempt))),把同步尖峰摊平成平滑、恒定的到达速率。 严格的可重试过滤 :只重试瞬时传输错误或明确的 429 503 状态码,存在 Retry-After 头时予以遵守。 本地重试预算(令牌桶) :把重试限制在总出站流量的某个百分比(例如 10% )。依赖彻底挂掉时,预算耗尽,客户端快速失败,而不是放大故障。 在 AWS 对100个竞争客户端的模拟中,全抖动相比标准指数退避把服务器总调用量削减了 50%以上 ,同时完成得更快。三种变体的取舍: 全抖动 random(0, min(cap, base × 2^attempt)):最佳默认选择,高竞争下服务器总工作量最低、完成最快。 等量抖动 temp/2 + random(0, temp/2):保证最小睡眠窗口,客户端不会在0毫秒重试,同时打散羊群。 去相关抖动 min(cap, random(base, prevSleep × 3)):无状态替代方案,按上一次随机等待时间缩放,而非严格的2的幂。 上线前的五条检查 在发布任何客户端 SDK、队列消费者或 API 网关规则之前,用这五条核对重试策略: 只在技术栈的单一层重试:网关或顶层调用方已经重试时,关掉内层微服务跳转和 ORM 驱动的自动重试,让下层快速失败。 始终应用全抖动:并发调用方多于一个的系统里,绝不部署固定间隔或纯2^n退避。 执行10%重试预算:用令牌桶,失败请求超过10%时客户端停止重试、立即卸载。 变更操作要求幂等键:除非传入幂等键并在存储层原子化执行,否则不要对 POST 或 PATCH 端点开启自动重试。 跨跳传播截止时间:进来的请求超时是2000毫秒、已在上游处理花掉1800毫秒,就别再发一次还要1500毫秒的重试——用户早就断开了。 重试只有在失败是瞬时的、孤立的、罕见的时候才有效。一旦失败变成系统性——连接池饱和、数据库副本降级——朴素的重试就把你自己的微服务变成了一支内部僵尸网络。用全抖动替换确定性退避,把重试限制在单一架构层,并执行重试预算,让系统知道什么时候该停止往下挖。 特别