头图

由许多相互通信的小服务组成的高度分布式应用程序越来越流行,在我看来,这是一件好事。 但是这种架构风格带来了一类在单体应用中不太常见的新问题。 考虑当一个服务需要向另一个服务发送请求时,而这第二个服务恰好暂时离线,或者太忙而无法响应会发生什么。 如果一个小服务在错误的时间下线,可能会产生多米诺骨牌效应,可能会导致整个应用程序宕机。

在本文中,我将向你展示一些技术,这些技术可以让你的应用程序对相关服务中的故障具有一定程度的容忍度。 基本概念很简单:我们假设在大多数情况下这些失败是暂时的,因此当操作失败时,我们只需重复几次,直到它有希望成功。 听起来很容易,对吧? 但与大多数事情一样,细节决定成败,所以如果你想学习如何实施稳健的重试策略,请继续阅读。

出于本文的目的,我们假设我们有一个使用微服务构建的分布式应用程序,其中每个微服务都公开一个 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


PythonCN
3 声望1 粉丝

感谢订阅,我是大鹏。人生苦短,我用 Python。