由许多互相通信的小服务组成的高度分布式应用程序越来越风行,在我看来,这是一件坏事。然而这种架构格调带来了一类在单体利用中不太常见的新问题。思考当一个服务须要向另一个服务发送申请时,而这第二个服务恰好临时离线,或者太忙而无奈响应会产生什么。如果一个小服务在谬误的工夫下线,可能会产生多米诺骨牌效应,可能会导致整个应用程序宕机。
在本文中,我将向你展现一些技术,这些技术能够让你的应用程序对相干服务中的故障具备肯定水平的容忍度。基本概念很简略:咱们假如在大多数状况下这些失败是临时的,因而当操作失败时,咱们只需反复几次,直到它有心愿胜利。听起来很容易,对吧?但与大多数事件一样,细节决定成败,所以如果你想学习如何施行持重的重试策略,请持续浏览。
出于本文的目标,咱们假如咱们有一个应用微服务构建的分布式应用程序,其中每个微服务都公开一个 REST API。其中一个微服务为零碎的其余部分提供用户服务,该微服务执行的重要性能之一是验证客户端随其申请一起发送的令牌。任何承受来自内部客户端申请的微服务都须要将它们收到的令牌传递给位于 /users/me
URL 的用户服务,以便对其进行验证。当确定令牌无效时,用户服务将领有该令牌的用户资源返回给客户端。当无奈验证令牌时,将返回适当的 HTTP 响应,很可能是 401 响应以批示令牌有效。
以下 Python 函数是一个简略的包装器,它应用 requests 向用户服务发出请求:
def get_user_from_token(token):
"""Authenticate the user. Raises HTTPError on error."""
r = requests.get(USER_SERVICE_URL + '/users/me',
headers={'Authorization': 'Bearer' + token})
r.raise_for_status()
return r.json()['user']
须要验证令牌的微服务能够简略地调用下面的函数来获取与给定令牌对应的用户。如果令牌有效,则会引发异样,这将阻止申请运行。如果你要在 Flask 中编写微服务(如果我能够这么说,这是一个很好的抉择),你能够将令牌验证放在 before_request 处理程序中:
@app.before_request
def before_request():
auth = request.headers['Authorization'].split()
if auth[0] != 'Bearer':
abort(401)
g.user = get_user_from_token(auth[1])
在这里,我从 Authorization
头部中提取令牌,如果令牌失去验证,我会将它所属的用户保留在 g.user
中,以便 API 端点能够拜访它。
重试的奢侈办法
所以当初让咱们思考当用户服务须要降级到新版本时会产生什么。因为这是一个分布式系统,用户服务能够独自降级,而其余微服务持续失常运行。假如用户服务进行、加载新代码、进行任何必要的数据库降级和重新启动须要大概 10 秒的工夫。依照大多数规范,10 秒是相当短的降级停机工夫,然而,除非采取特定措施,否则整个零碎将无奈验证令牌,因而将回绝在降级过程中的那 10 秒内客户端发送的所有申请。
侥幸的是,有一个更好的解决方案。与无奈验证的失败申请不同,服务器能够将这些申请暂停一段时间,心愿导致用户服务失败的任何外部状况都能尽快失去解决。这实际上是单方最好的解决方案。对于客户端来说,没有显著的失败,只是有一点提早。而对于咱们在服务器端来说,咱们依然能够依据须要对服务进行降级或保护,而不用放心影响正在踊跃应用咱们服务的客户端。
那么咱们如何让应用程序期待一个没有响应的服务呢?这真的很简略,如果咱们从服务中失去一个失败,咱们 wait,或者 sleep 一会儿,而后反复申请。咱们能够多次重复这个申请,心愿它最终会胜利。重试逻辑能够合并到 get_user_from_token() 函数中:
# this are the HTTP status codes that we are going to retry
# 429 - too many requests (rate limited)
# 502 - bad gateway
# 503 - service unavailable
RETRY_CODES = [429, 502, 503]
def _get_user_from_token(token):
r = requests.get(USER_SERVICE_URL + '/users/me',
headers={'Authorization': 'Bearer' + token})
r.raise_for_status()
return r.json()['user']
def get_user_from_token(token):
"""Authenticate the user. Raises HTTPError on error."""
# run the request, and catch HTTP errors
try:
return _get_user_from_token(token)
except requests.HTTPError as exc:
# if the error is not retryable, re-raise the exception
if exc.response.status_code not in RETRY_CODES:
raise exc
# retry the request up to 10 times
for attempt in range(10):
time.sleep(1) # wait a bit between retries
try:
return _get_user_from_token(token)
except requests.HTTPError as exc2:
# once again, if the error is not retryable, re-raise
# else, stay in the loop
if exc2.response.status_code not in RETRY_CODES:
raise exc2
# if we got out of the loop that means all retries failed
# in that case we give up, and re-raise the original error
raise exc
在第二个版本中,我将理论申请逻辑移到了 _get_user_from_token()
辅助函数中(留神下划线前缀),而后在 get_user_from_token() 函数中实现了一个重试循环,如果初始申请失败,该循环将从新收回多达 10 次的身份验证申请,在两次尝试之间期待 1 秒钟。如果任何一次重试胜利,那么函数的调用者甚至不会晓得有失败,这很好,因为它将错误处理本地化到这个函数。
代码中的正文应该能够帮忙你了解所有细节,但我认为乏味的是,我并没有自觉地重试所有谬误,而是有选择地重试返回几个白名单状态码的申请。REST API 会返回批示各种不同后果的状态码,其中一些重试实际上没有意义。200-299 范畴内的代码都是胜利代码,300-399 代码是重定向,400-499 是客户端谬误,500-599 是服务器谬误。从所有这些中,我抉择了 3 个我认为可能胜利的重试。我真的不想浪费时间重试那些没有胜利心愿的谬误。在这个示例中,我将重试状态码 429(由速率限度导致)以及 502 和 503,它们都是代理服务器在指标服务离线时(例如在进行降级时)的常见响应。显然,值得重试的谬误可能因应用程序而异,因而须要为每个我的项目评估这些谬误。
通过这项改良,咱们使代码对依赖服务的故障具备更大的容忍度,并且咱们通过一个繁多的、本地化的更改实现了这项工作。应用程序的其余部分,最重要的是咱们的客户端,齐全没有意识到这种对他们无利的重试逻辑。
你可能认为咱们对本文曾经有了一个圆满的终局,但实际上,我有很多办法能够改良我刚刚介绍的重试机制。咱们才刚刚开始!
重试搅动
假如咱们要增加重试的这个应用程序是一个相当大的应用程序,有很多客户端。举个例子,让咱们假如用户服务均匀每秒接管 100 个申请,但如果须要,能够解决多达 200 个申请。应用上一节中的代码,让咱们从零碎的申请数以及胜利和失败的申请数的角度来模仿 10 秒的停机工夫:
在此图表中,蓝色示意申请胜利,而红色示意失败,稍后须要重试。该服务从 0 工夫标记开始离线十秒钟,因而在 0 到 10 之间的所有申请都是红色的。如您所见,这看起来很蹩脚。当服务筹备好从新上线时,申请和重试的数量迅速减少到惊人的每秒 1000 个申请,但新申请持续以失常的速度一直涌来,因而在从新上线后的第一秒内,服务队列中就有 1100 个申请,这导致它被锁定在每秒最多解决 200 个申请的状况下,再继续 10 秒能力赶上积压的申请,同时一直以均匀每秒 100 个的速度接管新申请。
因而,尽管重试策略有助于进步应用程序的健壮性,但咱们以后的解决方案在滥用无限资源方面还有很多不足之处。在下一节中,咱们将钻研一种不同的重试策略,它有一个听起来很酷的名字,即指数退却。
指数退却算法
对于重试次数过多的问题,一个不言而喻的解决方案是不要对它们过于激进。我能够将 sleep 语句从 1 秒更改为 5 秒,这将大大减少降级期间的申请流量。然而,尽管对于咱们虚构的用户服务来说 5 秒可能是正当的,但对于另一个停机工夫较长的服务来说可能依然太多了。基本上,我将被迫依据我对服务预计无奈响应申请的工夫和频率的理解,为每个服务独自微调重试循环。能够设想,这可能很难实用于每个服务。
通常应用的代替解决方案是基于指数退却算法。这个想法是每次间断重试的 sleep 工夫都会减少一些因素,这样指标服务离线的工夫越长,重试距离越大。回到咱们的示例用户服务,当服务无奈响应时,我能够像以前一样 sleep 1 秒钟,然而如果重试失败,那么我会在新尝试之前 sleep 2 秒钟。如果我再次失败,那么我会在下一次失败前 sleep 4 秒钟,依此类推。
通常用于计算给定尝试的 sleep 工夫的公式如下:
sleep_time = (2 ^ attempt_number) * base_sleep_time
此公式中的 2 是因子,如有必要,能够更改为另一个数字,例如,应用 3 将导致每次重试时睡眠工夫增加三倍而不是两倍。在某些实现中看到的另一个变体是增加最大 sleep 工夫,以确保在多次重试时提早不会太长:
sleep_time = min((2 ^ attempt_number) * base_sleep_time, maximum_sleep_time)
为了在我之前介绍的重试循环中引入指数退却,我只须要引入下面的公式之一来计算 sleep 工夫:
base_sleep_time = 1
def get_user_from_token(token):
# ...
for attempt in range(10):
time.sleep(pow(2, attempt) * base_sleep_time)
# ...
如果咱们实现指数退却重试,让咱们看看上一节中的图表如何变动:
这看起来好多了。在最坏的状况下,申请积压达到 400 个申请,与之前的 1100 个申请相比有很大不同。乏味的是,乏味的是,在第一种状况下,服务在 20 秒左右复原但在这种状况下,决定复原何时实现不太分明,因为不同的重试迭代强制重试散布在更长的工夫内。然而咱们能够分明地看到,在固定重试的状况下,服务必须以最大容量运行 10 秒能力赶上,并且随着指数退却,服务开始在 18 秒左右开始失去喘息,大概提前两秒。
因而,总体而言,应用指数退却算法更好,即便某些申请须要更长的工夫能力实现。然而你有没有留神到这个图表有多块?这是这种算法的一个常见问题,它偏向于在特定工夫进行个体重试,而不是使它们平均产生。这在 14 秒时更为显著,因为过后没有足够的重试来利用所有可用资源,因而解决的申请数量略低于 200 次下限。
减少一些抖动
帮忙更好地散发重试的一个很好的抉择是为 sleep 工夫增加一些随机性。一种常见的解决方案是在由指数退却算法确定的 sleep 工夫中增加一个随机重量。在以下示例中,退却工夫最多随机减少 25%。
from random import random
base_sleep_time = 1
def get_user_from_token(token):
# ...
for attempt in range(10):
time.sleep(pow(2, attempt) * base_sleep_time * (1 + random() / 4))
# ...
你能够在上面的图表中看到,指数退却的一些块状性实际上曾经通过这种技术失去了平滑:
另一种更简略的办法是应用从退却算法取得的 sleep 工夫作为最大值,并在 0 和该工夫之间随机化 sleep 工夫:
from random import random
base_sleep_time = 1
def get_user_from_token(token):
# ...
for attempt in range(10):
time.sleep(pow(2, attempt) * base_sleep_time * random())
# ...
这仿佛有悖常理,但正如你在下图中所看到的,这种技术能够生成更平滑的曲线,但代价是在停机期间会减少一些申请累积:
两个 sleep 随机数发生器函数哪个更好?这真的不好说,事实上,在我提出的两个选项之间进行抉择甚至不偏心,因为有更多的办法能够随机化 sleep 工夫,也可能会产生绝对类似的曲线。
翻译
How to Retry with Class