我是一个着迷于产品和运营的技术人,乐于跨界的终身学习者。欢迎关注我哟~
当前处于「随机更」状态
何时恢复「周更」未知……
我的第「231」篇原创敬上
大家好,我是Z哥。
不得不说,我现在已经从「周更」变成「随机更」了,我自己都不知道哪天能更新,工作实在太忙了。
好了快速进入正题,最近团队里的一个重点工作是增加系统的稳定性和可用性,因此避不开的话题就是熔断、降级、限流。
这三个概念我在之前写的分布式系统系列中也有提及,有兴趣的可以在文末移步到之前的文章中阅读。
不过今天我们主要聊的是,在 golang 项目中如何落地「熔断」。
熔断是一种通用能力,可以在服务端做也可以在客户端做。我们的项目中大多数都基于 go-zero 框架实现,而使用 go-zero 框架实现的项目自带服务端熔断能力,所以本文的目的是阐述如何在客户端侧实现熔断机制。
由于 go-zero 内置熔断器能力,因此我们优先想到的是能否直接使用 go-zero 框架内的熔断器组件,如果可以满足需求的话,也避免了增加额外的外部依赖。
扒开 go-zero 的源码就能找到它的熔断器使用,以下是在使用 go-zero 构建 http 的服务端时,其通过 AOP 的方式利用 Handler 来注入熔断器的代码。这部分代码现在不用深究,等看完本篇文章,你再回头来看很容易知道它写的是什么意思。
// BreakerHandler returns a break circuit middleware.func BreakerHandler(method, path string, metrics *stat.Metrics) func(http.Handler) http.Handler { brk := breaker.NewBreaker(breaker.WithName(strings.Join([]string{method, path}, breakerSeparator))) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { promise, err := brk.Allow() if err != nil { metrics.AddDrop() logx.Errorf("[http] dropped, %s - %s - %s", r.RequestURI, httpx.GetRemoteAddr(r), r.UserAgent()) w.WriteHeader(http.StatusServiceUnavailable) return } cw := &response.WithCodeResponseWriter{Writer: w} defer func() { if cw.Code < http.StatusInternalServerError { promise.Accept() } else { promise.Reject(fmt.Sprintf("%d %s", cw.Code, http.StatusText(cw.Code))) } }() next.ServeHTTP(cw, r) }) }
通过以上代码继续深入源码,我们发现了go-zero框架中的熔断器模块(
https://github.com/zeromicro/go-zero/tree/master/core/breaker)其底层使用了 google 的熔断器思路来实现。
可能说起熔断器,很多人脑子里第一印象是 netflix的 hystrix 。但是我认为 google 的思路更棒一些,两者的区别从效果来说就是 google 的方案自适应能力更强。因为 hystrix 中使用三种状态来控制,当状态为 open 期间,所有请求都会直接被拦截,相对更粗暴一些。为了便于理解两者的不同,我用“水管”来比喻画了一张图。
这里就不展开了,hystrix 的熔断器思路在我之前写的文章《分布式系统关注点(8)——如何在到处是“雷”的系统中「明哲保身」?这是第一招》中也有提到。如果对 google 的熔断器思路感兴趣的话,可以看这篇文章:
https://sre.google/sre-book/handling-overload/
好了,那么 go-zero 中的熔断器该怎么使用呢?
首先,使用它的途径有三种方式,分别是:
01 持有熔断器实例 + 负责管理实例的生命周期
brk := breaker.NewBreaker()brk.Do(func() error { //do something. return nil})
02 持有熔断器实例 + 不管理实例的生命周期
将管理每个熔断器实例的职责交由框架内的「池」来实现。
brk := breaker.GetBreaker(“起个名字”)brk.Do(func() error { //do something. return nil})
03 不持有熔断器实例
直接使用非实例的 Do() 函数,需要定义一个标识 name,这个 name 就是熔断器的唯一标识。
breaker.Do(“起个名字”, func() error { //do something. return nil})
以上示例代码中的 Do() 函数中的 func() error,就是需要在熔断器的保护下执行的具体代码。
运行以下代码,就可以看到熔断器生效的效果:
for i := 0; i < 20; i++ { err = breaker.Do("func", func() error { return errors.New(strconv.Itoa(i)) }) fmt.Println("func", err) }
输出:
func 0func 1func 2func 3func 4func 5func 6func 7func circuit breaker is openfunc circuit breaker is openfunc circuit breaker is openfunc 11func circuit breaker is openfunc circuit breaker is openfunc circuit breaker is openfunc 15func 16func 17func 18func circuit breaker is open
以上的输出内容不是固定的,每次运行的结果都不同(为什么不同后面会提到原因)。其中“func circuit breaker is open”表示 Do()函数中的 func() error 直接被熔断器拦截了,没有实际执行。
上面是最基本的使用方式,除此之外,go-zero 封装的 breaker 还提供以下几个能力:
接下来我们来一个个说下。
01 熔断器外的代码实现熔断
前面的三种使用方式中,Do() 函数的作用是将需要执行的代码放到熔断器内执行,而有时候我们可能不便将代码放到熔断器内,但是也想实现熔断的能力可以吗?当然可以。
breaker 对象暴露了一个Allow() (Promise, error) 函数,返回一个 Promise 对象。
Allow() (Promise, error)
前提是,你得使用前两种持有 breaker 实例的方式。go-zero 实现服务端熔断的 BreakerHandler 就是利用这个机制来实现的,根据返回的 HttpCode 决定请求算成功还是失败(前面贴的第一段代码中的 17~21 行)。
02 主动让熔断器失效
如果你使用熔断器的方式是前面提到的方式二和方式三,那么可以通过调用下面的函数,将「池」中的 breaker 实例移除。这样的话,下次申请获取相同 name 的熔断器时会重新实例化一个新的 breaker,因此间接达到了清空计数器数字的效果。
breaker.NoBreakerFor("起个名字")
在前面熔断器生效的代码基础上,增加三行代码,就能看到不会再出现“func circuit breaker is open”了。
for i := 0; i < 20; i++ { err = breaker.Do("func", func() error { return errors.New(strconv.Itoa(i)) }) fmt.Println("func", err) if i%5 == 0 { breaker.NoBreakerFor("func") } }
03 自定义计数规则
在讲自定义计数规则之前先得了解一下 googleBreaker 的实现原理。googleBreaker 的底层实现基于一个「客户端请求拒绝概率」的公式:
其中每个变量的含义是:
在 go-zero 提供的 breaker 实现中,基于上面的公式增加了两处微调。
第一处是,为了避免极端情况下发起第一次请求就出现失败而导致触发熔断,在 go-zero 的代码中针对上面公式中的「分子」增加了一个 protection 常量,该值固定为 5,因此分子部分实际在代码中是 requests - protection - K * accepts。
第二处是,当公式计算的结果 >0 时,不会直接触发熔断,而是会与一个半开半闭区间 [0.0,1.0) 的伪随机数对比,如果大于这个伪随机数则该次请求触发熔断。
针对上面公式的中,涉及到的计数的变量是 requests 和 accepts。默认的计数规则是:如果 func() 执行返回的 err == nil,则 requests+1,accepts+1;否则 requests +1,accepts 不变。
有时候,有些 error 我们可能不希望将其视作「不可用」的信号,因此,我们可以通过使用以下函数代替 Do(req func() error) error
DoWithAcceptable(req func() error, acceptable Acceptable) error
该函数多了一个 Acceptable 对象,该对象是一个函数,用于判断 error 是否是可忽略的:
你可以试试运行以下代码:
for i := 0; i < 20; i++ { err = breaker.DoWithAcceptable("DoWithAcceptable", func() error { return errors.New("acceptable") }, func(err error) bool { return err == nil || err.Error() == "acceptable" }) fmt.Println(err)}
你看不到表示触发熔断的“circuit breaker is open”字眼,都是“acceptable”。
04 触发熔断时的回调函数
当某次 func() 的执行被熔断器拦截时,允许触发回调(callback)函数,以便外部调用方感知到这个事件,并基于此做一些其它的事情。比如使用降级方案来代替原 func() 的实现。
要使用该能力,需要调用以下函数代替 Do() 函数:
DoWithFallback(req func() error, fallback func(err error) error) error
该函数多了一个 fallback 的 func()。当某次请求由于触发熔断器导致被拦截时会被触发。触发方式是 sync 的,且 fallback 函数中返回的 err 即为调用方接收到 DoWithFallback 函数的返回值。直接上源码可能更好理解:
其实还有一个函数
DoWithFallbackAcceptable(req func() error, fallback func(err error) error,acceptable Acceptable) error
从名字也能看出来,它同时支持上面提到的 03 和 04 能力。
到此为止,相信你应该会用这个熔断器了。
可能有些想更进一步的小伙伴会问,熔断器的触发策略除了计数规则之外,其它的规则可以自定义吗?
很遗憾,目前框架没有暴露相关的参数出来,都是在代码中固定写死的常量。除了前面提到的 protection ,还有 3 个常量与熔断器的触发策略相关。
window = time.Second * 10buckets = 40k = 1.5protection = 5
K 的含义前面有提到过,主要讲一下 window 和 buckets 变量的作用。
googleBreaker 的底层使用了滑动窗口算法,这两个变量是用来定义滑动窗口的:
含义是,将滑动窗口分为 40 个区间,每个区间对 250ms 内的请求进行计数。
好了,总结一下。
今天呢,Z 哥带你深入剖析了一下 go-zero 框架中的熔断器,以及教你如何使用它。
首先,使用熔断器的方式有三种:
其次,熔断器总共提供 6 种能力:
这篇文章比较干,如果你之前对熔断器了解不多的话,可能需要多花点时间仔细阅读几遍,消化一下。
希望对你有所帮助。
推荐阅读:
内容包括:架构设计丨分布式系统丨产品丨运营丨个人深度思考。