...
Back

那次让故障更严重的重试

重试会把一次短暂失败变成一次持续失败。机制很简单,修法也众所周知,而我们写过的几乎每一个客户端,第一版都写错了。

那次让故障更严重的重试

那次让故障更严重的重试 🔁

某个依赖变慢了。你的客户端超时并重试。其他所有客户端也一样,在同一时刻 —— 因为它们是在同一时刻失败的。这个依赖现在收到的流量比它健康时还多,而它的服务能力更差了。它变得更慢。更多超时触发。更多重试抵达。

没有人写过这个循环。这个循环是涌现出来的,而且是朴素重试逻辑在相关性失败下的默认行为。


三个性质把重试变成放大器

同步。 一起失败的客户端会一起重试。固定延迟会把这种相关性永久保留下来 —— 羊群只是整整齐齐地往后挪了一秒。这就是为什么抖动不是一种精细化,而是修法的核心:让每个客户端的延迟随机化,才是把羊群打散成分布的那件事。

相乘。 多层重试是相乘的。HTTP 客户端里三次,套在服务封装的三次里,再套在一个会重试整个任务的作业调度器里,就是一个逻辑操作变成二十七个请求。每一层单看都合理。没人看得见那个乘积,因为没有任何一个文件包含它。

没有"无望"这个概念。 一个对着已经挂了十分钟的依赖持续重试的客户端,不是有韧性,是在攻击。重试对瞬时故障有用 —— 丢一个包、一次短暂的主节点选举、一个倒霉的实例。对一个已经宕了的依赖它毫无帮助,而且会主动阻止它恢复 —— 因为它正在消耗对方恢复所需的容量。


正确的样子

指数退避加完全抖动。 不是"加一点随机",而是在整个区间上采样:

delay = random.uniform(0, min(cap, base * 2 ** attempt))

这会把一个同步的羊群铺散到整个窗口里,而不是原封不动地平移它。

只在恰好一层重试。 挑那一层知道这个操作意味着什么、并且能判断重复它是否安全的层,让其他所有层把失败原样透传。这是大多数代码库里价值最高的一个改动,而且它通常意味着删掉重试逻辑,而不是加上。

只重试可重试的东西。 超时或 503,可以。400、401、校验错误,绝不 —— 答案不会变,而你正在消耗依赖的容量来再听一遍。限流是单独一类:429 的意思是停下,而如果它带了 Retry-After,那个值是指令,不是建议。

要放弃。 给一个预算 —— 次数,或者更好,总耗时 —— 到点就失败并把失败说出来。快速且可见地失败,比无声地永远重试有用得多,因为它把问题暴露出来,而不是把问题转换成无法解释的缓慢。

断路。 在连续失败足够多次之后,彻底停止发送一段冷却时间,然后放一个探针过去。这是让依赖得以恢复的那一块,也是最常缺失的那一块 —— 因为它要求你承认:正确的动作是停止尝试。


两个值得单独点名的情况

限流不是瞬时故障。 当上游强制一个按账号的配额时,并发帮不上忙 —— 并发正是被限制的那个东西。唯一管用的形状是:串行执行、每次成功立刻落盘、从中断处继续。一个撞上限流后从头重试整批的批处理作业永远跑不完,而且全程看起来像在工作。

后台探测也需要预算。 周期性的健康检查或数据富化感觉是免费的,因为每次都很小。并发足够高时,它们会耗尽本地的套接字或文件描述符上限,产生一堆看起来完全不像网络问题的资源耗尽错误。如果某个东西按定时器对着很多目标跑,它和任何面向用户的路径一样需要并发上限。


评审时该问的问题

看到一处重试时,问:如果所有客户端在同一时刻都这么做,依赖方会经历什么?

如果答案是"比它健康时还大的负载",那这不是韧性。这是一次你给自己排期的小型拒绝服务攻击,而且它会恰好在你最承受不起的那天触发。