断路器解释
断路器解释
熔断机制是指在交易时间内,当价格波动达到某个目标(熔点)时,股票市场暂停交易一定时间的机制。这种机制就像一个断路器,当电流过高时会熔断,因此得名。熔断机制的目的是为了防范系统性风险,给市场更多的时间冷静下来,避免恐慌情绪蔓延导致市场波动,从而防止股价大范围下跌的发生。
同样,在分布式系统的设计中,也应该有一种崩溃的机制。断路器通常配置在客户端(调用方),当客户端向服务端发起请求时,服务端的错误不断增加,那么就可能触发断路器,客户端的断路器触发后请求不再发送到服务器端,而是直接在客户端拒绝请求,从而可以保护服务器端不过载。这里所说的服务器端可能是rpc服务、http服务,也可能是mysql、redis等。注意,断路器是一种有损机制,在开启断路器的情况下可能需要一些降级策略来配合。
断路器原理
现代微服务架构基本都是分布式的,整个分布式系统是由非常多的微服务组成的。不同的服务相互调用,形成一个复杂的调用链。如果复杂调用链路中的某个服务不稳定,它可能会级联,最终整个链路可能会挂起。因此,我们需要对不稳定的服务依赖进行熔断和降级,暂时切断不稳定的服务调用,避免局部不稳定导致整个分布式系统雪崩。
说白了,我认为熔断器就像是那些容易出现异常的服务的一种代理。这个代理可以记录最近调用发生的错误次数,然后决定是继续操作还是立即返回错误。
断路器在内部维护一个断路器状态机,状态机转换相关如下图所示。
断路器具有三种状态。
- 关闭状态 :也是初始状态,我们需要一个调用失败计数器,如果调用失败,则失败次数加1。如果最近的失败次数超过给定时间内允许失败的阈值,则切换到打开状态, 当一个超时时钟打开时,当达到超时时钟时间时,它会切换到Half Open状态,设置超时时间是为了让系统有机会修复导致调用失败的错误以便返回到正常工作状态。在 Closed 状态下,错误计数是基于时间的。这可以防止断路器由于意外错误而进入打开状态,也可以根据连续故障的次数。
- 打开状态 :在这种状态下,客户端请求立即返回错误响应,而不调用服务器端。
- 半开状态 : 允许一定数量的客户端对服务端取消调用,如果这些请求对服务的调用成功,那么可以假设之前导致调用失败的错误已经被纠正,此时电路断路器切换到关闭状态并且错误计数器被重置。 Half-Open 状态可以有效地防止正在恢复的服务再次受到突然涌入的请求的影响。
下图展示了 Netflix 开源项目 Hystrix 中断路器实现的逻辑。
从这个流程图可以看出。
- 一个请求来了,首先allowRequest()函数判断是否在断路器中,如果没有,则释放,如果是,还要看一个断路器时间片是否到达,如果断路器时间片到,也释放,否则直接返回错误。
- 每个调用都有两个函数 makeSuccess(duration) 和 makeFailure(duration) 来计算在一定时间内有多少成功或失败。
- 判断是否断路的条件是isOpen(),是计算失败/(成功+失败)当前的错误率,如果高于某个阈值,则断路器打开,否则关闭。
- Hystrix 会在内存中维护一个数据,记录每个周期请求结果的统计信息,超过时间长度的元素将被删除。
断路器实现
了解了熔断的原理后,我们自己来实现一套熔断器。
熟悉 go-zero 的人都知道,在 go-zero 中进行融合并没有使用上面介绍的方式,而是指 “谷歌 SRE” 采用一种自适应融合机制,这种自适应方式有什么好处呢?下一节将基于这两种机制做一个比较。
下面我们根据上面介绍的熔断原理实现一套自己的断路器。
代码路径:go-zero/core/breaker/hystrixbreaker.go
断路器默认状态为 Closed,断路器分闸时默认冷却时间为 5 秒,断路器处于 HalfOpen 状态时默认检测时间为 200 毫秒,默认使用 rateTripFunc 方法判断是否触发熔断器,规则是采样大于等于200,错误率大于50%,使用滑动窗口记录请求总数和错误数。
func newHystrixBreaker() *hystrixBreaker {
bucketDuration := time.Duration(int64(window) / int64(buckets))
stat := collection.NewRollingWindow(buckets, bucketDuration)
返回 &hystrixBreaker{
状态:关闭,
冷却超时:默认冷却超时,
检测超时:默认检测超时,
tripFunc: rateTripFunc(defaultErrRate, defaultMinSample),
状态:状态,
现在:时间,
}
} func rateTripFunc(rate float64, minSamples int64) TripFunc {
返回 func(rollingWindow *collection.RollingWindow) bool {
var 总计,错误 int64
rollingWindow.Reduce(func(b *collection.Bucket) {
总计 += b.Count
错误 += int64(b.Sum)
})
errRate := float64(errs) / float64(total)
返回总计 >= minSamples && errRate > 率
}
}
每个请求都会调用 doReq 方法。在这个方法中,首先判断这个请求是否被accept()方法拒绝,如果被拒绝,直接返回断路器错误。否则执行req()真正发起服务端调用,分别调用b.markSuccess()和b.markFailure()表示成功和失败
func (b *hystrixBreaker) doReq(req func() 错误,回退 func(error) 错误,可接受 Acceptable) error {
如果错误:= b.accept();呃 ! =无{
如果后备! =无{
返回后备(错误)
}
返回错误
}
延迟函数(){
如果 e := 恢复(); ! =无{
b.markFailure()
恐慌(e)
}
}()
错误 := req()
如果可以接受(错误){
b.markSuccess()
} 别的 {
b.markFailure()
}
返回错误
}
在accept()方法中,首先获取当前断路器状态,当断路器处于Closed状态时直接返回,即正常处理本次请求。
当前状态为Open时,判断冷却时间是否过期,如果未过期,则直接返回断路器错误拒绝请求,如果过期,则将断路器状态改为HalfOpen,主要目的是冷却time是给服务器一些从故障中恢复的时间,避免连续请求服务器打到hang。
当前状态为HalfOpen时,首先确定探测间隔,避免探测过于频繁,默认使用200毫秒作为探测间隔。
func (b *hystrixBreaker) 接受() 错误 {
b.mux.Lock()
开关 b.getState() {
案例打开:
现在 := b.now()
如果 b.openTime.Add(b.coolingTimeout).After(now) {
b.mux.Unlock()
返回 ErrServiceUnavailable
}
如果 b.getState() == 打开 {
atomic.StoreInt32((*int32)(&b.state), int32(HalfOpen))
atomic.StoreInt32(&b.halfopenSuccess, 0)
b.lastRetryTime = 现在
b.mux.Unlock()
} 别的 {
b.mux.Unlock()
返回 ErrServiceUnavailable
}
案例半开:
现在 := b.now()
如果 b.lastRetryTime.Add(b.detectTimeout).After(now) {
b.mux.Unlock()
返回 ErrServiceUnavailable
}
b.lastRetryTime = 现在
b.mux.Unlock()
结案:
b.mux.Unlock()
}
返回零
}
如果此请求正常返回,则调用 markSuccess() 方法。如果当前断路器处于HalfOpen状态,则判断当前成功探测次数是否大于默认成功探测次数,如果大于则将断路器状态更新为Closed。
func (b *hystrixBreaker) markSuccess() {
b.mux.Lock()
开关 b.getState() {
案例打开:
b.mux.Unlock()
案例半开:
Atomic.AddInt32(&b.halfopenSuccess, 1)
如果 atomic.LoadInt32(&b.halfopenSuccess) > defaultHalfOpenSuccesss {
atomic.StoreInt32((*int32)(&b.state), int32(Closed))
b.stat.Reduce(func(b *collection.Bucket) {
b.计数 = 0
b.总和 = 0
})
}
b.mux.Unlock()
结案:
b.stat.Add(1)
b.mux.Unlock()
}
}
在markFailure()方法中,如果当前状态为Closed,通过执行tripFunc判断是否满足断路器条件,如果满足则将断路器状态变为Open状态。
func (b *hystrixBreaker) markFailure() {
b.mux.Lock()
b.stat.Add(0)
开关 b.getState() {
案例打开:
b.mux.Unlock()
案例半开:
b.openTime = b.now()
atomic.StoreInt32((*int32)(&b.state), int32(Open))
b.mux.Unlock()
结案:
如果 b.tripFunc ! = 无 && b.tripFunc(b.stat) {
b.openTime = b.now()
atomic.StoreInt32((*int32)(&b.state), int32(Open))
}
b.mux.Unlock()
}
}
断路器的整体实现逻辑比较简单,看代码基本可以看懂,这部分代码实现比较仓促,可能有bug,如果发现bug可以随时联系我指正。
hystrixBreaker 和 googlebreaker 比较
接下来,比较两种保险丝的熔断效果。
这部分示例代码位于:go-zero/example
分别定义了 user-api 和 user-rpc 服务。 user-api 充当客户端请求 user-rpc,user-rpc 充当服务器响应客户端请求。
在 user-rpc 的示例方法中,有 20% 的机会返回错误。
func (l *UserInfoLogic) UserInfo(in *user.UserInfoRequest) (*user.UserInfoResponse, error) {
ts := time.Now().UnixMilli()
如果 in.UserId == int64(1) {
如果 ts%5 == 1 {
返回零,状态。错误(代码。内部,“内部错误”)
}
返回 &user.UserInfoResponse{
用户 ID:1,
名称:“杰克”,
},无
}
返回 &user.UserInfoResponse{},无
}
在 user-api 的示例方法中,向 user-rpc 发起请求,然后使用 prometheus 度量来记录正常请求的数量。
var metricSuccessReqTotal = metric.NewCounterVec(&metric.CounterVecOpts{
命名空间:“电路断路器”,
子系统:“请求”,
名称:“req_total”,
帮助:“测试断路器”,
标签:[]字符串{“方法”},
})
func (l *UserInfoLogic) UserInfo() (resp *types.UserInfoResponse, err error) {
为了 {
_, err := l.svcCtx.UserRPC.UserInfo(l.ctx, &user.UserInfoRequest{UserId: int64(1)})
如果错了! = nil && err == 断路器.ErrServiceUnavailable {
fmt.Println(错误)
继续
}
metricSuccessReqTotal.Inc("用户信息")
}
返回 &types.UserInfoResponse{},无
}
启动两个服务,然后观察两种熔断策略下正常请求的数量。
googleBreaker 的正常请求率如下图所示。
hystrixBreaker 的正常请求率如下图所示。
从上面的实验结果可以看出,go-zero 内置的 googleBreaker 的正常请求数要高于 hystrixBreaker。这是因为 hystrixBreaker 维护了三个状态。在进入 Open 状态时,为了避免持续对服务器发起请求造成压力,使用了一个冷却时钟,在此期间没有任何请求可以幸免,同时从 HalfOpen 状态变为 Closed 状态后,一个大的请求数会立即再次发送到服
源码解读
googleBreaker 代码路径在:go-zero/core/breaker/googlebreaker.go
在doReq()方法中通过accept()方法判断是否触发了熔断器,如果熔断器被触发则返回错误,这里如果定义了回调函数则可以进行回调,比如做一些降级的数据处理.如果请求正常,则markSuccess() 将请求总数和正常请求数加1,如果请求因markFailure 失败,则仅将请求总数加1。
func (b *googleBreaker) doReq(req func() 错误,回退 func(err error) 错误,可接受 Acceptable) error {
如果错误:= b.accept();呃 ! =无{
如果后备! =无{
返回后备(错误)
}
返回错误
}
延迟函数(){
如果 e := 恢复(); ! =无{
b.markFailure()
恐慌(e)
}
}()
错误 := req()
如果可以接受(错误){
b.markSuccess()
} 别的 {
b.markFailure()
}
返回错误
}
通过accept()方法中的计算判断是否触发了断路器。
在该算法中,需要记录两个请求计数,分别是
- 请求总数(requests):调用者发起的请求数之和
- 正常处理的请求数(accepts):服务器正常处理的请求数
在正常情况下,这两个值是相等的。随着被叫方的服务开始拒绝异常请求,接受的请求数(accepts)的值开始逐渐小于请求数(requests),此时调用方可以继续发送请求,直到requests = K *接受,一旦超过这个限制,断路器打开,新的请求将在本地丢弃,有一定概率直接返回错误,概率计算如下。
最大(0,(请求 - K * 接受)/(请求 + 1))
通过修改算法中的K(multiplier),可以调节断路器的灵敏度,减小乘数时自适应断路器算法的灵敏度更高,增大乘数时自适应断路器算法灵敏度低,为例如,假设调用者请求的上限从requests = 2 * accept 调整为requests = 1.1 * accept ,则意味着每10个调用者的请求中就有1个会触发断路器。
func (b *googleBreaker) 接受() 错误 {
接受,总计 := b.history()
weightedAccepts := bk * float64(接受)
//[ https://landing.google.com/sre/sre-book/chapters/handling-overload/#eq2101](https://landing.google.com/sre/sre-book/chapters/handling-overload/#eq2101)
dropRatio := math.Max(0, (float64(total-protection)-weightedAccepts)/float64(total+1))
如果 dropRatio <= 0 {
返回零
}
如果 b.proba.TrueOnProba(dropRatio) {
返回 ErrServiceUnavailable
}
返回零
}
history 统计当前请求总数和滑动窗口正常处理的请求数。
func (b *googleBreaker) history() (accepts, total int64) {
b.stat.Reduce(func(b *collection.Bucket) {
接受 += int64(b.Sum)
总计 += b.Count
})
返回
}
结论
本文介绍了服务治理中的一种客户端节流机制——断路器。在hystrix断路器策略中要实现三个状态,Open、HalfOpen和Closed,不同状态之间切换的时机在上面的文章中有详细描述,大家可以反复阅读理解,或者更好的是,自己实现它。对于 go-zero 内置的断路器是无状态的,如果非要说它的状态,那么只有打开和关闭两种情况,它是根据当前请求的成功率自适应丢弃请求,是一个更灵活的断路器策略,丢弃请求的概率随着正常处理的请求数的变化而变化,正常处理的请求越多,丢弃请求的概率越低,反之,丢弃请求的概率越高。丢弃请求的概率越高,丢弃请求的概率就越高。
虽然熔断器的原理是一样的,但是由于实现机制不同,效果可能会有所不同,所以在实际生产中可以选择符合业务场景的熔断器策略。
我希望这篇文章对你有所帮助。
本文代码: https://github.com/zhoushuguang/go-zero/tree/circuit-breaker
参考
https://martinfowler.com/bliki/CircuitBreaker.html
https://github.com/Netflix/Hystrix/wiki/How-it-Works
项目地址
https://github.com/zeromicro/go-zero
欢迎使用 归零
和 星星 支持我们!
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明