浅谈Retry设计模式及在前端的应用与实现

道汐

引子

当一个网页刷不出来的时候,我们常常会本能得刷新一下。这就是一个最简单的 retry (重试)。有时重试一次就加载成功了,有时需要几次,有时需要隔半个小时再来尝试,有时再怎么尝试也没有用。在软件工程的世界里,发生这样失败与重试的场景不仅仅是加载网页,它还可以是向数据库读取数据,向某个微服务请求资源,或是向云端的某些服务发送计算请求等。尤其在今天这个纷纷向云上迁移,走向万物互联的时代,这样的场景正在变得越来越多。一个聪明完善的Retry机制可以使系统变得更有韧性,并减少冗余的报错。在客户端,它还可以提高用户体验。这篇文章会以前端为应用背景介绍一些retry相关的知识和个人经验,它们将涉及:

  • 什么情况下要 retry:认识并鉴别Transient fault(短暂故障)
  • 如何Retry: 一些 retry 设计模式
  • Retry 设计模式在客户端的应用与实现
  • 监控:更好地在运行时了解你的系统

如果你感兴趣,就请继续往下看吧~

什么情况下要 retry: 认识并鉴别Transient fault(短暂故障)

什么是Transient fault(短暂故障)

Transient fault(短暂故障),顾名思义,它是一个短暂存在,并且在一段时间后会被修复的故障。这个时间可以短到几毫秒也可以长达几小时。如果我们的请求是因为一个这样的故障而失败的,那我们在适当的时候重试就可以了。在前端应用中,短暂故障往往发生在你向服务端请求资源时。比如你向一个API发送一个AJAX请求,对面返回一个 “5XX” 的响应。

如何鉴别短暂故障

造成请求失败的原因有很多。它可以是因为服务端内部逻辑的错误,可以是因为客户端发送了一个糟糕的请求,也可以是由Infrastructure造成的一些短暂故障(比如暂时负载量过大,某个服务器的宕机,或网络问题等)。而只有在短暂故障的情况下进行retry才有意义。那么客户端如何鉴别短暂故障呢?最简单的方法是运用 HTTP 请求的响应码。根据规范, 400-499 之间是客户端造成的问题,500-599之间是服务端的故障。(完整响应码列表请参照 MDN文档)。显然,如果返回的错误码是4xx ,我们就没有必要重试了。那问题就缩小到了如何在5xx 的故障中鉴别出短暂故障。如果服务端对错误响应码有标准的定义,我们就可以通过不同的号码得知错误的原因,从而决定是进行retry还是做别的处理。这同时也说明服务端开发中标准并清晰的定义错误码和给与错误信息的重要性。

如何retry: 一些 retry 设计模式

认识了Transient fault,当请求失败时我们可以有一个基本的处理步骤:

  1. 鉴别是不是 transient fault
  2. 如果是,启动retry机制,进行一定次数的retry
  3. 当retry达到最大次数还没有成功,报错并说明原因:服务端暂时无法响应。

那么如何retry ?retry机制可以各种各样,我们应该根据需求的和失败原因的不同来选择不同的retry机制。下面让我们来看一些一些基本的retry设计模式。

简单的立即retry

当请求失败,立即retry,是我们会想到的最直接的方法。它可以被用于一些不常见的失败原因,因为原因罕见,立刻retry也许就修复了。但当碰到一些常见的失败原因如服务端负载过高,不断的立即retry只会让服务端更加不堪重负。试想如果有多个客户端instance在同时发送请求,那越是retry情况就越糟糕。

有延迟的retry

在遇到上面的失败原因,与其立即retry, 倒不如等待一会,也许那时服务端的负载就降下来了。这个 delay(延迟)的时间可以是一个常量,也可以是根据一定数学公式变化的变量。比如我们可以使用一些逐次增加delay算法。这样的算法就好像我们不断地去服务端敲敲门,每次没人开门,我们就意识到自己太急了,那下次来之前就再多等一会好了,因为再一次的失败也是在说明情况还没有得到缓解。Exponential Backoff (指数后退算法)就是一个这个类型的算法,它以指数的方式来增加delay。打个比方就是:第一次失败等待1秒,第二次再失败等待2秒,接下去4秒,8秒...。

上面的例子被广泛使用,但也不一定适应每一个场景。我们可以根据自己系统的特性和业务的需求,设计更适合更优化的算法。

Circuit Breaker(断路器)

那如果Transient fault 修复的时间特别长怎么办?比如长时间的网络问题,那就算我们有再好的retry机制,也免不了是徒劳。我们只会一次又一次地retry, 失败,再retry, 直到达到上限。这一来浪费资源,二来或许又会干扰服务端的自我修复。别担心,Circuit Breaker (断路器)的设计模式也许能拯救我们。它的原意其实就是电路中的开关,大家在中学物理中应该都有接触过。在电路里一旦开关断开,电流就别想通过了。那么在计算机系统中,我们就可以想象一旦开关断开,我们就不会再发送任何请求了。

Circuit Breaker在retry机制中的应用是一个状态机,有三种状态:OPEN,HALF-OPEN, CLOSE。我们设定一个threshold(阈值)和一个timeout,当retry的次数超过这个threshold时,我们认为服务端进入一个较长时间的Trasient fault。那么我们就开关断开,进入OPEN 状态。这时我们将不再向服务端发送任何请求,就像开关断开了,电流(请求)怎么也不会从客户端流向服务端。当过了一段时间到了timeout,我们就将状态设为HALF-OPEN( 这时电路中不具备的),这时我们会尝试把客户端的请求发往服务端去试探它是否已经恢复。如果是就进入CLOSE状态,回到我们正常的机制中,如果不是,就再次进入OPEN 状态。

这个机制既节约了资源,防止了无休止的无用的尝试,又保证了在修复后,客户端能知晓,并恢复的正常的运行。

以上是一些经典的设计模式,它为我们设计retry机制提供了范本和思路。在不同的应用场景下我们可以根据需求灵活地变换。也可以对上面的机制加以修改,设计出更适合自己的版本。

Retry 设计模式在客户端的应用与实现

在服务端,Retry 的机制被大量运用,尤其是在云端微服务的架构上。很多云平台本身就提供了主体(比如服务,节点等)之间的retry机制从而提高整个系统的稳定性。而客户端,作为一个独立于服务端系统之外,运行在用户手机或电脑上的一个App, 并没有办法享受到平台的这个功能。这时,就需要我们自己去为App加入retry机制, 从而使整个产品更加强壮。

npm 有一个 retry 的包可以帮助我们快速加入retry机制,具体如何使用可以参照 https://www.npmjs.com/package...

其实retry的实现并不复杂,我们完全可以自己写一个这样的工具供一个或多个产品使用。这可以让我们更容易更改其中的算法来适应产品的需求。

下面是我写的一个简单的retry小工具,由于我们向服务端做请求的函数常常是返回promise的,比如 fetch 函数 。这个工具可以为任何一个返回promise的函数注入retry机制。

// 这个函数会为你的 promiseFunction (一个返回promise的函数) 注入retry的机制。
// 比如 retryPromiseFunctionGenerator(myPromiseFunction, 4, 1000, true, 4000)
// 会返回一个函数,它的用法和功能与 myPromiseFunction 一样。但如果 Promise reject 了,
// 它就会进行retry, 最多retry 4 次,每次时间间隔指数增加,最初是1秒,随后2秒,4秒,由于
// 我们设定最大delay是4秒,那么之后就会持续delay4秒,直到达到最大retry次数 4 次。而如果
// enableExponentialBackoff 设为 false, delay就会是一个常量1秒。
const retryPromiseFunctionGenerator = (
  promiseFunction, // 需要被retry的function
  numRetries = defaultNumRetries, // 最多retry几次
  retryDelayMs = defaultRetryDelayMs, // 两次retry间的delay
  enableExponentialBackoff = false, // 是否启动指数增加delay
  maxRetryDelayMs // 最大delay时间
) => async (...args) => {
  for (
    let numRetriesLeft = numRetries;
    numRetriesLeft >= 0;
    numRetriesLeft -= 1
  ) {
    try {
      return await promiseFunction(...args);
    } catch (error) {
      if (numRetriesLeft === 0 || !isTransientFault(error)) {
        throw error;
      }

      const delay = enableExponentialBackoff
        ? Math.min(
            retryDelayMs * 2 ** (numRetries - numRetriesLeft),
            maxRetryDelayMs || Number.MAX_SAFE_INTEGER
          )
        : retryDelayMs;

      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
};

这个小工具可以实现上面提到的设计模式中的第二种:有延迟的retry。你可以方便地调节retry的参数以及是否要应用Exponential Backoff 的算法。你也可以稍作修改让计算delay的function本身也作为一个参数传进来,从而让这个工具变得更灵活。

监控:更好地在运行时了解你的系统

现在我们的App拥有了retry机制,在客户端运行时,它变得更强壮了,一些失败的服务端请求并不能打到它。但作为开发者的你一定想知道它在用户手上retry了几次,什么时候retry的,最终失败了没有。这些信息不仅让我更好的了解用户的实际体验,它们也可以作为服务端性能的指标之一。实时对这些信息进行监控甚至可以让我们尽早的发现服务端的故障以减少损失。

客户端的监控是一个很大的话题,Retry信息的收集只是其中一个应用场景。那如何实现呢?很简单。我们可以在每一次执行retry时发送一条log(日志)其中包含你想了解的信息。然后运用第三方或公司内部的日志浏览工具去分析这些日志,从而获得许多有意思的指标。

举个例子,我们可以简单地监控retry log 的数量,如果突然激增,那就说明服务端也许出现了一些故障,这时候开发团队可以在第一时间做出反应修复故障,以免对大面积的客户造成影响。当然这不仅仅可以通过监控retry实现,我们也可以监控服务端的http请求失败的数量,这是题外话了^^

结束语

这次的话题好像不那么前端,但我觉得它很有意思。同时我也在慢慢探索写作的方向。如果你有任何前端或监控相关感兴趣的话题,或对我的建议,欢迎留言告诉我。最后,感谢你的阅读。

阅读 877
25 声望
4 粉丝
0 条评论
25 声望
4 粉丝
宣传栏