从 0 开始实现一个 Golang 重试库

1,120 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

开篇

微服务的时代,对于复杂的业务来说,最前面的业务接口层经常需要调用多个服务,组装数据返回,每个下游之后又有下游,看起来一个简单的客户端请求,背后可能同时依赖了上百个微服务。

意味着,任何一个服务抖动,都可能影响发出请求客户端的体验,主要是两方面:

  1. 请求耗时;
  2. 能否得到符合预期的响应。

微服务的优势就在于解耦,作为上游,我可以完全不case下游具体的逻辑,实现的框架,语言,乃至更下游的依赖。只要按照约定的接口处理请求即可。但这同时也意味着,对于下游服务的稳定性没有把控。如果你的下游只能提供3个9的稳定性,但你作为核心服务需要达到4个9。就不能只是简单的直接调用了。

常见的解法有两种:1. 缓存;2. 重试。

今天我们来聊聊怎样用 Golang 实现一个与业务模型无关的重试库。

(如果想直接使用,可以直接看 go-retry,欢迎PR)

重试入口

我们希望开发者调用重试能力的成本最低,提供一个需要重试的函数即可,通过 error 返回值来进行重试条件判断。


// 需要重试的函数签名
type RetryFunc func() error


// go-retry 暴露的对外 API,调用方传入一个 RetryFunc 即可
func Do(ctx context.Context, fn RetryFunc) error

重试配置

思考一下,我们要实现的重试库要有什么样的能力呢?

假设我们有一个请求下游的函数:func CallDownstream() error

一个最朴素的想法是,我重试三次,如果 error == nil 就意味着成功,我就停止重试

var err error
for i := 0; i < 3; i++ {
  err = CallDownstream()
  if err == nil {
    break
  }
}

if err != nil {
  // 重试到最后依然不成功
  fmt.Println(err.Error())  
}

如果场景足够简单,这样处理也说不上错。

但作为一个通用库来说,需要考虑的问题更多:

  • 重试的次数是否可以配置?
  • 停止重试的信号,如果除了 err == nil,还有更细的粒度需要判断呢?
  • 完成一次重试之后,如果不想立即重试,而是间隔一小段时间呢?
  • 是否可以设置总体超时时间?
  • 如果 CallDownstream() 函数中抛 panic 了怎么办,要 recover 么?

重试库提供的能力关键在于一个可配置重试策略的容器,应该提供 generally 较优的默认配置,同时使用方应该可以根据自身诉求来调整策略。

作为一个基础库,我们希望提供如下配置能力:

  1. 全局超时时间,超出了就退出,给出对应错误码;
  2. 最大重试次数,避免无限重试;
  3. backoff 策略,即每次重试后间隔多长时间发起下次重试,打散请求,避免打挂下游;
  4. 判断是否重试,由业务方指定面对 error 时是否要继续重试;
  5. 重试前后的钩子函数;
  6. 出现 panic 是否要recover。
package goretry

import (
	"errors"
	"time"
)

type Config struct {
	MaxRetryTimes int
	Timeout       time.Duration
	RetryChecker  func(err error) (needRetry bool)
	Strategy      Strategy // backoff 策略
	RecoverPanic  bool
	BeforeTry     HookFunc
	AfterTry      HookFunc
}

var (
	DefaultMaxRetryTimes = 3
	DefaultTimeout       = time.Minute
	DefaultRetryChecker  = func(err error) bool {
		return !errors.Is(err, ErrorAbort) // not abort error, should continue retry
	}
)

func newDefaultConfig() *Config {
	return &Config{
		MaxRetryTimes: DefaultMaxRetryTimes,
		RetryChecker:  DefaultRetryChecker,
		Timeout:       DefaultTimeout,
		BeforeTry:     func() {},
		AfterTry:      func() {},
	}
}

option 扩展

type Option func(c *Config)

绝大多数开发者是用不到所有的配置的,每次都传入一个 config 对象显然比较笨重。这里我们采用经典的 option 模式,开发者按需传入一个 WithXXX option 即可进行对应项的配置。

根据我们上面提供的配置项,对应到 option 能力即为:


// 设置全局超时
func WithTimeout(timeout time.Duration) Option {
	return func(c *Config) {
		c.Timeout = timeout
	}
}

// 设置最大重试次数
func WithMaxRetryTimes(times int) Option {
	return func(c *Config) {
		c.MaxRetryTimes = times
	}
}

// 设置是否需要 recover panic
func WithRecoverPanic() Option {
	return func(c *Config) {
		c.RecoverPanic = true
	}
}

// 设置重试前钩子
func WithBeforeHook(hook HookFunc) Option {
	return func(c *Config) {
		c.BeforeTry = hook
	}
}

// 重试后钩子
func WithAfterHook(hook HookFunc) Option {
	return func(c *Config) {
		c.AfterTry = hook
	}
}

// 设置判断是否需要重试的函数 retryChecker

type RetryChecker func(err error) (needRetry bool)

func WithRetryChecker(checker RetryChecker) Option {
	return func(c *Config) {
		c.RetryChecker = checker
	}
}

// 设置 backoff 策略
func WithBackOffStrategy(s BackoffStrategy, duration time.Duration) Option {
	return func(c *Config) {
		switch s {
		case StrategyConstant:
			c.Strategy = Constant(duration)
		case StrategyLinear:
			c.Strategy = Linear(duration)
		case StrategyFibonacci:
			c.Strategy = Fibonacci(duration)
		}
	}
}

// 设置自定义的 backoff 策略
func WithCustomBackOffStrategy(s Strategy) Option {
	return func(c *Config) {
		c.Strategy = s
	}
}

对应的,我们把 Do 函数签名更新为下面即可:

func Do(ctx context.Context, fn RetryFunc, opts ...Option) error

核心实现

ok,我们明确了想实现的功能,下面开始实现 Do 方法。

首先是将 option 应用到我们的 config 上,直接遍历 ...Option 调用即可:

  config := newDefaultConfig()
  for _, o := range opts {
      o(config)
  }

除了全局超时时间,还需要注意入参的 context.Context 自带一个 Deadline,同样需要处理。所以我们需要借助 goroutine 和 select 的帮助。这样也有助于我们通过一个 defer 支持 panic 的 recover。

下面我们看一下简易的实现:

  1. 将运行结果最终的 error 以及 panic 的上下文各自放到一个 channel 中;
  2. 通过 time.NewTimer 获取一个计时器,在 select 处判断;
  3. 通过 ctx.Done 判断 context 是否过期;
  4. 若运行无错误,error 对应的 channel读到的是个 nil,直接返回。
func Do(ctx context.Context, fn RetryFunc, opts ...Option) error {
	var (
		run           = make(chan error, 1)
		panicInfoChan = make(chan string, 1)

		timer  *time.Timer
		runErr error
	)
	config := newDefaultConfig()
	for _, o := range opts {
		o(config)
	}

	if config.Timeout > 0 {
		timer = time.NewTimer(config.Timeout)
	}

	go func() {
		var err error
		defer func() {
			if e := recover(); e == nil {
				return
			} else {
				panicInfoChan <- fmt.Sprintf("retry panic detected, err=%v, stack:%s", e, debug.Stack())
			}
		}()
		for i := 0; i < config.MaxRetryTimes; i++ {
			err = fn()
			if err == nil {
				run <- nil
				return
			}
		}
		run <- err
	}()
	select {
	case <-ctx.Done():
		// context deadline exceed
		return ErrorContextDeadlineExceed
	case <-timer.C:
		// timeout
		return ErrorTimeout
	case msg := <-panicInfoChan:
		// panic occurred
		if !config.RecoverPanic {
			panic(msg)
		}
		runErr = fmt.Errorf("panic occurred=%s", msg)
	case e := <-run:
		// normal run
		if e != nil {
			runErr = fmt.Errorf("retry failed, err=%w", e)
		}
	}
	return runErr
}

判断是否重试也是需要注意的一个点,当出现 error 时,不代表还可以继续重试,有一些错误是无法通过重试恢复的。对此,我们提供两个层面的支持:

  1. 默认的终止重试错误

var (
	ErrorAbort                 = errors.New("stop retry")
	ErrorTimeout               = errors.New("retry timeout")
	ErrorContextDeadlineExceed = errors.New("context deadline exceeded")
	ErrorEmptyRetryFunc        = errors.New("empty retry function")
)

框架默认支持,一旦开发者返回了 ErrorAbort,就认为不需要继续重试。

  1. 自定义终止重试错误

开发者可以通过 WithRetryChecker,传入一个 checker,自行判断是否需要重试

func WithRetryChecker(checker RetryChecker) Option {
	return func(c *Config) {
		c.RetryChecker = checker
	}
}

其实默认的 ErrorAbort 支持也是基于 RetryChecker 的,我们声明了默认的 RetryChecker 并注册进 config,如果开发者不去自行覆盖,就会使用默认配置。

DefaultRetryChecker  = func(err error) bool {
        return !errors.Is(err, ErrorAbort) // not abort error, should continue retry
}

func newDefaultConfig() *Config {
	return &Config{
		...
		RetryChecker:  DefaultRetryChecker,
		...
	}
}

对 RetryChecker 的支持也很简单,在我们的重试 goroutine 中,若发现返回的 error 不为 nil,就调用 RetryChecker 判断是否需要重试:

go func() {
        var err error
        ...
        for i := 0; i < config.MaxRetryTimes; i++ {
                err = fn()
                if err == nil {
                        run <- nil
                        return
                }
                // check whether to retry
                if config.RetryChecker != nil {
                        needRetry := config.RetryChecker(err)
                        if !needRetry {
                                abort <- struct{}{}
                                return
                        }
                }
                ...
        }
        run <- err
}()

一旦发现需要不需要重试,直接往 abort 专用的 channel 发送一条消息,select 进行返回即可

select {
case <-ctx.Done():
        // context deadline exceed
        return ErrorContextDeadlineExceed
case <-timer.C:
        // timeout
        return ErrorTimeout
case <-abort:
        // caller abort
        return ErrorAbort
}

backoff 策略

我们提供三种策略供开发者选择

type BackoffStrategy int

const (
	StrategyConstant BackoffStrategy = iota
	StrategyLinear
	StrategyFibonacci
)

分别代表,常量,线性,斐波那契数列 三种 backoff 策略。

每一种策略针对目前尝试的次数,会给出间隔的时长,Strategy 的定义如下:

type Strategy func(times int) time.Duration

func Constant(d time.Duration) Strategy {
	return func(times int) time.Duration {
		return d
	}
}

func Linear(d time.Duration) Strategy {
	return func(times int) time.Duration {
		return (d * time.Duration(times))
	}
}

func Fibonacci(d time.Duration) Strategy {
	return func(times int) time.Duration {
		return (d * time.Duration(fibonacciNumber(times)))
	}
}

func fibonacciNumber(n int) int {
	if n == 0 || n == 1 {
		return n
	}
	return fibonacciNumber(n-1) + fibonacciNumber(n-2)
}

在主流程中,我们只需要将重试的次数传入 config.Strategy 即可

if config.Strategy != nil {
        interval := config.Strategy(i + 1)
        <-time.After(interval)
}

使用案例

核心在于将自己需要重试的函数封装为一个 func() error 的 retryFunc,就可以搭配 option,调用 retry.Do 函数使用了。下面是官方示例:

package example

import (
	"context"
	"log"
	"time"

	goretry "github.com/ag9920/go-retry"
)

type Item struct {
	ID    string
	Name  string
	Price string
}

type ItemRepo interface {
	Query(id string) (Item, error)
}

type ItemService struct {
	repo ItemRepo
}

func (srv ItemService) GetItem(ctx context.Context, id string) (*Item, error) {
	var err error
	var item *Item
	// wrapper function
	retryFn := func() error {
		// send request to downstream to get data
		result, err := srv.repo.Query(id)
		if err == nil {
			item = &result
			return nil
		}
		if err.Error() == "overload" {
			return goretry.ErrorAbort // use prefined abort err to stop execution
		}
		return err
	}
	err = goretry.Do(ctx, retryFn,
		goretry.WithMaxRetryTimes(3),
		goretry.WithTimeout(500*time.Microsecond),
		goretry.WithBackOffStrategy(goretry.StrategyFibonacci, 10*time.Microsecond),
		goretry.WithRecoverPanic())

	if err != nil {
		log.Default().Printf("retry failed, id=%s, err=%v", id, err)
		return nil, err
	}
	return item, nil
}

源码地址

github.com/ag9920/go-r…

本文介绍的 go-retry 是一个基本涵盖开发日常需要的重试库,只依赖 Golang 原生的 goroutine, channel 支持。项目时间不长,欢迎大家 PR。