通过网络的请求可能可能会失败。这是我们无法避免的,为了编写强大的软件,我们需要处理这些故障,否则它们可能会作为错误呈现给用户。处理失败请求的最常见技术之一是重试。
在这篇文章中,我们将直观地探索重试请求的不同方法,展示为什么一些常见方法是危险的,并最终得出最佳实践。在这篇文章的结尾,您将对构成安全重试行为有一个深入的了解,并生动地了解什么是不安全的重试行为。
我们将重点关注您何时可以控制客户的行为。这篇文章中的建议同样适用于您向自己的后端服务或第三方服务发出请求时。当然我们不会讨论本文中描述的问题的任何服务器端缓解措施。
背景介绍
让我们介绍一下可视化中涉及的元素。我们有:
- 请求可以被认为是 HTTP 请求。他们可能成功,也可能失败。失败的请求有峰值流量,成功的 请求保持平滑。
- 负载均衡器将请求从客户端路由到服务器。
- 服务器接受并服务请求。
- 客户端通过负载均衡器向服务器发送请求。收到响应后,他们会等待一段时间,然后再发送另一个请求。
我们有一个客户端定期向一台服务器发送请求。您可以想象这是一个客户端定期检查某些后台作业的状态。该请求通过负载均衡器,该负载均衡器选择将请求发送到哪个服务器 。请求成功或失败,您可以在返回客户端时看到。当客户端等待发送下一个请求时,它显示为循环计时器。
基本重试处理
处理失败的最简单方法就是什么也不做。在此可视化中,服务器90%发生故障时,每个客户端只是在请求失败之后,再次简单地发送其下一个请求。由于中间没有任何时间间隔,如果所有的客户端都发生这种行为,这会导致服务端爆炸,爆炸代表的是服务器过载和崩溃。然后它会在几秒钟后重新启动。在现实世界中,这种情况可能因各种原因而发生,从进程内存不足到仅在压力下发生的罕见错误。通常,服务器会有请求队列,当服务器有太多工作要做时,这些请求队列会拒绝请求,但为了简单起见,我们使用过载来表示任何潜在的故障模式。
一旦服务器崩溃一次,重试产生的额外负载可能会使其难以恢复。当它恢复时,它可能会很快被淹没并再次崩溃。随着规模的扩大,这个问题会变得更严重。
您可能会看到,随着客户端开始重试,流量开始增加。最终,其中一台服务器将崩溃。一旦一台服务器失效,剩下的两台服务器将无法处理新的负载。然后开始继续陷入崩溃的漩涡。
延迟重试
因此,在紧密循环中重试是有问题的,我们已经了解了原因。人们要做的下一件事是在每次重试之间添加延迟。重试 10 次,sleep(1000) 中间间隔 1 次。您应该注意到这里的模式与直接重试之间的区别就是没有设置时延。这可能需要更长的时间,但它依然会发生崩溃。如果您的客户端重试的速率不高于它们通常发送请求的速率,您将看到总体负载增加。只要服务器不太可能过载,并且如果发生过载,它也能够轻松恢复,那么这种方法就“有效”。但这在实践中会导致糟糕的用户体验。用户不喜欢等待,并且重试之间的睡眠时间越长,他们就越有可能手动刷新或去做其他事情。都是不好的结果。
我们需要一种重试方法,可以在错误概率较低的情况下快速重试,从而保护用户体验,但可以识别出真正的错误并等待更长时间以防止出现不可恢复的过载。
更好的答案是什么呢?
我们需要“指数退避”。在计算指数退避时,您可以配置很多东西,但如果您想象我们开始等待 1 秒,每次重试等待两倍的时间,那么 10 次重试将如下所示:
代码语言:javascript复制1秒
2秒
4秒
8秒
16秒
32秒
1分4秒
2分8秒
4分16秒
8分32秒
这将是一个巨大的等待时间,因此在实践中,指数退避被调整为低于 1 秒的启动时间,并且通常具有较低的乘数。例如, Google 的Java HTTP 客户端库从 0.5 秒开始,乘数为 1.5。这会产生以下重试间隔:
代码语言:javascript复制0.5秒
0.75秒
1.125秒
1.687秒
2.53秒
3.795秒
5.692秒
8.538秒
12.807 秒
19.210 秒
足够的数学知识,这在实践中看起来如何?以下所有示例均使用 Google HTTP 库退避默认值(0.5 秒初始延迟,1.5 乘数)。
一旦请求量增加,当重试这些请求时,您会注意到回退开始,事情会平静下来。服务器可能会崩溃, 但客户端会为其提供恢复空间。
抖动
我们已经看到了指数退避的威力,但我们还可以通过重试做最后一件事,使它们成为真正的最佳实践。
“抖动”是将重试之间等待的时间随机化到特定范围内的过程。为了遵循 Google HTTP 客户端库示例,他们添加了 50% 的抖动。因此,重试间隔可能比计算值低 50% 到高 50%。以下是这对我们之前的数字的影响:
代码语言:javascript复制0.5秒,±0.25秒
0.75秒,±0.375秒
1.125秒,±0.5625秒
1.687秒,±0.8435秒
2.53秒,±1.265秒
3.795秒,±1.8975秒
5.692 秒,± 2.846 秒
8.538 秒,± 4.269 秒
12.807 秒,± 6.4035 秒
19.210 秒,± 9.605 秒
这种抖动有助于防止客户端相互同步并发送大量请求。
代码实现
因此,您已经阅读了这篇文章,并意识到您要么没有利用重试,要么正在危险地进行重试。下面是一些示例 Go 代码,它实现了我们构建的重试策略(带抖动的指数退避),您可以在自己的项目中使用。
代码语言:javascript复制package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/cenkalti/backoff/v4"
)
func main() {
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = 500 * time.Millisecond
bo.Multiplier = 1.5
bo.RandomizationFactor = 0.5
err := backoff.Retry(func() error {
resp, err := http.Get("https://jsonplaceholder.typicode.com/todos/1")
if err != nil {
return err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
fmt.Printf("% vn", result)
return nil
}, bo)
if err != nil {
fmt.Println("Request failed:", err)
}
}
总结
我希望这篇文章能够帮助您直观地了解不同的重试行为在实践中的工作原理,并让您对故障模式有一个良好、直观的理解。我们不能总是避免失败,但我们可以让自己在失败发生时拥有最好的恢复机会。
回顾一下我们所学到的知识:
- 在紧密循环中重试是危险的。您可能会面临陷入难以恢复的超载情况的风险。
- 延迟重试会有所帮助,但仍然很危险。
- 指数退避是一种更安全的重试方式,可以平衡用户体验与安全性。
- 抖动增加了额外的保护层,防止客户端发送同步请求激增。