【系统设计】如何优雅地重试Retry

计算机领域里面我们遇到的问题80%可以通过重启或者重试来解决。

那还有剩下的20%呢?

剩下的20%不用解决啊,这个世界上哪能解决百分之百的问题。

故障的产生

当一个系统或者服务去调用另一个系统或者服务的时候,由于服务器坏掉,网络不好等各种各样,奇奇怪怪的原因,调用都有可能失败。设计一个从来不出错的系统几乎是不可能的,我们要做的应该是尽量减少系统故障的可能性,以及尽量使故障在可控范围内。

通常当用户发现有问题的时候,最简单也是最常用的方法就是重试Retry,(比如一个网站加载不出来的时候,最常见的方法就是不停地点刷新),而且在大多数情况下重试都会成功,尤其是一些部分或瞬态故障partial failure or transient issue。

但是,重试也有可能会导致很严重的问题,甚至会将整个服务弄崩掉。所以对这些可能产生的问题,我们需要使用一些方法来解决。

本文从客户端出发,讨论当错误发生时,客户端该如何优雅地处理,既能大概率保证自身请求能重试成功,又不增加调用服务的负担。

超时 Timeout

很多错误都是由于调用的系统用比正常情况下长很多的时间来处理这个请求,或者这个请求根本无法完成。如果客户端一直等下去的话,会浪费客户端的有限资源,比如内存、线程、网络端口等。所以通常客户端应该设置一个超时时间,即客户端等待一个请求完成的最长时间,超过这个时间,客户端直接放弃等待。

不要在一棵树上吊死,该放弃就得放弃。

最佳实践应该是对每一个远程调用,甚至是同一台机器里面不同进程之间的调用都设置一个超时时间。

而这个超时时间其实也决定了客户端啥时重试,因为客户端在超时后往往会选择重试。

说起来很简单,但是找到一个合适的超时时间却很困难。

设置超时timeout过长

  • 客户端的资源还是会一直被占用。

设置超时timeout过短

  • 如果超时设置过短,会导致大量请求重试,从而导致后端服务流量激增,增加延迟。
  • 后端服务整体一个很小的延迟增加就会导致整个系统崩掉,因为很多的请求都会由于整体小的延迟增加而超时重试。

那我们应该如何选择timeout呢?通常我们可以从下游服务downstream service的延迟metric入手来决定客户端的超时时间。如果我们的availability goal是99%(具体目标是一年有至少99%的时间我们可以顺利回应至少99%请求),那么我们就可以选择下游服务延迟的p99作为超时时间。

这个方法大多数情况下效果很好,但是还是得具体问题具体分析

  • 如果延迟的p50与p99十分接近,那当我们设置p99延迟为超时,如果整体服务延迟增加一点可能就会导致大量请求都超时。所以一般这种情况下需要增加一点buffer。
  • 有时我们也会根据业务逻辑设置跟正常情况不一样的超时时间,比如我们组之前用AWS 美国的服务器去调用中国地区的AWS S3,由于在不同的网络,以及防火墙,通常一次调用卡住了,大概率是不成功的,所以我们会设置一个很短的超时时间,如果卡住了就赶紧fail掉(fail fast),进行下一次重试。

重试Retries与退避Backoff

系统一般不会整个挂掉,通常只是部分或瞬态故障。所以重试一个失败的请求,大概率会成功。而且系统的压力也不会很大,因为只有一小部分请求会重试。

但是如果错误本身就是由于系统已经过载导致的,那么无限制重试会大幅度增加系统压力,会延迟系统从最初问题恢复的时间,甚至会导致整个系统彻底崩掉,俗称retry storm重试风暴。

水能载舟亦能覆舟,重试是一个很强大的方法,但是也存在一定的风险。我们需要权衡利弊,找到一个合理的重试策略。通常首选方法是限制重试次数外加退避Backoff。客户端不会立即重试,而是等待一段时间再重试,并且如果重试次数已经达到设置的上限,客户端会放弃重试。

下面是我们在AWS做的一些实验,假设一个服务同一时间只能满足一个用户请求,X轴为N个竞争用户同时调用这个服务,Y轴为完成所有用户调用时,该服务总共被调用次数。

可以看到如果没有退避的话,总共调用大概是N^2(第一次同时有N个调用只能成功一个,第二次同时有N-1,以此类推,总共调用次数大概(N+1)*N/2)

但当我们加入退避后,情况会有所改变。通常我们退避策略会使用封顶指数退避capped exponential backoff,即等待时间会随着等待次数指数增加直到设置的上限。

wait_time = min(upper_bound, base * 2 ** attempt)

可以看到使用封顶指数退避策略,总共调用次数有所下降。但如果我们看看不同时间对应的服务被调用次数,就会发现,每次重试时还是有很多用户在同时一起调用服务,而在其他时间,服务闲置没有用户调用。

抖动Jitter

根据上面的例子,如果故障是由过载或竞争引起的,单纯退避通常无法带来应有的帮助。因为如果所有失败的调用都退避到同一时间来重试这些调用,还是会导致竞争或过载。解决方法其实很简单就是增加一点随机性。

wait_time = random(0, min(upper_bound, base * 2 ** attempt))

可以看到抖动会给退避增加一定程度的随机性,以使重试在时间上有所分散,服务总共被调用次数大幅度降低。

重试其他挑战

在哪里重试?

分布式系统通常由多个分层或者多个小服务(microservice)组成。试想一下有这样一个系统:该系统中的客户调用导致了三层深的服务调用堆栈。它最终执行一次对数据库的查询,并在每层重试三次。如果在负载下数据库开始使查询失败,并且我们每层单独进行重试,那么每一层的重试次数都会成倍增加,首先是第一层3次尝试,然后第二层9次尝试,第三层27次尝试,那么数据库上的负载将激增 27 倍,导致恢复几乎成为不可能完成的任务。相反,如果只在堆栈的最高层重试可能会浪费以前已经成功的调用工作,尤其是在这些调用工作费时费力的情况下,从而降低效率。

通常我们只在堆栈中的单个点执行重试,但具体在哪里执行重试,需要具体问题具体分析。

即使在堆栈中的单个点执行重试,错误开始时流量仍然会显著增加。为了避免失败蔓延Limit Blast Radius,我们需要熔断机制Circuit Breakers,也就是在某个指标超过错误阈值时,完全停止对下游服务的调用 ,同时对所有请求(重试流量+正常流量)直接失败。我们还可以通过限流器Rate Limiter来限制调用服务的流量(正常调用 + 重试调用)。熔断机制与限流器主要发生在服务器端,所以这里就不继续展开了。

什么情况能重试?

我们一般认为幂等 idempotent(其任意多次执行所产生的影响均与一次执行的影响相同)的 API 才能保证重试的安全性。只读API一般都是idempotent,而资源创建的API通常不是idempotent,所以很难保证其重试的安全性。为了防止重试出现副作用,需要良好的 API 设计,并在实现客户端时要格外谨慎。

哪些故障值得重试?

HTTP 明确区分了客户端和服务器错误。我们不应该重试客户端错误,因为它们后续也不会成功,而服务器错误可能在后续尝试中成功。

但这也不是绝对的,分布式系统往往是最终一致性Eventual consistency而非强一致性。随着状态的传播,客户端错误可能在下一刻变为成功。

总结

当系统出现问题的时候,重试是最直接的也是很有效的一种解决方法。

但是重试也是一把双刃剑,不加限制的重试往往会导致整个系统的崩溃。

所以重试听起来简单,但是实现一个满足需求,高扩展性,高可用性的重试却很难。

[1] Timeouts, retries, and backoff with jitter

[2] Exponential Backoff And Jitter