指数退避与抖动:重试不该成为第二次故障

外部调用失败以后立刻再试,试到成功为止,这在单机、单用户、故障只持续一瞬间时像是能自愈。故障若持续几秒,所有调用方会在同一节奏上醒过来,一起打向还没恢复的服务。起初那次不可用是对方的故障,紧接着的过载是重试自己制造的。退避用来把重试拉开时间,抖动用来避免大家拉开到同一个节拍上。两者都做了,重试仍可能放大负载,所以还要有预算、有终点、有资格判断。

指数退避与抖动,重试不该成为第二次故障
指数退避与抖动,重试不该成为第二次故障放大图解 ↗

下面把一次发布调用拆开看。工作器把笔记交给外部平台,平台返回拥挤或一直不响应。我们要决定等不等、等多久、谁来等、什么时候放弃。这四个问题里只回答“等多久”,就会得到一个看起来很聪明、实际上仍会打垮下游的循环。

同步重试会把波峰对齐

设想发布接口依赖一个下游。下游开始返回拥挤。每个工作器的策略都是失败后等待 1 秒、2 秒、4 秒、8 秒。故障是从整秒开始的,于是这批工作器几乎同时失败,又几乎同时在 1 秒后醒来。下游看到的不是平滑的重试,而是一浪一浪的流量,浪的高度接近原来的并发量,间隔正好是退避的档位。下游刚有一点容量,被下一浪打满,又开始拒绝,循环持续到尝试次数用尽。

指数增长解决的是“不要每秒都打满”,它不解决“不要在同一毫秒一起打”。没有抖动的指数退避,只是把对齐的节拍从固定的 1 秒改成了 1、2、4、8。图上仍然是梳齿。梳齿的尖越来越高或保持原高度,取决于你有没有并发限制。没有并发限制时,失败的任务全部留在内存里等待,醒来时并发比故障前还高,因为新请求还在不断进来。

立刻重试还有一个更小的对齐:超时时间相同的调用会一起超时。三百个调用都把超时设成 2 秒,下游一卡住,三百个超时在同一窗口内到期,然后一起进入等待 1 秒的退避,再一起回来。所以抖动不只是加在退避上,超时本身也可以有一个很小的分散。不过超时分散解决不了退避档位对齐,两者都要看。

退避的形状

一次尝试失败后,下一次等待的基础值可以写成:基础延迟乘以二的尝试次数次方,再加一个上限。例如基础 200 毫秒,上限 30 秒。尝试次数从零计起,等待大约是 200 毫秒、400 毫秒、800 毫秒,然后继续翻倍,到了上限就不再变大。上限必不可少。没有上限的指数,几次之后会变成不可接受的等待,任务看起来像丢失;也有人会在代码里写出巨大的睡眠,把工作器占住。

等待多久不是唯一的旋钮。尝试次数或总时长也要有上限。次数用尽就停止,把错误交给任务的终态,而不是在调用方进程里无限循环。上限的选择来自调用方还能等多久,以及下游需要多久才能恢复。两者都未知时,选一个短的、可见的上限,让失败早点变成可查询的状态,比在后台睡上许多分钟更可解释。

睡眠要可被取消。工作器若在睡眠里不看租约和取消请求,一次长退避会把取消拖到睡眠结束。等待应当能被“租约快到期”或“已被要求取消”打断。打断不是失败重试,是另两条状态边。把睡眠写成不可打断的阻塞,状态机里的取消就只在睡眠之外生效,用户会看到取消点了很久仍然发出去。

退避的基数不要从零开始再左移到溢出。尝试次数若因为bug变成一个很大的整数,位移会得到一个无意义的等待,甚至变成负数。计算时先取尝试次数和你允许的最大档位之间的较小值,再做乘法,并且每次都和上限比较。等待为负数或为零时,把它当成实现错误,不要真的立刻再打一次还假装这是退避。

抖动是把梳齿打散

抖动是在退避上加一个随机量,让不同调用方的醒来时间错开。有几种常见加法,语义不一样。

全抖动:在零和当前退避值之间随机取一个等待。当前退避是 8 秒时,实际等待可能是 0 到 8 秒里的任意值。浪被打得很散,但平均等待只有退避值的一半左右,重试会更早发生,对下游的压力更大一些。下游仍然很弱时,过早的重试帮助不大。它适合希望尽快成功、下游容量还比较宽的读取。

等量抖动:等待时间是当前退避值的一半,再加上零到一半之间的随机量。退避 8 秒时,等待落在 4 到 8 秒。既保留“越失败等越久”的下限,又避免所有人踩在 8 秒上。多数后台任务用这种就够。下限让你仍能估计“最迟什么时候会再试一次”,上限让这一档不会拖得比理论值更久。

去相关抖动:下一次等待依赖上一次的实际等待,而不是依赖尝试次数的理论值。随机落在基础延迟和上一次等待的三倍之间,再吃上限。它不要求所有调用方共享一个尝试次数时钟,适合各自分散失败的客户端。实现时不要让随机范围变成零,否则会退化成立刻重试。这种办法的等待序列在不同客户端上发散,不容易再次对齐,代价是单次等待不那么好预测。

无论哪一种,随机源用每次独立的随机数。不要用任务编号模一个常数来假装抖动,那会让同一批按编号排序的任务仍然分成几梳齿。也不要只在进程启动时抽一个偏移然后全程复用,所有任务会共用这个偏移,浪还是齐的,只是整体晚一点。

测试不能把随机数固定成零还声称覆盖了抖动。固定种子可以让断言稳定,但种子要真的跑出不同的等待。至少断言:同一退避档上的多次等待不会总是相等,等待不会超过上限,也不会小于该策略承诺的下限。再加一条:一批同时失败的任务,它们的下次运行时间不能全部相同。这条能抓住“算了抖动又在入库时被截断成秒”的问题。截断到整秒会把精心打散的毫秒重新对齐。

不是所有失败都配重试

退避只处理“再试一次有可能变好”的失败。连接被拒绝、响应超时、对方明确说拥挤、返回了可重试的状态,适合进入退避。参数不被接受、资源不存在、认证被拒绝,再试多少次都一样。把这些放进退避,只会推迟那个必然到来的失败,并占住重试预算。

文本匹配错误信息是脆弱的。对方改了一行文案,你的“可重试”判断就反了。优先看明确的类别或状态码。没有类别时,默认不重试,把未知错误留给人工或留给更上一层的策略。默认重试是危险的默认。未知错误里常常包括“你已经把对方写坏了”,再试只会继续写。

幂等是资格的另一半。查询是幂等的,重试只多读几次。创建若没有幂等键,超时的那一次可能已经在对方创建成功,只是响应丢失。立刻重试会创建两次。这种调用要么先补幂等键,要么超时后改为查询对方是否受理,而不是盲目再提交。退避改变不了“两次提交”的语义。幂等键要用任务编号这类稳定值,不要用每次尝试重新生成的随机数。随机数会让对方把每次重试看成全新请求,幂等形同虚设。

对方若给出了下次可试的时间,尊重它,不要再用更短的本地退避去打。对方给出的时间也可以加上少量抖动,避免所有客户端都卡在它指定的那一秒。对方给出的时间长得离谱时,用你自己的上限截断,并把截断记进原因,否则一个错误的响应能把任务推迟到不可接受的未来。对方给出零或负数时,不要理解成立刻重试,把它当成缺失,走你自己的退避。

重试会在层级之间相乘

一次用户请求经过网关、应用、工作器、下游。每一层都“善意地”重试三次,最里面一次失败会变成外面许多次调用。用户等待时间是各层退避之和,常常先超时断开,里面却还在重试。断开的请求没有人看结果,重试仍在消耗下游。

划清哪一层负责重试。交互式请求的调用方只重试很短的、幂等的读取,总预算放进剩余的截止时间里。超过截止时间就停止,把超时返回给用户。后台任务可以等更久,但由任务系统统一退避,里面的客户端库不要再套一层完整的指数退避。库若自带重试,关掉它,或把它的次数限制成一次,避免和任务级重试叠乘。

截止时间要往下传。上游还剩 400 毫秒,下游调用的超时就不要设成 2 秒,更不要在这 2 秒里再退避三次。剩余预算小于一次调用的最小超时时,直接失败,不要发出明知来不及的请求。这个失败是预算用尽,不是下游故障,统计时分开,免得把下游误判成不可用。

批量任务同时失败时,重试预算还要按下游来算,而不是按单条任务来算。一万条任务各自觉得自己只重试五次,下游会看到五万次。可以给每个下游一个并发中的重试额度,额度用尽的任务把下次运行时间再推迟,而不是在本地睡完就冲出去。额度本身也要有退避,不然额度一恢复又是一浪。额度的恢复要慢于下游的恢复,否则你只是把梳齿从任务挪到了额度上。

共享额度要有自己的上限和排队,不要用一把全局锁把所有工作器串行化。串行化确实不会打出浪,但它把无关的下游也拖慢了。额度按下游分开。发布平台拥挤时,生成预览的本地任务不该一起停住。停住的范围和故障的范围一致,排障的人才能从“谁在等”看出来“谁坏了”。

抖动解决不了的那些故障

抖动让请求不要对齐,不增加下游的容量。下游长时间不可用时,正确的行为是少打、早点进入可查询的失败,而不是打得更均匀。均匀的重试仍然是负载。熔断在这里和退避配合:连续失败后停止尝试一段时间,这段时间里任务直接延后,不发起调用。熔断打开时的等待,同样要打散,否则熔断一关闭,积压的任务会一起涌入。

局部失败不要触发全局重试。一个账号的凭证失效,只标记这个账号的任务不可重试。若用全局熔断,会把其他健康账号的发布一起推迟。熔断的键至少要细到依赖和身份这一层。键太粗,抖动和退避都在保护一个错误的范围。键太细,例如细到每一篇笔记,熔断就失去了“这一片都先别打”的意义,退化为单条任务的退避。

观察重试是否变成了第二次故障,要看下游的拒绝率和调用方的重试占比,而不是只看任务最终成功了没有。最终成功可能是在把下游打了很久之后才发生的。重试次数、实际睡眠、是否打过上限、是否因为预算停止,都记在迁移原因里。没有这些数,退避参数只能靠感觉调,调大了任务变慢,调小了浪又对齐。

时钟和随机数在多进程里各算各的,这是对的。不要搞一个中心调度去给所有工作器分配同一个等待表,那张表会把你刚刚打散的请求再排齐。协调只应该发生在预算和熔断这种需要全局事实的地方。连随机数发生器都共享同一个种子文件,多进程会算出同一串等待,抖动在纸面上存在,在时间戳上消失。启动时给每个进程一个不同的种子,是这种故障里最便宜的修复。

边界

单元测试里把睡眠替换成可记录的时钟,断言等待序列落在策略的区间里,不要真的睡满几十秒。集成测试里制造下游的拥挤,看调用时间戳是否形成一梳齿。若仍然对齐,先检查是不是有一层没有抖动,或所有进程共用了同一个随机种子。也要看时钟的分辨率。把下次运行时间存成秒,毫秒级的抖动会被吃掉。存成更细的时间,扫描条件仍可以用索引,两者不冲突。

用户正在等待的按钮下不要套长退避。人会再次点击。两次点击若变成两串重试,比一串更糟。交互路径上快速失败,让用户决定要不要再试;长退避留给已经进了队列、没有人干等的任务。队列里的那串重试对用户显示下一次时间,而不是显示成“正在处理”却不说要等多久。人等的那一层如果也重试,按钮要变成不可重复提交,或者用幂等键把第二次点击合并进同一条任务。

已经部分完成的步骤不要整个重试。图片传完了、正文提交超时,重试整个发布会再传一次图片。步骤分开记,重试从失败的步骤继续。这一条和幂等键是同一件事的两个说法。退避代码若包在最外层的大函数上,里面的步骤就得不到这个区别。包一层的写法在演示里很短,在事故里会让你无法只重做失败的那一步。

依赖方的维护窗口可以事先把下次运行时间调到窗口之后,而不是靠失败去撞窗口、再退避。明知对方在维护还按失败重试,是在用故障路径做日程调度。日程是显式的延迟,统计里不应该算成下游错误。把维护记成错误,会让熔断在窗口开始的几分钟内打开,窗口结束后还要等熔断自己闭上,等于把维护延长了一段谁也没宣布的时间。

重试还要面对变化了的世界。等待的几秒里,笔记可能被改过,人可能被移出账号。醒来之后不要沿用失败前读到的正文和权限,重新加载再判断。退避延长了“当时的决定”和“现在的执行”之间的距离,这个距离里的变化必须重新检查。否则抖动只是让越权或旧稿的发布来得不那么整齐。

收束

指数退避让失败之后的间隔变长,上限让间隔不要变成无限。抖动让同一档间隔不要落在同一时刻。在这之上,只有可能变好的、幂等的调用才配重试;每一层不要各试一轮;剩余预算不够就不要发;批量重试要有共享的额度。退避和抖动减轻的是对齐的浪,不是过多的尝试。尝试太多时,再均匀也是一次新的故障。把停止写进状态,比把睡眠写进循环更重要。能说清下一次为什么还要打、打之前要重新看什么、打不动时停在哪里,重试才是恢复手段,而不是第二场事故。

见字如晤

图解