likes
comments
collection
share

一次不够,就再试一次:用Retry,让Golang函数重试轻而易举的轻量级神器

作者站长头像
站长
· 阅读数 1

我是 LEE,老李,一个在 IT 行业摸爬滚打 17 年的技术老兵。

在开始今天的话题之前,我想先从这段代码开始今天的内容分享。在日常的开发过程中,我们经常遇到需要对一些函数的执行过程进行重试的场景。

例如,我们需要调用一个远程服务,但由于网络原因,调用失败了,我们需要对这个调用进行重试。这时,我们可以使用循环来实现重试,但这样的代码会使我们的代码变得复杂且难以维护。今天,我将分享如何使用 Golang 的函数式编程来实现一个简单的重试机制。

我们的业务代码中经常会遇到如下类似的代码段:

func callRemoteService() error {
    var err error
    // 循环重试
    for i := 0; i < 3; i++ {
        // 调用远程服务
        err = doCall()
        if err == nil {
            return nil
        }
        // 休眠一秒, 再重试
        time.Sleep(time.Second)
    }
    // 返回最后一次错误
    return err
}

这段代码的逻辑很简单,就是对 doCall 函数进行重试,最多重试 3 次。在业务开发过程中,如果这样的代码很多,我们的代码就会变得非常复杂且难以维护。

那么,如何使用 Golang 的函数式编程来实现一个简单的重试机制呢?

事件背景

元宵节过完了以后,所有业务研发团队都开始了新一年的工作。在新的一年里,我们的业务团队也开始了新一年的业务开发工作。在业务开发过程中,我正在审查部分小伙伴们提交的代码,其中有一部分代码是针对年前需要进行熔断和调试的模块升级。

通过一段时间的审查,我发现了一个模块的代码存在一些问题。这个模块的代码中有很多地方都使用了循环重试的方式来调用远程服务。这样的代码让我感到非常不舒服,因为这样的代码不仅不够优雅,而且不易维护。

经过了解,我得知公司内部的 Golang SDK 中没有提供类似的功能。为了赶紧修复问题并追上年后的工作进度,我们只能采用这种开发方式。然而,通过对 GitHub 进行调研后,我发现了 retry-go 这个项目。这个项目基本满足了我们的需求,但在阅读代码后,我发现它并不太适合我们的业务场景。

痛点分析

在之前提到的代码中,我们使用了循环重试的方式来调用远程服务。这样的代码不仅不够优雅,而且不易维护。如果这样的代码很多,我们的代码就会变得非常复杂。

考察以下代码片段:

func callRemoteService() error {
    var err error
    // 循环重试
    for i := 0; i < 3; i++ {
        // 调用远程服务
        err = doCall()
        if err == nil {
            return nil
        }
        // 休眠一秒, 再重试
        time.Sleep(time.Second)
    }
    // 返回最后一次错误
    return err
}

以下是需要解决的问题和痛点:

  1. 重复编写 for 循环,判断 doCall 的返回值,成功则返回,否则休眠后重试。
  2. 重试次数固定,不够灵活。
  3. 重试等待间隔固定,不够灵活。
  4. 代码写法单一,复用性低。
  5. doCall 的返回值可能不是 error 类型,处理不方便。
  6. 编写模式单一,难以与复杂代码整合。

为解决以上问题,需要具备以下特点的工具:

  1. 可对任意函数进行重试调用。
  2. 可设置重试次数。
  3. 可设置重试间隔时间,最好自动控制间隔。
  4. 可设置重试条件,包括指定类型的错误重试。
  5. 支持多种开发模式,如函数式编程、面向对象编程等。
  6. 支持处理函数返回值,如处理或过滤返回值。
  7. 提供统一接口,方便使用。

项目介绍

Retrygithub.com/shengyanli1…

一次不够,就再试一次:用Retry,让Golang函数重试轻而易举的轻量级神器

Retry 是一个基于 Golang 的函数式编程库,提供了对函数进行重试调用的功能。

Retry 项目的目标是提供一个简单易用的函数式编程库,让开发者可以更加方便地对函数进行重试调用。

Retry 能够提供以下功能:

  1. 指定的重试次数。
  2. 特定错误的指定次数。
  3. 支持动作回调函数。
  4. 支持延迟抖动因子。
  5. 支持指数回退延迟、随机延迟和固定延迟。
  6. 支持每次重试失败的详细错误。

Retry 支持两种工作模式,分别解决不同的问题和需求:

  • 单例模式:提供最简单的使用方式。引入函数包 retry 后,直接调用 Do 或者 DoWithDefault 函数即可。不论在 struct 的方法中还是在普通函数中,都可以直接调用 Do 或者 DoWithDefault 函数。
  • 工厂模式:提供更加灵活的使用方式。通过 New 函数创建一个 Retry 对象,然后调用 TryOnConflict 函数即可。这个适用于一些复杂的场景中使用。

TipsRetry 中的 单例模式 实际上也是通过 工厂模式 实现的,只是对外提供了一个更加简单的使用方式。

依托这两种工作模式,Retry 可以帮助我们解决大部分的重试问题。当然,如果你有更加复杂的需求,也可以通过 Retry 提供的接口进行扩展。

尽管在 GitHub 上可以找到很多类似的库,但我发现这些库要么功能过于复杂,要么功能过于简单,要么代码过于复杂,要么代码过于简单。因此,我决定从零开始,自己动手实现一个轻量级的函数式重试库,这就是 Retry

我的设计初衷:

  • 轻量Retry 是一个代码量非常少的轻量级库,只有几百行代码。
  • 简单Retry 的使用非常简单,只需要几行代码即可完成任务处理逻辑编写。
  • 高效Retry 的执行效率非常高,可以快速、高效地处理各种重试任务。

架构设计

为了让 Retry 简单易用且高效执行,它的架构设计必须简洁可靠。

一次不够,就再试一次:用Retry,让Golang函数重试轻而易举的轻量级神器

算法设计

退避模块

Retry 项目中,重点是实现 backoff 这个退避模块,而这个模块的设计重点在于如何根据重试次数生成具备随机能力的退避时间。Retrybackoff 实现了若干个退避方法,包括指数退避、随机退避和固定退避。

  • FixBackOff: 固定退避方法
  • RandomBackOff: 随机退避方法
  • ExponentialBackOff: 指数退避方法

TipsRetry 项目最终采用的是混合模式的 backoff,具体方法是 CombineBackOffs,它将多个退避方法进行组合,然后生成多个重试间隔。

延迟计算

延迟计算 是每次函数执行过程中根据重试次数计算延迟时间的模块。它受到 backoffjitterfactorinitDelay 等参数的影响。

也就是说,通过上面提到的 退避模块 中的方法,我们可以计算出每次重试的延迟时间。

计算公式如下:

backoff = backoffFunc(factor * count + jitter * rand.Float64()) * 100 * Millisecond + delay

上面的 backoffFunc 就是 退避模块 中的方法,factor 是延迟抖动因子,count 是重试次数,jitter 是随机延迟,rand.Float64() 是随机数,delay 是初始延迟。

当然,你可以通过 Config 结构体中的 WithFactorWithJitterWithInitDelay 方法来设置这些参数。最重要的是可以使用 WithBackOffFunc 方法来设置回退延迟方法,这样就可以实现自定义的延迟计算。

接口设计

Retry 的接口设计也非常简洁,只有几个接口,但这些接口可以帮助我们完成大部分处理重试任务。Retry 的属性控制通过 Config 结构体来实现,通过 WithXXX 方法来设置属性。

配置选项

  • WithCallback: 设置回调函数。
  • WithContext: 设置上下文,可以使用这个 Context 来取消重试任务。
  • WithAttempts: 设置重试次数。
  • WithAttemptsByError: 设置特定错误的重试次数。
  • WithFactor: 设置延迟抖动因子。
  • WithInitDelay: 设置初始延迟。
  • WithJitter: 设置随机延迟。
  • WithRetryIfFunc: 设置重试条件。
  • WithBackOffFunc: 设置指数回退延迟。
  • WithDetail: 设置是否返回每次重试失败的详细错误。

方法接口

Retry 的方法接口也非常简洁,只有几个方法,非常容易上手。

单例模式

  • Do: 执行重试函数。
  • DoWithDefault: 执行重试函数,使用默认配置。

工厂模式

  • New: 创建一个 Retry 对象。
  • TryOnConflict: 执行重试函数。

Callback

  • OnRetry: 在重试函数执行完毕时调用,入参为重试次数、当前被延迟时长和错误。
// Callback 方法用于定义重试回调函数
// The Callback method is used to define the retry callback function.
type Callback interface {
	OnRetry(count int64, delay time.Duration, err error)
}

返回结果

当你使用 Retry 执行一个函数时,经过一段时间的执行和多次重试后,最终会返回一个 Result 结构体,其中包含函数的执行结果和错误信息。

// data 为执行结果,tryError 为尝试执行时的错误,execErrors 为执行过程中的错误
// data is the result of the execution, tryError is the error when trying to execute, and execErrors is the error during the execution.
type Result struct {
	count      uint64
	data       any
	tryError   error
	execErrors []error
}

包含如下方法:

  • Count: 获取重试次数。
  • Data: 获取执行结果。
  • TryError: 获取 Retry 执行过程中的错误。
  • ExecErrors: 获取执行函数返回的所有错误(多次重试的错误)。
  • LastExecError: 获取最后一次函数执行的错误。
  • IsSuccess: 判断 Retry 是否执行成功。

提示: 在创建 Retry 对象时,可以通过 WithDetail 方法设置是否返回每次重试失败的详细错误。如果设置了 WithDetail 方法,那么在 Retry 执行完毕后,可以通过 Result 结构体的 ExecErrors 方法获取执行过程中的所有错误。

使用示例

下面通过简单的示例来演示 Retry 的使用方法。

单例模式

在这段代码中,我定义了一个重试函数 testFunc,然后使用 RetryDoWithDefault 方法来执行该函数。在示例中,将重试次数设置为默认值(3 次),使用默认的回退策略,最后打印函数的执行结果。

package main

import (
	"fmt"

	"github.com/shengyanli1982/retry"
)

// retryable function
func testFunc() (any, error) {
	return "lee", nil
}

func main() {
	// retry call
	result := retry.DoWithDefault(testFunc)

	// result
	fmt.Println("result:", result.Data())
	fmt.Println("tryError:", result.TryError())
	fmt.Println("execErrors:", result.ExecErrors())
	fmt.Println("isSuccess:", result.IsSuccess())
}

输出结果

$ go run test.go
result: lee
tryError: <nil>
execErrors: []
isSuccess: true

工厂模式

在这段代码中,我定义了两个重试函数 testFunc1testFunc2,一个成功,一个失败。然后使用 RetryNew 方法创建一个 Retry 对象,然后使用该对象的 TryOnConflict 方法来执行 testFunc1testFunc2 两个函数。在示例中,将重试次数设置为默认值(3 次),使用默认的回退策略,最后打印函数的执行结果。

package main

import (
	"errors"
	"fmt"

	"github.com/shengyanli1982/retry"
)

// retryable function
func testFunc1() (any, error) {
	return "testFunc1", nil
}

func testFunc2() (any, error) {
	return nil, errors.New("testFunc2")
}

func main() {
	// retry with config
	r := retry.New(nil)

	// try on conflict
	result := r.TryOnConflict(testFunc1)

	// result
	fmt.Println("========= testFunc1 =========")
	fmt.Println("result:", result.Data())
	fmt.Println("tryError:", result.TryError())
	fmt.Println("execErrors:", result.ExecErrors())
	fmt.Println("isSuccess:", result.IsSuccess())

	// try on conflict
	result = r.TryOnConflict(testFunc2)

	// result
	fmt.Println("========= testFunc2 =========")
	fmt.Println("result:", result.Data())
	fmt.Println("tryError:", result.TryError())
	fmt.Println("execErrors:", result.ExecErrors())
	fmt.Println("isSuccess:", result.IsSuccess())
}

输出结果

$ go run test.go
========= testFunc1 =========
result: testFunc1
tryError: <nil>
execErrors: []
isSuccess: true
========= testFunc2 =========
result: <nil>
tryError: retry attempts exceeded
execErrors: []
isSuccess: false

总结

Retry 是一个轻量级的库,用于对函数进行重试调用。它非常简单、易用、高效,上手成本低,学习时间短。

Retry 支持两种工作模式:单例模式工厂模式。通过这两种模式,Retry 可以解决大部分重试问题。

通过设计和实现 Retry,我们标准化了重试操作过程,实现了逻辑代码的多处复用。这大大减少了重复编写代码的时间,提高了开发质量,让整项目代码看起来更加清爽而不油腻,更加简洁而不简单

最后,如果您有任何问题或建议,请在 RetryGitHub 上提出 issue。我将尽快回复您的问题。