持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情
开篇
微服务的时代,对于复杂的业务来说,最前面的业务接口层经常需要调用多个服务,组装数据返回,每个下游之后又有下游,看起来一个简单的客户端请求,背后可能同时依赖了上百个微服务。
意味着,任何一个服务抖动,都可能影响发出请求客户端的体验,主要是两方面:
- 请求耗时;
- 能否得到符合预期的响应。
微服务的优势就在于解耦,作为上游,我可以完全不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 较优的默认配置,同时使用方应该可以根据自身诉求来调整策略。
作为一个基础库,我们希望提供如下配置能力:
- 全局超时时间,超出了就退出,给出对应错误码;
- 最大重试次数,避免无限重试;
- backoff 策略,即每次重试后间隔多长时间发起下次重试,打散请求,避免打挂下游;
- 判断是否重试,由业务方指定面对 error 时是否要继续重试;
- 重试前后的钩子函数;
- 出现 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。
下面我们看一下简易的实现:
- 将运行结果最终的 error 以及 panic 的上下文各自放到一个 channel 中;
- 通过 time.NewTimer 获取一个计时器,在 select 处判断;
- 通过 ctx.Done 判断 context 是否过期;
- 若运行无错误,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 时,不代表还可以继续重试,有一些错误是无法通过重试恢复的。对此,我们提供两个层面的支持:
- 默认的终止重试错误
var (
ErrorAbort = errors.New("stop retry")
ErrorTimeout = errors.New("retry timeout")
ErrorContextDeadlineExceed = errors.New("context deadline exceeded")
ErrorEmptyRetryFunc = errors.New("empty retry function")
)
框架默认支持,一旦开发者返回了 ErrorAbort,就认为不需要继续重试。
- 自定义终止重试错误
开发者可以通过 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
}
源码地址
本文介绍的 go-retry 是一个基本涵盖开发日常需要的重试库,只依赖 Golang 原生的 goroutine, channel 支持。项目时间不长,欢迎大家 PR。