WHY

当一个service去调用另一个service时,就有可能会发生故障。这些故障可能是由与各种原因所引起的,比如:

  • 服务器端
  • 客户端
  • 网络
  • 负载均衡器
  • 软件
  • 操作系统

设计系统的目的是减少发生故障的可能性,但我们无法构建永不中断的系统。因此我们需要做的是进一步设计我们的系统,以“容忍”故障,并避免将一小部分故障放大为一次系统性的故障。

WHAT

超时(timeouts)

许多类型的失败表现为请求花费的时间比平时更长,并且可能永远无法完成。如果客户端等待响应的时间比一般情况下长得多,则在客户端等待时,会将资源持有更多时间。这会导致内存,线程,连接,临时端口或其他任何有限资源的耗尽。为了避免这种情况,客户端设置了timeouts,即客户端愿意等待请求完成的最长时间。

重试(retry)

通常,将失败的请求再次进行尝试会成功。发生这种情况是因为我们构建的系统通常不会作为一个整体而失败:相反,它们会发生部分故障(一定比例的请求成功)或短暂故障(请求在短时间内失败)。重试允许客户端通过再次发送相同的请求来避免“随机”发生的部分故障或短暂故障。被重试的服务一定需要是幂等的。

HOW

如何设置超时时间

设置超时时间是一个需要权衡的事情。如果我们将超时时间设置的过大,那么就会失去设置它的意义,因为client在等待超时时仍会消耗资源。而如果我们设置的过小,会导致重试次数过多,增加后端流量,进而会增加服务的延迟,而该延迟的增加,又会进一步导致更多的重试,进而有可能造成完全的中断。
选择一个好的超时时间通常需要我们观测RPC请求延时,一般来说会设置为请求延时的p99。这样的设置会有可能在网络中产生两个相同的请求,等待较早返回的请求即可。但在某些情况下需要我们更加仔细的进行分析,比如:

  • 某些地区的客户端总是产生比较大的延迟。比如可能是因为我们的客户遍布全球,而该地区距离服务器很远等原因。在这种情况下,我们要考虑的合理的最坏情况下的网络延迟。
  • 此方法也不适用于latency相差不大的情况下,比如p50非常接近p99。在这些情况下,可以添加一些buffer。

如何设置重试次数以及重试间隔时间

始终记住的一点是:Retries are “selfish.”

重试的风险

  1. 重试会加大直接下游的负载。假设 A 服务调用 B 服务,重试次数设置为 r(包括首次请求),当 B 高负载时很可能调用不成功,这时 A 调用失败重试 B ,B 服务的被调用量快速增大,最坏情况下可能放大到 r 倍,不仅不能请求成功,还可能导致 B 的负载继续升高,甚至直接打挂。
  2. 更可怕的是,重试还会存在链路放大的效应。假设现在场景是 Backend A 调用 Backend B,Backend B 调用 DB Frontend,均设置重试次数为 3 。如果 Backend B 调用 DB Frontend,请求 3 次都失败了,这时 Backend B 会给 Backend A 返回失败。但是 Backend A 也有重试的逻辑,Backend A 重试 Backend B 三次,每一次 Backend B 都会请求 DB Frontend 3 次,这样算起来,DB Frontend 就会被请求了 9 次,实际是指数级扩大。假设正常访问量是 n,链路一共有 m 层,每层重试次数为 r,则最后一层受到的访问量最大,为 n * r ^ (m - 1) 。这种指数放大的效应很可怕,可能导致链路上多层都被打挂,整个系统雪崩。

重试治理

  1. 动态配置
    如何让业务方简单接入是首先要解决的问题。如果还是普通组件库的方式,依旧免不了要大量入侵用户代码,且很难动态调整。字节跳动使用了middleware(中间件)模式,将业务代码和非业务代码进行了解耦。他们定义了一个middleware并在内部实现了对RPC的重复调用,把重复的配置信息用分布式存储中心进行存储,这样middleware可以读取配置中心的配置并进行重试,对用户来说不需要修改调用RPC的代码,而只需要在服务中引入一个全局的middleware即可。
    如下面的整体架构图所示,他们专门提供了配置的网页和后台,使用户能够在专门进行服务治理的页面很方便的对RPC进行配置修改并自动生效,内部的实现逻辑对用户透明,对业务代码无入侵。

    配置的纬度按照了字节的RPC调用特点,选定[调用方服务,调用房方集群,被调用方服务,被调用方方法]为一个元组,按照元组进行配置。Middleware中封装了读取配置的方法,在RPC调用的时候会自动读取并生效。
    这种middleware方式能够让业务方很容易接入,一次接入以后就具有动态配置的能力,可以很方便地调整或者关闭重试配置。
  2. 退避策略

    • 线性退避:每次等待固定时间后重试。
    • 随机退避:在一定范围内随机等待一个时间后重试。
    • 指数退避:连续重试时,每次等待时间都是前一次的倍数。(常用)
  3. 防止retry storm

    • 退避策略中添加适当的随机抖动
      添加抖动不是完全随机的抖动,我们需要使用某种方法,使得每次在同一台主机上产生相同的抖动。这样,如果服务超载或服务故障,则该主机的行为模式会完全相同。这样方便我们发现并分析问题产生的根本原因。
    • 限制单点重试
      我们可以基于断路器的思想,限制 请求成功/请求失败 的比率,给重试增加熔断功能。这里采用了滑动窗口的方法来实现,如下图,内存中为每一类RPC调用维护一个滑动窗口,窗口中含有10个bucket,每个bucket中记录了一秒内RPC的请求结果(成功/失败)。新的一秒到来时,生成新的bucket,并淘汰最早的一个bucket,只维持10s的数据。在新请求这个RPC失败时,根据前10s内的 失败/成功 是否超过阈值来判断是否可以重试。默认阈值可以设置为0.1,即下游最多承受1.1倍的QPS,用户可以根据需要自行调整熔断开关和阈值。
      image.png
    • 限制链路重试
      虽然有了重试熔断之后,重试不再是指数增长(每一单节点重试扩大限制了 1.1 倍),但还是会随着链路的级数增长而扩大调用次数,因此还是需要从链路层面来考虑重试的安全性。链路层面的防重试风暴的核心是限制每层都发生重试,理想情况下只有最下一层发生重试。
      Google SRE 中指出了 Google 内部使用特殊错误码的方式来实现:

      • 统一约定一个特殊的 status code ,它表示:调用失败,但别重试。
      • 任何一级重试失败后,生成该 status code 并返回给上层。
      • 上层收到该 status code 后停止对这个下游的重试,并将错误码再传给自己的上层。

      但该方法对业务代码有一定入侵,字节跳动内部用的 RPC 协议中有扩展字段,他们在 Middleware 中封装了错误码处理和传递的逻辑,在 RPC 的 Response 扩展字段中传递错误码标识 nomore_retry ,它告诉上游不要再重试了。Middleware 完成错误码的生成、识别、传递等整个生命周期的管理,不需要业务方修改本身的 RPC 逻辑,错误码的方案对业务来说是透明的。
      image.png
      在链路中,推进每层都接入重试组件,这样每一层都可以通过识别这个标志位来停止重试,并逐层往上传递,上层也都停止重试,做到链路层面的防护,达到“只有最靠近错误发生的那一层才重试”的效果。

    • 超时处理
      对于 A -> B -> C 的场景,假设 B -> C 超时,B 重试请求 C ,这时候很可能 A -> B 也超时了,所以 A 没有拿到 B 返回的错误码,而是也会重试 B , 这个时候虽然 B 重试 C 且生成了重试失败的错误码,但是却不能再传递给 A 。这种情况下,A 还是会重试 B ,如果链路中每一层都超时,那么还是会出现链路指数扩大的效应。
      因此为了处理这种情况,除了下游传递重试错误标志以外,还需要实现“对重试请求不重试”的方案。
      对重试请求,在request上打上一个特殊的retry flag,在上面的A->B->C的链路中,当B收到A的请求时,会先读取这个flag判断是不是重试请求,如果是,那么它调用C即使失败也不会重试,否则调用C失败后会重试C。同时B也会把这个retry flag向下传,它发出的请求也会有这个标志位,它的下游也不会再对这个请求重试。
      image.png
      这样即使A因为超时而拿不到B的返回,对B发出重试请求后,B能感知到并且不会对C重试,这样A最多请求r次,B最多请求r+r-1次,如果后面还有更下层的话,C最多请求r+r+r-2次,第i层最多请求i*r-(i-1)次,最坏情况下是倍数增长,不是指数增长了。加上实际还有重试熔断限制,增长的幅度要小很多。

    通过重试熔断来限制单点的放大倍数,通过重试错误标志链路回传的方式来保证只有最下层发生重试,又通过重试请求标志链路下传的方式来保证对重试请求不重试,多种控制策略结合,可以有效的降低重试放大效应。

  4. 结合DDL
    DDL是“deadline request调用链超时”的简称,在TCP/IP协议中的TTL用于判断数据包在网络中的时间是否太长而应该被丢弃,DDL与之相似,它是一种全链路式的调用超时,可以用来判断当前的RPC请求是否还需要继续下去。字节的基础团队实现了DDL功能,在RPC请求的调用链中会带上超时时间,并且每经过一层就减去该层处理的时间,如果剩下的时间已经小于0,则可以不需要再请求下游,直接返回失败即可。
    image.png
    DDL的方式能有效减少对下游的无效调用,在重试治理中结合DDL的数据,在每一次发起重试前都会判断DDL的剩余值是否还大于0,如果已经不满足条件了,那也就没必要对下游进行重试,这样能做到最大限度的减少无用的重试。

总结

在分布式系统中,不可避免的是瞬时故障或远程交互中的延迟。超时防止系统等待时间过长,重试可以掩盖这些故障,退避策略和添加抖动可以减少retry storm。
字节的重试治理系统,支持动态配置,无需入侵业务代码,并使用了多种策略控制重试放大效应,兼顾易用性灵活性安全性

参考

https://aws.amazon.com/cn/bui...
https://www.infoq.cn/article/...


Xinli
1 声望0 粉丝