hystrix-go——微服务里的“及时止损”

编者荐语:

良心推荐一个#公众号:非典型后端码农 。腾讯大佬在线带你探究后端开发的奥秘,打开从编程语言到架构设计的新视角,欢迎围观。

为什么需要熔断?怎么做?

为什么需要熔断机制?

在分布式系统中,服务间相互调用非常常见,单个用户请求背后会调用多个服务,而被调用的服务可能依赖其他服务,一直形成一条调用链。当被调用的服务出现故障时,可能出现远端负载增加雪崩效应两种情况。


远端负载增加:当远程服务出现异常时,如果不适当减少下发到对端的请求。以最近线上服务遇到的case为例,远端服务因为一个DB慢查询导致响应超时,由于调用者没有进行限制,导致DB负载增加,进一步加剧服务响应超时的情况。


雪崩效应:假设有A、B、C三个服务,A会依赖B,B会依赖C。当C响应过慢或超时时,B服务会堆积过多的等待连接,甚至出现故障,而这个故障也可能进一步像雪崩一样传导到A服务。


熔断机制

熔断机制即在远程服务调用前增加一个断路器,当远程调用出现过多超时或失败时,后面来的请求都不会下发到远端服务,而是进行快速失败(fast-fail)。

   

当断路器为闭合时(closed),请求能够顺利下发,而当总的失败的请求超过特定阈值时,电路断开(open),此后所有请求都会快速失败而不发起远程调用。


此时打开一个定时器,倒计时结束后进入半开状态(half open),此时能够尝试下发一个请求,如果成功则转成闭合,否则回到断开状态,重新倒计时。


hystrix-go使用

// hystrix.gofunc main() {    // 设置一个命令名为"getfail"的断路器    hystrix.ConfigureCommand("getfail", hystrix.CommandConfig{        Timeout:                int(3 * time.Second),        MaxConcurrentRequests:  10,        SleepWindow:            5000,        RequestVolumeThreshold: 10,        ErrorPercentThreshold:  30,    })    // 使用getfail这个断路来保护以下指令,采用同步的方式,hystrix.Go则是异步方式    _ = hystrix.Do("getfail", func() error {        // 尝试调用远端服务        _, err := http.Get("https://localhost:8080")        if err != nil {            fmt.Println("get error:%v",err)            return err        }        return nil    }, func(err error) error {        // 快速失败时的回调函数        fmt.Printf("handle  error:%v\n", err)        return nil    })}

断路器有几个配置:

  • Timeout:一个远程调用的超时时间。

  • MaxConcurrentRequests:最大并发数,超过并发数的请求直接快速失败。

  • SleepWindow:断路器从开路状态转入半开状态的睡眠时间,单位:ms。

  • RequestVolumeThreshold:为了避免断路器一启动就进入断路,当超过这个请求之后,断路器才开始工作。

  • ErrorPercentThreshold:开路阈值,表示触发开路的失败请求的百分比。



核心源码与实现

整体流程

hystrix-go的核心组件是指令command和断路器CircuitBreakercommand在每次发起Do或者Go时根据传入的调用函数和回调函数构成。CircuitBreak则由一个全局唯一的map来维护——map[string]*CircuitBreaker,key则是ConfigureCommand时设置的命令名。在利用断路器对某个调用进行保护时,会创建一个command对象,并根据传入的key获取对应断路器。


不同指令对应不同的断路器,可以保证不同的依赖服务间不会相互影响,这个优化点称作舱壁模式(bulkhead)。


接下来我们看下hystrix-go的整体流程


如图所示,当断路器不处于开路或者超过并发数时,会进行远程调用,并将成功/失败/超时的结果上报到断路器的健康检查器;否则进行快速失败。


这里以hystrix-go.Go为例进行解读,Go最后会调用GoC

// hystrix.gofunc GoC(ctx context.Context, name string, run runFuncC, fallback fallbackFuncC) chan error {    // 将待执行函数和失败回调函数封装成command对象    cmd := &command {...}    // 获取断路器    circuit, _, err := GetCircuit(name)    ...    // 开启一个协程,用于处理远程调用    go func {        if !cmd.circuit.AllowRequest() {            // 断路器开路或半开时在这里进行尝试或快速失败            ...             }                // 尝试从协程池中获取Token,如果获取失败则快速返回失败        ...
// 执行远程调用,并上报结果(成功/失败) ... } // 开启另一个协程,用于处理请求超时的情况 go func { timer := time.NewTimer(getSettings(name).Timeout) defer timer.Stop() select { case <- timer.C: returnOnce.Do(func() { returnTicket() // 返回Ticket进协程池 cmd.errorWithFallback(ctx, ErrTimeout) // command设为超时失败 reportAllEvent() // 向健康检查器上报超时失败 }) return } }
return cmd.errChan}


可以看到整个流程跟断路器处理一个请求的流程是一致的。对于一个请求的处理,hystrix-go开启了一个协程,这是考虑到Client在执行的过程中也可能会出现非网络异常,这些都应该被隔离。


另外有趣的是,断路器使用一个buffered chan对最大并发数进行控制,只有从通道中成功获取Token的请求能够进行远程调用。


接下来简单看下hystrix.DoC的实现,可以看见核心还是调用了GoC,只是在最后会通过<-chan操作阻塞返回,达到同步调用的效果。

// hystrix.gofunc DoC(ctx context.Context, name string, run runFuncC, fallback fallbackFuncC) error {    ...    if fallback == nil {    errChan = GoC(ctx, name, r, nil)  } else {    errChan = GoC(ctx, name, r, f)  }    select {        case <-done:    return nil  case err := <-errChan:    return err    }}


如何检查电路是否健康

前文提到,每个circuit都会有一个metrics属性,而在源代码每个可能上报请求状态的点都会调用reportAllEvent,最终其实是将所有的请求情况推进circuit.metrics。而这个属性会维护过去发生过的所有请求,以及所有失败或超时的请求。在AllowRequest里会查看失败率是否超过上线,如果是则将断路器转成开路。


维护断路器状态

hystrix-go没有像理论模型一样维护三种状态,而是只维护了一个布尔值。半开状态则是通过CAS的方式来保证只能有一个尝试的请求发出:CompareAndSawp在修改一个值前,会先尝试用变量的老的值去比较(Compare),只有旧值匹配上才能成功附上新值,这可以保证并发时多个请求只有一个能够修改成功。利用这个特性,hystrix-go可以保证在断路器开路且计时器到期进入半开状态时只有一个尝试请求能够达到远端。

func (circuit *CircuitBreaker) allowSingleTest() bool {  ...  if circuit.open && now > openedOrLastTestedTime+getSettings(circuit.Name).SleepWindow.Nanoseconds() {    swapped := atomic.CompareAndSwapInt64(&circuit.openedOrLastTestedTime, openedOrLastTestedTime, now)    if swapped {      log.Printf("hystrix-go: allowing single test to possibly close circuit %v", circuit.Name)    }    return swapped  }  return false}


启发

1. 当需要复用同一段代码,同时需要向调用者暴露同步调用的接口和异步调用的接口时,尝试返回一个表示完成或结果的chan,在异步任务中返回chan,同步任务中则尝试用<-chan来阻塞返回。

2. golang中控制协程最大并发数:使用buffered chan并指定其大小为最大协程数,每个请求来的时候尝试从通道中获取一个token,在请求结束后将token放回通道中。

3. 使用CAS可以保证并发请求下来时只有一个请求能够修改成功。

4. hystrix-go里涉及到不少访问共享变量或临界区的设计,比如sync.Once等,都比较值得学习。

本文分享自微信公众号 - 编程三分钟(coding3min)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章