【分布说】常见go语言开源熔断器设计解析

1,676 阅读9分钟

这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战

pexels-alexander-sheryshev-10205032

导入

熔断器这个概念发端于电器设备领域,常用语是保险丝,它根据电流超过规定值一段时间后,利用自身产生的热量使其熔体融合,从而使电路断开,以达到保护线路上的其他设备。熔断器广泛用于高低压配电系统和控制系统中,是应用最普遍的保护器件之一。

d000baa1cd11728ba7635ffec8fcc3cec2fd2ca4

那么在我们现在要讨论的微服务系统中,为什么也需要熔断器呢?

熔断器使用场景

在诸如微服务的分布式环境中,应用程序执行访问远程资源和服务的操作,这些操作可能由于诸如网络连接缓慢,超时,资源过量使用或暂时不可用之类的瞬时故障而失败。这些故障通常会在短时间后自行纠正,因此一般需要使用重试模式所描述的策略。

但是,也可能存在由于意外事件而导致故障的情况,这种情况很难预测,因此可能需要更长的时间进行恢复。这些故障的严重性范围从部分连接中断到服务完全失败。在这些情况下,应用程序连续重试执行不太可能成功的操作可能是没有意义的,相反,应用程序应迅速接受该操作已失败并相应地处理此失败。

例如在如下高并发场景的用户订单服务场景下:

服务场景

配图来自参考文档2,侵删

假如此时 账户服务 过载,订单服务持续请求账户服务只能被动的等待账户服务报错或者请求超时,进而导致订单请求被大量堆积,这些无效请求依然会占用系统资源:cpu,内存,数据连接... 最终可能导致订单服务整体不可用。即使账户服务恢复了订单服务也无法自我恢复。

服务出现过载

配图来自参考文档2,侵删

这时如果有一个主动保护机制应对这种场景的话,订单服务至少可以保证自身不被拖垮,从而等待账户服务恢复后,订单服务也同步恢复。这种主动保护机制就是熔断,主要是保护上游服务不被不可用的下游服务所拖垮。注意此时整体服务是有损的,熔断并不能保证服务不中断,它只能保护其中子服务不因为资源耗尽而垮掉,整个系统不雪崩,否则那将会是更大的灾难。毕竟单个服务是更容易恢复的。

服务链路熔断

配图来自参考文档2,侵删

微服务熔断实现原理

实现主要有两个思路: 熔断器、Google SRE (Site Reliability Engineering) 过载保护算法。

熔断器

熔断器一般有关闭、打开、半打开三个状态:

  • 关闭: 关闭状态没有触发断路保护,所有请求正常通过。
  • 打开: 触发错误阈值后就进入开启状态,这个时候所有的流量都会被拦截。
  • 半打开: 处于打开状态一段时间后,会尝试放行一个流量来探测当前服务是否恢复。若可以正常处理则转为关闭状态,反之又回到打开状态。
20210504154754

基于熔断器的方法存在的问题: 触发熔断后的一段时间内会拒绝所有的请求。代表开源代码有 hystrix-go、sentinel。其中 hystrix-go 是由 Netflex 开发,sentinel 由阿里巴巴提供。

熔断器打开通常有三种判断方法: 错误次数、错误率、超时请求个数。如可以设置5s内,最大错误率为40%,超过则打开熔断器。

SRE过载保护算法

image-20211116105008531

这个公式计算的是请求被丢弃的概率:

  • requests: 请求数量。
  • accepts: 成功的请求数量。
  • K: 倍率,K 越小表示越激进,越小表示越容易被丢弃请求。

这个算法的好处是不会一刀切丢掉所有请求,而是计算出来一个概率来进行判断。当成功的数量越少、K越小,概率越大,表示这个请求被丢弃的概率越大。

代表开源代码有go-kratos/aegis、go-zero。kratos、zero 都是 go 流行的微服务框架。

go语言开源实现

hystrix-go

基本信息

Hystrix 是由 Netflex 开发的一款开源组件,具有java和go两种语言的实现,提供了基于错误率的熔断功能。

源码: github.com/afex/hystri…

  • star 个数: 3.4k, java 版本 22k。
  • 最近更新时间 2018-05-02,已停止维护。

使用示例

hystrix 的配置是按照每个 command 进行配置,下面的配置就是我们的请求数量大于等于 10 个并且错误率大于等于 20% 的时候就会触发熔断器开关,熔断器打开 500ms 之后会进入半打开的状态,尝试放一部分请求去访问。

hystrix.ConfigureCommand("test", hystrix.CommandConfig{
  // 执行 command 的超时时间
  Timeout: 10,
 
  // 最大并发量
  MaxConcurrentRequests: 100,
 
  // 一个统计窗口 10 秒内请求数量
  // 达到这个请求数量后才去判断是否要开启熔断
  RequestVolumeThreshold: 10,
 
  // 熔断器被打开后
  // SleepWindow 的时间就是控制过多久后去尝试服务是否可用了
  // 单位为毫秒
  SleepWindow: 500,
 
  // 错误百分比
  // 请求数量大于等于 RequestVolumeThreshold 并且错误率到达这个百分比后就会启动熔断
  ErrorPercentThreshold: 20,
})

客户端使用:

_ = hystrix.Do("test", func() error {
   resp, err := http.Get("https://www.baidu.com/")
   if err != nil {
      fmt.Println("get error:%v", err)
      return err
   }
   return nil
}, func(err error) error {
   // 出错后的错误处理
   fmt.Println("fallback err: ", err)
   return err
})

原理简介

hystrix 执行时:

  • 先根据资源 command 名字查询到当前的熔断器 circuitBreaker,若查询不到则创建一个默认的熔断器。
  • 创建两个子协程:
    • 一个负责监听超时时间、ctx.Done等信息。
    • 一个根据熔断器 circuitBreaker 判断是否允许执行,允许执行后执行任务 run。
  • 任务执行结束后将执行状态记录到熔断器 circuitBreaker。

优缺点分析

优点:

  • 使用简单,不同 command 可以采用不同的限流策略。
  • 支持动态更新阈值。

缺点:

  • 动态更新时不能删除某 command 的熔断策略。
  • 每次都开启两个子协程 (初次外每个 circuitBreaker 还有其它协程在监听计算) 浪费资源。
  • 官方已停止维护

Sentinel

基础信息

Sentinel 是面向分布式服务架构的流量控制组件,主要以流量为切入点,从流量控制、熔断降级、系统自适应保护等多个维度来帮助您保障微服务的稳定性。

源码: github.com/alibaba/sen…

  • star 个数: 1.7k
  • 最近更新时间 2021-09-22
  • 当前主要用户:阿里、每日优鲜、拼多多等,详情参考 github.com/alibaba/Sen…

使用示例

func init() {
   conf := config.NewDefaultConfig()
   // for testing, logging output to console
   conf.Sentinel.Log.Logger = logging.NewConsoleLogger()
   err := sentinel.InitWithConfig(conf)
   if err != nil {
      log.Fatal(err)
   }
 
   // Register a state change listener so that we could observer the state change of the internal circuit breaker.
   circuitbreaker.RegisterStateChangeListeners(&stateChangeTestListener{})
 
   _, err = circuitbreaker.LoadRules([]*circuitbreaker.Rule{
      // Statistic time span=5s, recoveryTimeout=3s, maxErrorCount=50
      {
         Resource:                     "ErrorCount",
         Strategy:                     circuitbreaker.ErrorCount,
         RetryTimeoutMs:               3000,
         MinRequestAmount:             10,
         StatIntervalMs:               5000,
         StatSlidingWindowBucketCount: 10,
         Threshold:                    50,
      },
      // Statistic time span=5s, recoveryTimeout=3s, maxErrorRatio=40%
      {
         Resource:                     "ErrorRatio",
         Strategy:                     circuitbreaker.ErrorRatio,
         RetryTimeoutMs:               3000,
         MinRequestAmount:             10,
         StatIntervalMs:               5000,
         StatSlidingWindowBucketCount: 10,
         Threshold:                    0.4,
      },
      // Statistic time span=5s, recoveryTimeout=3s, slowRtUpperBound=50ms, maxSlowRequestRatio=50%
      {
         Resource:                     "SlowRequestRatio",
         Strategy:                     circuitbreaker.SlowRequestRatio,
         RetryTimeoutMs:               3000,
         MinRequestAmount:             10,
         StatIntervalMs:               5000,
         StatSlidingWindowBucketCount: 10,
         MaxAllowedRtMs:               50,
         Threshold:                    0.5,
      },
   })
   if err != nil {
      log.Fatal(err)
   }
 
   logging.Info("[CircuitBreaker ErrorRatio] Sentinel Go circuit breaking demo is running. You may see the pass/block metric in the metric log.")
}
 
 
 
func testResource(resource string) {
   ch := make(chan struct{})
   go func() {
      for {
         e, b := sentinel.Entry(resource)
         if b != nil {
            // g1 blocked
            time.Sleep(time.Duration(rand.Uint64()%20) * time.Millisecond)
         } else {
            if rand.Uint64()%20 > 6 {
               // Record current invocation as error.
               sentinel.TraceError(e, errors.New("biz error"))
            }
            // g1 passed
            time.Sleep(time.Duration(rand.Uint64()%80+20) * time.Millisecond)
            e.Exit()
         }
      }
   }()
   go func() {
      for {
         e, b := sentinel.Entry(resource)
         if b != nil {
            // g2 blocked
            time.Sleep(time.Duration(rand.Uint64()%20) * time.Millisecond)
         } else {
            // g2 passed
            time.Sleep(time.Duration(rand.Uint64()%80+40) * time.Millisecond)
         }
         e.Exit()
      }
   }()
   <-ch
}

Sentinel 不同于 hystrix ,未触发熔断时需要手动执行 e.Exit() 上报当前请求状态,且若任务执行失败需要调用 sentinel.TraceError 上报请求出错。

Sentinel支持的熔断策略有异常数 (ErrorCount)、异常比例 (ErrorRatio)、慢调用比例 (SlowRequestRatio) 三种。

  • 异常数:当单位统计时长内的异常数目超过阈值之后会自动进行熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。
  • 异常比例:当单位统计时长(statIntervalMs)内请求数目大于设置的最小请求数目,并且异常的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。异常比率的阈值范围是 [0.0, 1.0],代表 0% - 100%。
  • 慢调用比例:选择以慢调用比例作为阈值,需要设置允许的慢调用 RT(即最大的响应时间),请求的响应时间大于该值则统计为慢调用。当单位统计时长(statIntervalMs)内请求数目大于设置的最小请求数目,并且慢调用的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求响应时间小于设置的慢调用 RT 则结束熔断,若大于设置的慢调用 RT 则会再次被熔断。

原理简介

image2021-10-13_13-27-10

优缺点分析

优点:

  • 不用创建新协程
  • 阿里在维护
  • 三种熔断策略
  • 除配置流量级别的熔断外,还可以配置多维度系统自适应流控:load、avgRT、concurrency、inboundQPS、cpuUsage。
  • Sentinel 除提供熔断外还支持限流
  • Sentinel 提供动态数据源接口进行扩展,用户可以通过动态文件、etcd、consul、nacos 等配置中心来动态地配置规则

缺点:

  • 使用较 hystrix 复杂,任务执行后需要手动上报状态 (可以做二次包装)

aegis

基础信息

源码地址: github.com/go-kratos/a…

  • star: 15
  • 最近更新: 2021-09-08

微服务框架 Kratos

  • 熔断使用了 aegis ,为每个 grpc.client 设置一个单独的熔断器
  • star: 15k
  • 最近更新: 2021-10-11

使用示例

func TestCircuitBreaker(t *testing.T) {
   breaker := sre.NewBreaker()
   if err := breaker.Allow(); err != nil {
      // unAllow
      breaker.MarkFailed()
      return
   }
 
   // allow
   err := doSomething()
   if err != nil {
      breaker.MarkFailed()
   } else {
      breaker.MarkSuccess()
   }
}

优缺点分析

优点:

  • 熔断期间不会拒绝所有请求
  • 可以通过option设置sre算法相关参数

缺点:

  • 不能全局管理所有熔断器
  • 任务执行后需要手动同步执行状态
  • sre 算法相关参数不支持动态更新

go-zero

基础信息

源码地址: github.com/zeromicro/g…

  • star: 11.9k
  • 最近更新:2021-10-13

使用示例

每个资源一个熔断器,分别统计、维护各自调用信息。不存在熔断器时会自动创建一个熔断器。该熔断器不支持为每个资源设置单独的熔断器。

func TestBreakersDo(t *testing.T) {
   assert.Nil(t, breaker.Do("any", func() error {
      return nil
   }))
 
   errDummy := errors.New("any")
   assert.Equal(t, errDummy, breaker.Do("any", func() error {
      return errDummy
   }))
}
 
func TestDoWithFallback(t *testing.T) {
   errDummy := errors.New("any")
   for i := 0; i < 10000; i++ {
      err := breaker.DoWithFallback("fallback", func() error {
         return errDummy
      }, func(err error) error {
         return nil
      })
      assert.True(t, err == nil || err == errDummy)
   }
 
   verify(t, func() bool {
      return breaker.ErrServiceUnavailable == breaker.Do("fallback", func() error {
         return nil
      })
   })
}
 

优缺点分析

优点:

  • 管理了所有资源的熔断器
  • 熔断期间不会拒绝所有请求

缺点:

  • sre算法相关参数不可以调整

总结

熔断器在服务治理中一般会与限流和服务降级进行整合,从而保证整个系统的稳定性。熔断在业界有比较成熟的解决方案了,各个实现也都有各自的优缺点,大家一般不需要重复造轮子。即使不能完全满足,也很容易进行定制化的改进。

  1. 基于 SRE 算法一般会实现为自适应算法,从而避免人工进行配置的繁琐。
  2. 熔断器方案建议选择 Sentinel,综合考量下是最优的。

参考资料

  1. 微软设计模式系列之一熔断器模式docs.microsoft.com/en-us/previ… 顺便案例一下微软的这个设计模式系列
  2. 熔断器通用介绍 mp.weixin.qq.com/s/NwhAqx49F…
  3. Sony gobreaker熔断器实现分析 bytemode.github.io/articles/so…
  4. hystrix-go熔断器实现分析 mp.weixin.qq.com/s/jhHhowKj9…
  5. kratos熔断器实现分析 pandaychen.github.io/2020/07/12/…
  6. go-zero自适应熔断实现分析 mp.weixin.qq.com/s/DDtFvFuJ1…