熔断

在微服务中服务间的相互调用和一栏非常常见。一个下游的服务出现了问题可能会影响这个调用端的所有请求或者功能。这是我们不想看到的情况。为了防止被调用服务出现问题进而导致调用服务出现问题,所以调用服务需要进行自我保护,二保护的常用手段就是熔断。

熔断的原理

熔断的原理类似于生活中的保险丝,为了当电路出现短路或者超负荷的情况发生。达到了某一个条件和阈值之后就会断开。从而保证电路中的电器不受上海。
但是由此也出现了一个问题,保险丝一旦断开之后就需要手动的更换才可以再次正常工作,但是在运行的项目我们不可能实时的通过人工去干预。所以熔断机制机制中还需要有检测和判断是否回复熔断。这种判断机制有很多种,可以是GoogleSre中的通过成功和失败的比例得出一个概率。或者通过事件窗口内成功和失败的数量来判断是否恢复。

熔断的颗粒度

在数据库中我们知道有表锁行锁,粒度较小的锁能让数据不受过多的影响。在熔断中也是一样,我们熔断的颗粒度可以使一个服务、一个域名、甚至于某一个特定的方法。这些可以根据我们的业务需求去判断,也不是说粒度越细就越好。

熔断器的状态

  • 关闭(closed): 关闭状态下没有触发断路保护,所有的请求都正常通行
  • 打开(open): 当错误阈值触发之后,就进入开启状态,这个时候所有的流量都会被节流,不运行通行
  • 半打开(half-open): 处于打开状态一段时间之后,会尝试尝试放行一个流量来探测当前 server 端是否可以接收新流量,如果这个没有问题就会进入关闭状态,如果有问题又会回到打开状态

熔断在微服务中的使用

本文不在于介绍熔断本生,主要是记录一下熔断在各个Go微服务中的实现和使用。在参考了一些文章和自己实践之后,会从三个方面做介绍。(本文不过多的了解熔断器的实现代码,只是描述它在微服务框架内的使用)

  1. 单纯的使用熔断器,不牵扯微服务。(hystrix-go)
  2. 熔断在B站微服务框架kratos中的应用和如何自己实现一个熔断并使用在Kratos中。
  3. 熔断器在gozero中的应用。

熔断在Go中的使用

熔断器中比较经典的时间是hystrix,go也有对应的版本:hystrix-go。
先举一个例子:在这个例子中我们可以看到在server这个函数中使用gin启动了一个http的服务,在前200ms的请求都会返回500错误,之后的请求都会返回200的http状态码。
然后创建了一个熔断器,这个熔断器的name为test,我们可以设置的时几个参数

  • Timeout : 熔断器的超时时间
  • MaxConcurrentRequests :最大并发量
  • RequestVolumeThreshold:一个统计窗口 10 秒内请求数量,达到这个请求数量后才去判断是否要开启熔断
  • SleepWindow:熔断器被激活之后,多久可以尝试服务是否可用 单位为毫秒
  • ErrorPercentThreshold:错误百分比,在窗口时间内,请求数量和错误数量的比例如果达到了这个阈值,则会启动熔断器。

客户端代码中,我们可以看到,会请求http服务20次,每次请求消耗100ms。那么按照这个的情况来看钱2次的请求客户端返回的时500错误,但是在窗口时间内并没有打到20%。所以熔断器并没有开启,等到请求打到10次的时候就达到了我们设置的错误率20%这个阈值。这个时候熔断器会被激活,等过了500毫秒也就是5次请求之后,又会去正常的发出请求。

在这个例子中我们需要注意的有两点:

  1. 之前文中提到的颗粒度的问题,在hystrix-go中一个commandName就是一个熔断器,是根据创建时候的name字段来做区分的。我们可以根据不同的业务和维度自定义这个name,可以是一个域名也可以是一个具体的方法等。这个需要我们自己用逻辑去实现。
  2. 在这个例子中我们可以发现,当窗口期内错误率达到阈值之后会切断所有的这个command下的请求,如果颗粒度不是很细的情况下回导致这个维度下所有的请求都没办法发送成功。这个问题可以看一下下面的GoogleSre的做法。
package main

import (
    "fmt"
    "net/http"
    "time"

    "github.com/afex/hystrix-go/hystrix"
    "github.com/gin-gonic/gin"
    "gopkg.in/resty.v1"
)

func server() {
    e := gin.Default()
    start := time.Now()
    e.GET("/ping", func(ctx *gin.Context) {
        if time.Since(start) < 201*time.Millisecond {
            ctx.String(http.StatusInternalServerError, "pong")
            return
        }
        ctx.String(http.StatusOK, "pong")
    })

    err := e.Run(":8080")
    if err != nil {
        fmt.Printf("START SERVER OCCUR ERROR, %s", err.Error())
    }
}

func main() {
    go server()

    hystrix.ConfigureCommand("test", hystrix.CommandConfig{
        // 执行 command 的超时时间
        Timeout: 10,

        // 最大并发量
        MaxConcurrentRequests: 100,

        // 一个统计窗口 10 秒内请求数量
        // 达到这个请求数量后才去判断是否要开启熔断
        RequestVolumeThreshold: 10,

        // 熔断器被打开后
        // SleepWindow 的时间就是控制过多久后去尝试服务是否可用了
        // 单位为毫秒
        SleepWindow: 500,

        // 错误百分比
        // 请求数量大于等于 RequestVolumeThreshold 并且错误率到达这个百分比后就会启动熔断
        ErrorPercentThreshold: 20,
    })

    // 模拟20个客户端请求
    for i := 0; i < 20; i++ {
        _ = hystrix.Do("test", func() error {
            resp, _ := resty.New().R().Get("http://localhost:8080/ping")
            if resp.IsError() {
                return fmt.Errorf("err code: %s", resp.Status())
            }
            return nil
        }, func(err error) error {
            fmt.Println("fallback err: ", err)
            return err
        })
        time.Sleep(100 * time.Millisecond)
    }

}

Kratos中熔断器的使用

Kratos是B站开源的一套轻量级的Go微服务框架,包含大量微服务相关框架以及工具。其中包含日志、服务注册发现、路由负载均衡当然也包含熔断器这个比较常见的功能。

接口

大多数的微服务框架提供的都是可以自由替换插件的。在Kratos中,默认有一套熔断的实现。如果无法满足你的也无需求的话,你也可以自己实现一套,只要类继承了CircuitBreaker这个接口,接口如下:

// CircuitBreaker is a circuit breaker.
type CircuitBreaker interface {
    Allow() error // 判断请求是否允许发送,如果返回 error 则表示请求被拒绝
  MarkSuccess() // 标记请求成功
    MarkFailed() // 标记请求失败
}

只要实现了上述三个函数,就可以完全替换Kratos原有的熔断逻辑了。

使用方法

在Client请求中使用熔断器:

// http
conn, err := http.NewClient(
    context.Background(),
    http.WithMiddleware(
        circuitbreaker.Client(),
    ),
    http.WithEndpoint("127.0.0.1:8000"),
)
// grpc 
conn,err := transgrpc.Dial(
  context.Background(), 
    grpc.WithMiddleware(
        circuitbreaker.Client(),
    ),
  grpc.WithEndpoint("127.0.0.1:9000"),
)

GoogleSre过载算法

max(0, frac{requests - K * accepts}{requests + 1})
算法如上所示,这个公式计算的是请求被丢弃的概率

  • requests: 一段时间的请求数量
  • accepts: 成功的请求数量
  • K:倍率,K越小表示越激进, 越小表示越容易被丢弃请求
    这个算法的好处是不会直接一刀切的丢弃所有请求,而是计算出一个概率来进行判断,当成功的请求数量越少,K越小的时候 $requests - K * accepts$ 的值就越大,计算出的概率也就越大,表示这个请求被丢弃的概率越大。

核心函数

Allow函数中,通过上面介绍的GoogleSre算法。先判断成功的和总的请求数量,通过概率判断出是否触发熔断。

func (b *sreBreaker) Allow() error {
    // 统计成功的请求,和总的请求
    success, total := b.summary()

    // 计算当前的成功率
    k := b.k * float64(success)
    if log.V(5) {
        log.Info("breaker: request: %d, succee: %d, fail: %d", total, success, total-success)
    }
    // 统计请求量和成功率
    // 如果 rps 比较小,不触发熔断
    // 如果成功率比较高,不触发熔断,如果 k = 2,那么就是成功率 >= 50% 的时候就不熔断
    if total < b.request || float64(total) < k {
        if atomic.LoadInt32(&b.state) == StateOpen {
            atomic.CompareAndSwapInt32(&b.state, StateOpen, StateClosed)
        }
        return nil
    }
    if atomic.LoadInt32(&b.state) == StateClosed {
        atomic.CompareAndSwapInt32(&b.state, StateClosed, StateOpen)
    }

    // 计算一个概率,当 dr 值越大,那么被丢弃的概率也就越大
    // dr 值是,如果失败率越高或者是 k 值越小,那么它越大
    dr := math.Max(0, (float64(total)-k)/float64(total+1))
    drop := b.trueOnProba(dr)
    if log.V(5) {
        log.Info("breaker: drop ratio: %f, drop: %t", dr, drop)
    }
    if drop {
        return ecode.ServiceUnavailable
    }
    return nil
}

// 通过随机来判断是否需要进行熔断
func (b *sreBreaker) trueOnProba(proba float64) (truth bool) {
    b.randLock.Lock()
    truth = b.r.Float64() < proba
    b.randLock.Unlock()
    return
}
需要注意的一点是,在Kratos中的熔断器使用一个Group对象封装的,通过请求的path的维度来作为熔断的的维度。比如一次请求是http://192.168.0.1:8080/hello...,那么这个熔断器的维度就是helloworld.v1.Greeter/SayHello。每一个这样的string都是一个熔断器,其中通过map来储存。我们可以看一下group这个对象的定义
type Group struct {
    new  func() interface{}
    vals map[string]interface{}
    sync.RWMutex
}

如何实现一个自定义的熔断器,并且在Kratos中使用

首先我们需要实现Kratos定义熔断器的接口CircuitBreaker,下面可以看一下最简单的实现,其中没有任何的算法只是单纯的实现:

package mybreak

import (
    "context"
    "github.com/go-kratos/kratos/v2/errors"
    "github.com/go-kratos/kratos/v2/middleware"
)

var ErrNotAllowed = errors.New(503, "MyBreak", "request failed due to circuit breaker triggered")

type MyBreaker struct {
    Count int
}

func NewMyBreaker() *MyBreaker {
    r := &MyBreaker{Count: 0}
    return r
}

func (mb *MyBreaker) Allow() error {
    return nil
}

func (mb *MyBreaker) MarkSuccess() {

}

func (mb *MyBreaker) MarkFailed() {

}

func Client() middleware.Middleware {
    opt := NewMyBreaker()
    return func(handler middleware.Handler) middleware.Handler {
        return func(ctx context.Context, req interface{}) (interface{}, error) {
            if opt.Count > 10 {
                return nil, ErrNotAllowed
            }

            reply, err := handler(ctx, req)
            if err != nil && (errors.IsInternalServer(err) || errors.IsServiceUnavailable(err) || errors.IsGatewayTimeout(err)) {
                opt.MarkFailed()
            } else {
                opt.MarkSuccess()
            }
            return reply, err
        }
    }
}

从上述代码中可以看到,我们只要实现了接口中的三个函数之后。再封装一个中间件之后就可以完美的替换掉Kratos中默认实现的熔断器了。再次注意,上面的实现是没有任何逻辑在其中的,只是为了实践替换原有框架中的熔断器。

熔断器在gozero中的应用

gozero也是现在大家比较关注的一款go语言微服务框架,现在gozero的git星已经达到19k。社区也很活跃,那么在这个微服务框架中肯定也会带有熔断器,并且也有自己的默认实现。值的一提的是gozero的的熔断器也是基于googlesre实现的。
gozero的熔断器是基于框架中的拦截器实现的。我们知道,熔断器主要是用来保护调用端,调用端在发起请求的时候需要先经过熔断器,而客户端拦截器正好兼具了这个这个功能,所以在zRPC框架内熔断器是实现在客户端拦截器内。gozero的拦截器原理图如下:

具体代码实现为:

func BreakerInterceptor(ctx context.Context, method string, req, reply interface{},
    cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
  // 基于请求方法进行熔断
    breakerName := path.Join(cc.Target(), method)
    return breaker.DoWithAcceptable(breakerName, func() error {
    // 真正发起调用
        return invoker(ctx, method, req, reply, cc, opts...)
    // codes.Acceptable判断哪种错误需要加入熔断错误计数
    }, codes.Acceptable)
}

本文就不过多的描述gozero中是如何实现熔断的了,核心就是使用了Google sre算法。

如何在gozero中实现自己的熔断机制

我认为在gozero中,你可以模仿gozero自身的实现做一个实现自己逻辑的熔断器。或者可以利用截断器的功能在breakerInterceptor中直接实现比如直接用hystrix-go去实现都是可以的。

本文参考:
gozero
go微服务熔断
Kratos


大二小的宝
222 声望74 粉丝