Skip to content

超时与重试

简介

在微服务架构中,一个大系统被拆分成多个微服务,微服务之间存在大量 RPC 调用,可能会因为网络抖动、网络设备(如 DNS 服务、网卡、交换机、路由器)不稳定等原因导致 RPC 调用失败,这时候使用重试机制可以提高请求的最终成功率,减少故障影响,让系统运行更稳定。
需要明白的是,"重试" 的前提是我们认为这个故障是暂时的,而不是永久的,否则重试没有任何意义。

最简单的重试应该如何做呢?能想到的最直接的办法就是在代码逻辑中捕获异常,但是这样做显然不够优雅。
这时就需要有一套完善的重试机制。

在实际开发过程中,很多故障是因为没有设置超时或者设置得不对而造成的。
而这些故障都是因为没有意识到超时设置的重要性而造成的。如果应用不设置超时,则可能会导致请求响应慢,慢请求累积导致连锁反应,甚至造成应用雪崩。
而有些中间件或者框架在超时后会进行重试(如设置超时重试两次),读服务天然适合重试,但写服务大多不能重试(如写订单,如果写服务是幂等的,则重试是允许的),
重试次数太多会导致多倍请求流量,即模拟了 DDoS 攻击,后果可能是灾难,因此,务必设置合理的重试机制,并且应该和熔断、快速失败机制配合。
在进行代码 Review 时,一定记得 Review 超时与重试机制

客户端和服务器端都应该设置超时时间,而且客户端根据场景可以设置比服务器端更长的超时时间。
如果存在多级依赖关系,如 A 调用 B,B 调用 C,则超时设置应该是 A > B > C,否则可能会一直重试,引起 DDoS 攻击效果。
不过最终如何选择还是要看场景,有时候客户端设置的超时时间就是要比服务器端的短,可以通过在服务器端实施降流/降级等手段防止 DDoS 攻击。

超时重试必然导致请求响应时间增加,最坏情况下的响应时间 = 重试次数 * 单次超时时间,这很可能严重影响用户体验,
导致用户不断刷新页面来重复请求,最后导致服务接收的请求太多而挂掉,因此除了控制单次超时时间,也要控制好用户能忍受的最长超时时间。

什么时候应该重试

重试模式适合解决系统中的瞬时故障,简单地说就是有可能自己恢复(Resilient, 称为自愈,也叫做回弹性)的临时性失灵,如网络抖动、服务的临时过载(典型的如返回了 503 Bad Gateway 错误)这些都属于瞬时故障。
重试模式实现并不困难,即使完全不考虑框架的支持,靠程序员自己编写十几行代码也能够完成。
在实践中,重试模式面临的风险反而大多来源于太过简单而导致的滥用。
我们判断是否应该且是否能够对一个服务进行重试时,应同时满足以下几个前提条件

仅在主路逻辑的关键服务上进行同步的重试,而非关键的服务,一般不把重试作为首选容错方案,尤其不该进行同步的重试

仅对由瞬时故障导致的失败进行重试
尽管很难精确判定一个故障是否属于可自愈的瞬时故障,但从 HTTP 的状态码上至少可以获得一些初步的结论。
譬如,当发出的请求收到了 401 Unauthorized 响应,说明服务本身是可用的,只是你没有权限调用,这时候再去重试就没有任何意义。
功能完善的服务治理工具会提供具体的重试策略配置(如 Envoy 的 Retry Policy),可以根据包括 HTTP 响应码在内的各种具体条件来设置不同的重试参数

仅对具备幂等性的服务进行重试
如果服务调用者和提供者不属于同一个团队,那服务是否幂等其实也是一个难以精确判断的问题,但仍可以找到一些总体上通用的原则。
譬如,RESTful 服务中的 POST 请求是非幂等的,而 GET、HEAD、OPTIONS、TRACE 由于不会改变资源状态,所以应该被设计成幂等的;
PUT 请求一般也是幂等的,因为 n 个 PUT 请求会覆盖相同的资源 n -1 次;
DELETE 也可看作是幂等的,同一个资源首次删除会得到 200 OK 响应,此后应该得到 204 No Content 响应。
这些都是 HTTP 协议中定义的通用的指导原则,虽然对于具体服务如何实现并无强制约束力,但我们自己在建设系统时,遵循业界惯例本身就是一种良好的习惯

重试必须有明确的终止条件,常用的终止条件有两种:

超时终止: 并不限于重试,所有调用远程服务都应该有超时机制以避免无限期的等待。
这里只是强调重试模式更加应该配合超时机制来使用,否则重试对系统很可能是有害的

次数终止: 重试必须要有一定限度,不能无限制地做下去,通常最多只重试 2 到 5 次。
重试不仅会给调用者带来负担,对于服务提供者也同样是负担,所以应避免将重试次数设得太大。
此外,如果服务提供者返回的响应头中带有 Retry-After,即使他没有强制约束力,我们也应该充分尊重服务端的要求,做个 "有礼貌" 的调用者

由于重试模式可以在网络链路的多个环节中去实现,譬如客户端发起调用时自动重试、网关中自动重试、负载均衡器中自动重试,等等,而且现在的微服务框架都足够便捷,只需设置一两个开关参数就可以开启对某个服务甚至全部服务的重试机制。
所以,对于没有太多经验的程序员,有可能根本意识到其中会带来多大的负担。
这里举个具体例子: 一套基于 Netflix OSS 建设的微服务系统,如果同时在 Zuul、Feign 和 Ribbon 上都打开了重试功能,且不考虑重试被超时终止的话,
那总重试次数就相当于它们的重试次数的乘积。
假设它们都重试 4 次,且 Ribbon 可以转移 4 个服务副本来计算,理论上最多会产生高达 4 * 4 * 4 * 4 = 256 次调用请求。

重试风险

重试是有风险的,重试虽然可以提高服务的稳定性,但重试不当可能会扩大故障。
重试会加大直接下游的负载。假设服务 A 调用服务 B,重试次数设置为 r(包括首次请求),当服务 B 高负载时很可能调用失败,这时服务 A 进行重试,服务 B 的调用失败,这时服务 A 进行重试,服务 B 的被调用量快速增大,最坏情况下可能放大到 r 倍,不仅不能请求成功,还可能导致服务 B 的负载继续升高,甚至重试请求直接打挂。

更可怕的是,重试可能导致服务雪崩。
假设服务 A 调用服务 B,服务 B 调用服务 C,均设置重试次数为 3.
如果服务 B 调用服务 C,请求并重试 3 次都失败,这时服务 B 会给服务 A 返回失败。
但是服务 A 也有重试的逻辑,服务 A 重试服务 B 三次,这样算起来,服务 C 就会被请求 9 次,实际上呈指数级扩大。
假设正常访问量是 n, 链路上一共有 m 层,每层重试次数为 r,则最后一层受到的访问量最大,为 n * r ^ (m - 1)
这种呈指数放大的效应是很可怕的,可能导致链路上多层服务都被重试请求打挂,整个系统雪崩

退避策略

一般来说,关于重试的设计都需要有一个重试的最大值,经过一段时间不断地重试后,就没有必须再重试了。
在重试过程中,每一次重试失败时都应该休息一会儿再重试,也可以打散上游重试的时间,这样可以避免因为重试过快而导致服务负担加重。

在重试的设计中,一般都会引入 Exponential Backoff 的策略,也就是所谓的 "指数级退避"。
在这种情况下,每一次重试所需要的休息时间都会成倍增加。决定等待多久之后再重试的方法叫做退避策略,常见的退避策略说明如下:

  • 线性退避: 每次等待固定时间后重试
  • 随机退避: 在一定范围内随机等待一定时间后重试
  • 指数退避: 连续重试时,每次的等待时间都是前一次的倍数

重试熔断策略

除了按照用户配置的退避策略进行重试外,更重要的是根据重试请求的成功率判断是否要继续重试。
如果服务不受限制地重试下游,很容易造成下游宕机。

实现的方案其实很简单,可以基于断路器的思路给重试增加熔断功能。
采用常见的滑动窗口的方法来实现,在内存中维护一个滑动窗口,比如窗口分为 5 个桶(bucket),每个桶记录 1s 内请求结果的数据(成功/失败)。
新的 1 秒到来时,生成新的桶,并淘汰最早的一个桶,只维持 5s 的数据。
在新请求失败时,根据前 5s 内的失败/成功比率是否超过阈值来判断是否可以重试

链路重试熔断

重试熔断虽然可以有效防止无效的重试请求,但是随着链路的级数增长,也会不断扩大调用的次数,所以需要从链路层面限制每层都发生重试。
主要有以下几种方法:

约定重试状态码:

链路层面防重试风暴的核心是限制每层都发生重试,理想情况下只有最下一层发生重试。
可以统一约定特殊的状态码,该状态码表示调用失败,不需要重试。任何一级服务重试失败后,都生成该重试状态码并返回给上层。
上层收到该状态码后停止对下游重试,并将错误码再传给自己的上层。
理想情况下只有最下一层发生重试,上游收到错误码后都不会重试。
约定重试状态码依赖于各层之间相互传递错误码,对业务代码有一定的侵入性。
同时,可能因为各种原因导致没有把下游拿到的错误码传递给上游

在协议中透传状态码

如果企业是自定义的 RPC 协议,可以在响应结果中通过协议扩展字段携带错误码(比如 no retry),RPC 组件实现错误码生成、识别以及传递等整个生命周期的管理。
这样对业务服务来说就是透明的了,所有的逻辑都由重试组件和 RPC 组件完成

响应结果中透传状态码有一个缺点: 如果出现请求超时,就会导致错误码无法传递,例如 A -> B -> C 的场景

假设 B -> C 超时,B 重试请求 C,这时候很可能 A->B 也超时了,所以 A 没有拿到 B 返回的错误码,还是会重试 B。
虽然 B 重试 C 且生成了重试失败的错误码,但是不能再传递给 A。
这种情况下,A 还是会重试 B,如果链路中每一层都超时,最终还是会出现链路指数扩大的效应

为了处理这种情况,可以在请求携带特殊的重试标识,在上面 A -> B -> C 的链路,当 B 收到 A 的请求时,会先读取这个标识判断请求是不是重试请求,如果是,那它调用 C 即使失败也不会重试;
否则调用 C 失败后会重试 C。同时,B 会把重试标识往下传,它发出的请求也会有这个标志,它的下游也不会再对这个请求重试。
这种方案可以实现 "对重试请求不再重试",参考下图

重试超时

在 TCP/IP 中,TTL(Time To Live) 用于判断数据包在网络中的时间是否太长而应被丢弃。
参考 TTL 设计,重试也可以设置全链路超时,简称 DDL(Deadline Request),用来判断当前的请求是否还需要继续下去

在请求调用链中带上 DDL 时间,并且每经过一层就减去该层处理的时间,如果剩下的时间已经小于等于 0,则可以不再请求下游,直接返回失败即可。
DDL 方式能有效减少对下游的无效调用,做到最大限度地减少无用的重试。