【Go语言】系统弹力设计之断路器的实现

本文详细介绍了断路器的概念及其在软件系统中的作用,以Go语言的go-resiliency库为例,展示了如何使用断路器来提高系统的容错能力。通过实例代码和源码分析,解释了断路器的工作原理和状态转换过程。
摘要由CSDN通过智能技术生成

这位 Gopher 你好呀!如果觉得我的文章还不错,欢迎一键三连支持一下!文章会定期更新,同时可以微信搜索【凑个整数1024】第一时间收到文章更新提醒⏰

什么是断路器?

断路器(circuit-breaker)是软件系统弹力设计中常用的故障保护机制,提高软件系统的容错能力。断路器的工作方式类似于我们生活中所接触到的电路断路器,或者说是电闸上的“保险丝”,当电路出现问题(如短路)时会自动跳闸,保险丝会“熔断”,这时电路断开,可以起到保护电路上电器的作用,否则轻则导致电器烧坏,重则引发火灾,因此在电路的设计中,断路器这种自我保护装置可以说是不可或缺的。

同样,在计算机的场景中,一个服务可能会发生故障,当故障的严重程度达到某一阈值时,断路器会自动打开,以阻止对该服务的进一步调用,并直接返回一个预先设定好的错误响应,进而避免故障的加剧与扩散,提高系统的容错能力。

断路器同时还需要具备自我修复的能力。当故障发生一段时间后,断路器会尝试关闭,重新恢复对服务的调用,如果调用恢复正常,则断路器会关闭,否则继续打开,直到服务恢复正常。这就类似于我们生活中的电闸,当电路故障排除后,可以手动合闸,尝试恢复电路的通电状态。

断路器一般存在三种状态:闭合(closed)、断开(open)与半开(half-open),这与电路中的术语就很类似。断路器处于闭合状态时,服务正常,用户请求正常执行,断路器将不会对请求进行拦截;当客户请求因故障导致失败,且失败次数达到一定阈值时,断路器会自动打开并进入断开状态,此时断路器会拦截所有用户请求,并返回预先设定好的错误响应;断路器不可能永远处于断开状态,在一段时间后,断路器会尝试关闭,进入半开状态,重新尝试处理用户请求。如果请求处理成功的次数或比率达到一定阈值,则认为服务已经恢复正常,断路器会进入闭合状态,否则继续保持断开。

使用 Go 语言实现断路器

本文将以 Go 语言开源项目go-resiliency中的断路器实现为例,介绍使用 Go 语言的断路器代码设计与实现。go-resiliency 项目中的断路器实现位于breaker包中,其实现了一个精炼好用的断路器,知名开源 Go 语言 Apache Kafka 客户端sarama项目中就依赖了 go-resiliency 中所实现的断路器。

断路器的基础使用

go-resiliency 的断路器提供了一个New方法用于初始化一个断路器(breaker.Breaker类型)实例:

func New(errorThreshold, successThreshold int, timeout time.Duration) *Breaker

输入参数分别为:

  • errorThreshold:错误阈值,当timeout时间内连续发生错误的次数达到该阈值时,断路器会从闭合状态转为断开状态;
  • successThreshold:成功阈值,当处于半开状态的断路器连续成功处理请求的次数达到该阈值时,断路器会重新闭合;
  • timeout:超时时间,断路器在超时时间内连续发生错误的次数达到errorThreshold时,会从闭合状态转为断开状态;同时该超时时间也是断路器断开状态下的持续时间,当到期后,断路器会变为半开状态。

随后我们可以通过 breaker 实例的 Run 方法来执行需要断路保护的目标代码块。Run方法所接收的目标函数类型为func() error,即执行完后会返回一个error,如果执行成功返回的就是nil。下面我们来看一个简单的 demo:

func main() {
    cnt := 0
    work := func() error {
        if cnt++; cnt >= 3 {
            // 模拟从第三次调用开始出错
            return errors.New("work got an error")
        }
        return nil
    }

    b := breaker.New(5, 1, 5*time.Second)
    for range 10 {
        switch err := b.Run(work); err {
        case nil:
            fmt.Println("work success!")
        case breaker.ErrBreakerOpen:
            fmt.Println(err.Error())
        default:
            fmt.Println("got some other error:", err.Error())
        }
    }
}

上述代码中,我们定义了一个work函数,该函数会模拟从第 3 次调用开始出错。我们初始化了一个断路器Breaker类型实例b,通过b进行 10 次work的调用。我们通过b.Run(work)work作为断路器保护的代码块执行,当work执行成功时,b.Run会返回nil;前 5 次work出错将会返回work的错误;第 6 次出错开始,b.Run会直接返回预先定义的错误breaker.ErrBreakerOpen,表示断路器已经打开:

var ErrBreakerOpen = errors.New("circuit breaker is open")

以上的代码运行输出为:

work success!
work success!
got some other error: work got an error
got some other error: work got an error
got some other error: work got an error
got some other error: work got an error
got some other error: work got an error
circuit breaker is open
circuit breaker is open
circuit breaker is open

断路器源码走读

我们在本节来看下断路器是如何实现的。在 go-resiliency 中,断路器的结构体类型定义如下:

type Breaker struct {
    errorThreshold, successThreshold int
    timeout                          time.Duration

    lock              sync.Mutex
    state             State
    errors, successes int
    lastError         time.Time
}
  • errorThresholdsuccessThresholdtimeout:断路器的错误阈值、成功阈值、超时时间。这与上文提到的New方法的传参定义是一模一样的,这三个属性也正是通过New方法进行初始化的;

  • lock:断路器用到的互斥锁,用于在并发场景下保护断路器的内部状态;

  • state:断路器的状态,是breaker.State类型(定义见下文)。有三种取值分别对应上文所说断路器的三种状态:Closed(闭合)、Open(断开)和HalfOpen(半开);

  • errorssuccesses:断路器内部记录的一段时间内错误次数和成功次数;

  • lastError:最后一次发生错误的时间。

断路器的状态State的定义如下,底层类型为uint32

type State uint32

const (
    Closed State = iota
    Open
    HalfOpen
)

有了上述字段定义,断路器的初始化方法New很好理解,实际上就是初始化了errorThresholdsuccessThresholdtimeout

func New(errorThreshold, successThreshold int, timeout time.Duration) *Breaker {
    return &Breaker{
        errorThreshold:   errorThreshold,
        successThreshold: successThreshold,
        timeout:          timeout,
    }
}

BreakerRun方法是断路器的核心方法,用于执行需要断路保护的代码块,逻辑很精简,可以被编译器进行内联优化:

func (b *Breaker) Run(work func() error) error {
    state := b.GetState()

    if state == Open {
        return ErrBreakerOpen
    }

    return b.doWork(state, work)
}

Run方法首先通过GetState方法获取当前断路器的状态,当断路器处于断开状态(Open)时,目标服务是不可以被调用的,属于 Fast Path,直接返回ErrBreakerOpen错误;如果为其它两种状态,需要稍微复杂一些的处理逻辑,则走 Slow Path,调用doWork方法执行目标服务的代码块。

获取断路器状态的GetState方法实现如下,使用的是原子Load操作以保证并发调用时的安全,读取了state字段当前的值:

func (b *Breaker) GetState() State {
    return (State)(atomic.LoadUint32((*uint32)(&b.state)))
}

这种 Fast Path 与 Slow Path 结合的设计可以简化方法逻辑,易于编译器进行内联优化以提高代码性能。在 Go 标准库中也有很多类似的例子,典型的如sync.MutexLock方法、sync.OnceDo方法等。

doWork方法中,目标函数才真正被调用了,代码如下:

func (b *Breaker) doWork(state State, work func() error) error {
    var panicValue interface{}

    // 执行目标函数,得到执行结果,以及捕获panic
    result := func() error {
        defer func() {
            panicValue = recover()
        }()
        return work()
    }()

    if result == nil && panicValue == nil && state == Closed {
        // 正常情况:目标函数执行正常,且断路器处于闭合状态,直接返回nil结果,也不用加锁
        return nil
    }

    // 处理目标函数执行结果以更新断路器内部状态,这段逻辑会加锁
    b.processResult(result, panicValue)

    if panicValue != nil {
        panic(panicValue)
    }

    return result
}

doWork会在一个匿名函数中执行目标函数work,得到目标函数返回的result(其实就是个error),以及如果目标函数 panic 了,也会通过recover捕获到 panic,赋值给panicValue。然后判断resultpanicValue若为空,即代表目标函数正常执行,且当前断路器处于闭合状态时,直接返回nil结果,也不用去竞争锁。这是属于大多数的正常情况,也就是目标服务正常且断路器也没断开情况下的调用逻辑。

而当不满足上述正常情况时,就会走到processResult方法中,这个方法会处理目标函数执行结果以更新断路器内部状态,且在processResult方法中会加锁。之后如果panicValue不为空,还会主动触发一次 panic。

processResult方法的逻辑会稍微复杂些,代码实现如下:

func (b *Breaker) processResult(result error, panicValue interface{}) {
    b.lock.Lock()
    defer b.lock.Unlock()

    if result == nil && panicValue == nil {
        if b.state == HalfOpen {
            b.successes++
            if b.successes == b.successThreshold {
                b.closeBreaker()
            }
        }
    } else {
        if b.errors > 0 {
            expiry := b.lastError.Add(b.timeout)
            if time.Now().After(expiry) {
                b.errors = 0
            }
        }

        switch b.state {
        case Closed:
            b.errors++
            if b.errors == b.errorThreshold {
                b.openBreaker()
            } else {
                b.lastError = time.Now()
            }
        case HalfOpen:
            b.openBreaker()
        }
    }
}

processResult方法首先会加锁,然后判断resultpanicValue是否都为空,以判断目标函数是否是正常执行的:

  • 如果是正常执行的,且当前断路器处于半开状态(HalfOpen),则会将successes加 1,若此时successes达到成功阈值successThreshold时,会调用closeBreaker方法关闭断路器;

  • 如果不是正常执行的,说明有错误产生,则先会看断路器所记录的错误次数errors是否大于 0,也就是之前是否已经有过错误发生。如果有过错误发生,会判断当前时间是否已经超过了设定的超时时间timeout,如果超时了,则会将errors重置为 0。随后根据当前断路器的状态,分别处理闭合状态和半开状态的情况:

    • 当断路器处于闭合状态时,会将errors加 1,若此时errors已经达到错误阈值errorThreshold,会调用openBreaker方法断开断路器,否则将当前时间赋值给lastError,以记录最后一次发生错误的时间,而先不去断开断路器;
    • 当断路器处于半开状态时,按照断路器的工作特点,会直接调用openBreaker方法断开断路器。

我们再来看下processResult方法所用来断开和闭合断路器的openBreakercloseBreaker方法的实现:

func (b *Breaker) openBreaker() {
    b.changeState(Open)
    go b.timer()
}

func (b *Breaker) closeBreaker() {
    b.changeState(Closed)
}

func (b *Breaker) timer() {
    time.Sleep(b.timeout)

    b.lock.Lock()
    defer b.lock.Unlock()

    b.changeState(HalfOpen)
}

func (b *Breaker) changeState(newState State) {
    b.errors = 0
    b.successes = 0
    atomic.StoreUint32((*uint32)(&b.state), (uint32)(newState))
}

openBreakercloseBreaker方法分别都使用了changeState方法来改变断路器的状态。在changeState方法中,会将errorssuccesses都重置为 0,然后通过原子Store操作将state字段的值更新为新的状态newStateopenBreaker会多启动一个执行timer方法的协程,在这个协程中,会首先睡眠timeout时间,然后再次加锁,调用changeState方法将断路器状态更新为半开状态。

以上就是 go-resiliency 实现断路器的几乎全部源码,通过上述源码走读,我们可以看到其断路器已经实现了断路器的基本功能,且是并发安全的。为了方便大家整理思路,这里给大家画了一张基于go-resiliency断路器的状态转移图,核心的工作原理基本上都能在这张状态转移图中体现。

go-resiliency断路器Breaker状态转移图

  • 16
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值