go-sentinel流量控制(四): 熔断降级和热点并发隔离控制

1,350 阅读13分钟

熔断降级

熔断降级是在分布式架构中保护服务稳定性的一种重要手段。当一个服务调用其依赖的外部服务时,如果外部服务出现不稳定情况,如响应时间过长或服务不可用,熔断机制可以暂时切断对该外部服务的调用,从而避免服务自身因等待过长响应而出现线程堆积和资源耗尽的情况,进而保护整个系统免受雪崩效应的影响。

在实际应用中,熔断降级通常在客户端(调用端)进行配置。例如,在服务A调用服务B的接口时,服务A的代码中会配置熔断器,通过监控服务B的响应情况,当检测到服务B的响应异常时,触发熔断机制,暂时停止对服务B的调用,而是直接返回一个预设的默认值或错误信息,从而保护服务A免受服务B不稳定性的影响。

image.png

熔断器模型

熔断器内部维护了一个状态机:

image.png

熔断器有三种状态:

  1. Closed 状态:也是初始状态,该状态下,熔断器会保持闭合,对资源的访问直接通过熔断器的检查。
  2. Open 状态:断开状态,熔断器处于开启状态,对资源的访问会被切断。
  3. Half-Open 状态:半开状态,该状态下除了探测流量,其余对资源的访问也会被切断。探测流量指熔断器处于半开状态时,会周期性的允许一定数目的探测请求通过,如果探测请求能够正常的返回,代表探测成功,此时熔断器会重置状态到 Closed 状态,结束熔断;如果探测失败,则回滚到 Open 状态。

这三种状态之间的转换关系这里做一个更加清晰的解释:

  1. 初始状态下,熔断器处于 Closed 状态。如果基于熔断器的统计数据表明当前资源触发了设定的阈值,那么熔断器会切换状态到 Open 状态;
  2. Open 状态即代表熔断状态,所有请求都会直接被拒绝。熔断器规则中会配置一个熔断超时重试的时间,经过熔断超时重试时长后熔断器会将状态置为 Half-Open 状态,从而进行探测机制;
  3. 处于 Half-Open 状态的熔断器会周期性去做探测。

熔断器的设计

  1. 下游服务质量指标:衡量下游服务质量的指标包括响应时间(RT)、异常数量和异常比例等。这些指标可以用来触发熔断策略。

  2. 熔断策略:Sentinel支持三种熔断策略,即慢调用比例熔断、异常比例熔断和异常数量熔断。根据这些策略,用户可以设置熔断规则来为资源添加熔断器。

  3. 熔断器:每个熔断规则都会被转换成对应的熔断器,熔断器对用户是不可见的。每个熔断器都有自己独立的统计结构。

  4. 熔断器的整体检查逻辑

    • 基于熔断器的状态机判断资源是否可以访问。
    • 对于不可访问的资源,会有探测机制确保资源访问的弹性恢复。
    • 熔断器会在资源访问完成时更新统计信息,并根据熔断规则更新熔断器的状态机。

熔断策略

静默期: 三种熔断策略都支持静默期,静默期指的是最小的静默请求数,在一个统计周期里,如果对资源的请求数小于设置的静默数,熔断器不会基于统计值去更改熔断器的状态。 比如在统计周期刚开始的时候,第一个请求恰好是慢请求,这时候慢调用比例是100%,显然不合理。静默期提高了熔断器的精准性且降低了误判的可能性。 支持的几种熔断策略:(前提都是不在静默期)

慢调用比例策略:慢调用的比例大于设置的阈值,需要设置RT临界值(最大响应时间),对该资源访问的响应时间大于该阈值即为慢调用。

  • 错误比例策略: 统计周期内资源请求访问异常的比例大于阈值。
  • 错误计数策略: 请求访问异常数大于设定的阈值。

如果规则指定熔断器策略采用错误比例或则错误计数,那么为了统计错误比例或错误计数,需要调用API: api.TraceError(entry, err) 埋点每个请求的业务异常。

 熔断降级规则定义

// Rule encompasses the fields of circuit breaking rule.
type Rule struct {
	Id string `json:"id,omitempty"`//全局唯一ID 可选
	Resource string   `json:"resource"` //资源名称
	Strategy Strategy `json:"strategy"` //熔断策略
	RetryTimeoutMs uint32 `json:"retryTimeoutMs"`//熔断出发后持续时间 ms
	MinRequestAmount uint64 `json:"minRequestAmount"` //静默数量
	StatIntervalMs uint32 `json:"statIntervalMs"` //统计的时间窗口长度 ms
	MaxAllowedRtMs uint64 `json:"maxAllowedRtMs"` //最大响应时间
	Threshold float64 `json:"threshold"` //阈值 
}

补充说明:

  • Strategy: 熔断策略,目前支持 SlowRequestRatio、ErrorRatio、ErrorCount 三种;

  • 选择慢调用比例(SlowRequestRatio)作为阈值

    • 设置允许的最大响应时间(MaxAllowedRtMs),请求的响应时间大于该值则统计为慢调用;
    • 通过 Threshold 字段设置触发熔断的慢调用比例,取值范围为 [0.0, 1.0];
    • 规则配置后,在单位统计时长内请求数目大于设置的最小请求数目(静默期),并且慢调用的比例大于阈值,接下来的熔断时长内请求会自动被熔断;
    • 经过熔断时长后熔断器会进入探测恢复状态,若接下来的一个请求响应时间小于设置的最大 RT,则结束熔断,否则会再次被熔断。
  • 选择错误比例 (ErrorRatio) 作为阈值

    • 设置触发熔断的异常比例(Threshold),取值范围为 [0.0, 1.0];
    • 规则配置后,在单位统计时长内请求数目大于设置的最小请求数目,并且异常的比例大于阈值,则接下来的熔断时长内请求会自动被熔断;
    • 经过熔断时长后熔断器会进入探测恢复状态,若接下来的一个请求没有错误则结束熔断,否则会再次被熔断;
    • 代码中可以通过 api.TraceError(entry, err) 函数来记录 error。
  • Threshold:

    • 对于慢调用熔断策略,Threshold 表示慢调用比例的阈值(小数表示,比如 0.1 表示 10%);
    • 对于错误比例策略,Threshold 表示错误比例的阈值(小数表示,比如 0.1 表示 10%);
    • 对于错误数策略,Threshold 是错误计数的阈值。
  • MaxAllowedRtMs:

    • 仅对慢调用比例 (SlowRequestRatio) 策略有效,表示允许的最大响应时间。
  • 其他字段:

    • Resource、RetryTimeoutMs、MinRequestAmount、StatIntervalMs 每个规则都必设;
    • StatIntervalMs 表示熔断器的统计周期,单位是毫秒,一般设置为约 10 秒;
    • RetryTimeoutMs 的设置需要根据实际情况设置探测周期,一般设置为约 10 秒。

配置参考

// 慢调用比例规则
rule1 := &Rule{
        Resource:         "abc",
        Strategy:         SlowRequestRatio,
		RetryTimeoutMs:   5000,
		MinRequestAmount: 10,
		StatIntervalMs:   10000,
		MaxAllowedRtMs:   20,
		Threshold:        0.1,
},
// 错误比例规则
rule1 := &Rule{
        Resource:         "abc",
        Strategy:         ErrorRatio,
		RetryTimeoutMs:   5000,
		MinRequestAmount: 10,
		StatIntervalMs:   10000,
		Threshold:        0.1,
},
// 错误计数规则
rule1 := &Rule{
        Resource:         "abc",
        Strategy:         ErrorCount,
		RetryTimeoutMs:   5000,
		MinRequestAmount: 10,
		StatIntervalMs:   10000,
		Threshold:        100,
},

最佳场景实践

熔断器一般用于应用对外部资源访问时的保护措施。这里简单描述一些场景:

分布式系统中降级:假设存在应用A需要调用应用B的接口(特别是一些对接外部公司或者业务的接口时候),那么一般用于A调用B的接口时的防护; 数据库慢调用的防护: 假设应用需要读/写数据库,但是该读写SQL存在潜在慢SQL的可能性,那么可以对该读写接口做防护,当接口不稳定时候(存在慢SQL),那么基于熔断器做降级。 也可以是应用中任意弱依赖接口做降级防护(即自动降级后不影响业务核心链路)

热点并发隔离控制

流控方式

在热点参数流控中是根据MetricType+ControlBehavior组合来提供不同的流控策略,相比于流量控制的多种流控方式热点参数流控方式只有三种:

  • Concurrency+Reject:并发超过阈值直接拒绝的流控方式
  • QPS+Reject:QPS超过阈值直接拒绝
  • QPS+Throttling:QPS超过阈值匀速排队

统计结构

在热点参数流控中,使用滑动时间窗口进行流量统计可能不是最优的选择。这是因为热点参数流控的特点是参数值的数量不固定,可能会非常多,并且只需要统计一段时间内的热点数据。如果使用滑动时间窗口,可能会导致每个参数值都对应一个统计结构,即使某些参数值只有一次请求也会占用资源,这样的资源浪费是不必要的。

相反,针对热点参数流控,更合适的方法是只统计一段时间内的热点数据的请求次数,对于非热点数据可以进行淘汰,以节省内存空间。这样可以在满足流控需求的同时,有效地管理资源,提高系统的性能和效率。

在常见的缓存淘汰算法中有LRU和LFU

  • LRU算法:(Least Recently Used)最近最少使用的数据会被淘汰,而最近频繁使用的数据会留在缓存中
  • LFU算法:(Least Frequently Used)最不经常使用的数据会被淘汰,它是一种根据数据使用频率来进行缓存淘汰的算法,当缓存空间不足时,LFU 算法会优先淘汰访问频率最低的数据。

热点数据的统计比较符合LRU算法的特点,所以在Sentinel Go中选择使用LRU 策略统计最近最常访问的热点参数,下面来看一下具体的工程实现

首先在hotspot模块下有一个Cache目录,在Cache目录里有ConcurrentCounterCache 接口,此接口是对热点参数统计结构的抽象,实现此接口就可以作为热点参数限流的统计结构

type ConcurrentCounterCache interface {
   Add(key interface{}, value *int64)
   AddIfAbsent(key interface{}, value *int64) (priorValue *int64)
   Get(key interface{}) (value *int64, isFound bool)
   Remove(key interface{}) (isFound bool)
   Contains(key interface{}) (ok bool)
   Keys() []interface{}
   Len() int
   Purge()
}

在Cache目录下的concurrent_lru.go文件中是ConcurrentCounterCache接口的具体实现,concurrent_lru中实现了并发安全的lru算法

type LruCacheMap struct {
   // Not thread safe
   lru  *LRU
   lock *sync.RWMutex
}
func (c *LruCacheMap) Add(key interface{}, value *int64) {
   c.lock.Lock()
   defer c.lock.Unlock()
   c.lru.Add(key, value)
   return
}
func (c *LruCacheMap) Get(key interface{}) (value *int64, isFound bool) {
   c.lock.Lock()
   defer c.lock.Unlock()
   val, found := c.lru.Get(key)
   if found {
      return val.(*int64), true
   }
   return nil, false
}

在Cache目录下的lru.go中则是lru算法的真正实现,也是热点数据真正存储的数据结构(在LRU算法中Add,Get等操作会将元素移动到队列头部,当元素中个数超过容量时会将队尾元素淘汰)

type LRU struct {
   size      int
   evictList *list.List
   items     map[interface{}]*list.Element
   onEvict   EvictCallback
}
func (c *LRU) Add(key, value interface{}) {
   // Check for existing item
   if ent, ok := c.items[key]; ok {
      c.evictList.MoveToFront(ent)
      ent.Value.(*entry).value = value
      return
   }
   // Add new item
   ent := &entry{key, value}
   entry := c.evictList.PushFront(ent)
   c.items[key] = entry
   evict := c.evictList.Len() > c.size
   // Verify size not exceeded
   if evict {
      c.removeOldest()
   }
   return
}
//省略部分代码 ......

在hotspot模块下的params_metric.go文件中,声明了ParamsMetric 结构,ParamsMetric就是热点参数的统计结构,在不同的控制行为中对统计结构中的LRU有不同的使用方式:

// 热点参数的计数器,key=热点参数的值,value=计数器
type ParamsMetric struct {
   // 记录热点参数值最后添加令牌的时间
   RuleTimeCounter cache.ConcurrentCounterCache
   // 记录热点热点参数值对应的令牌个数
   RuleTokenCounter cache.ConcurrentCounterCache
   // 记录实时热点参数值对应的并发个数
   ConcurrencyCounter cache.ConcurrentCounterCache
}

流量控制

下面重点介绍热点参数流控是如何利用LRU统计结构实现的流量计算以及流量的控制。

并发超过阈值-直接拒绝

并发超过阈值直接拒绝的流程比较简单,只需要用到统计结构(ParamsMetric)中的ConcurrencyCounter来记录热点参数值的并发数量即可,如下:

并发流控行为对应的代码实现:

func (c *baseTrafficShapingController) performCheckingForConcurrencyMetric(arg interface{}) *base.TokenResult {
   specificItem := c.specificItems
   initConcurrency := int64(0)
   concurrencyPtr := c.metric.ConcurrencyCounter.AddIfAbsent(arg, &initConcurrency)
   if concurrencyPtr == nil {
      // First to access this arg
      return nil
   }
   concurrency := atomic.LoadInt64(concurrencyPtr)
   concurrency++
   if specificConcurrency, existed := specificItem[arg]; existed {
      if concurrency <= specificConcurrency {
         return nil
      }
      msg := fmt.Sprintf("hotspot specific concurrency check blocked, arg: %v", arg)
      return base.NewTokenResultBlockedWithCause(base.BlockTypeHotSpotParamFlow, msg, c.BoundRule(), concurrency)
   }
   threshold := c.threshold
   if concurrency <= threshold {
      return nil
   }
   msg := fmt.Sprintf("hotspot concurrency check blocked, arg: %v", arg)
   return base.NewTokenResultBlockedWithCause(base.BlockTypeHotSpotParamFlow, msg, c.BoundRule(), concurrency)
}

QPS超过阈值-直接拒绝

在QPS超过阈值直接拒绝的流控行为中会用到统计结构中的RuleTImeCounter和RuleTokenCounter,分别是记录热点参数值最后添加令牌的时间和热点参数值对应的令牌个数(令牌桶)。RuleTImeCounter和RuleTokenCounter都是利用LRU实现。

详细流程如下:

  1. 当流量请求经过时首先会获取参数对应的最后一次添加令牌的时间
  2. 如果没有获取到最后添加令牌的时间则有两种情况:
  3. 这个参数是第一次请求,需要为这个参数初始化令牌桶以及添加令牌的时间(对桶中的令牌计数器设置为流控规则中的阈值,添加令牌的时间设置为当前时间)
  4. 这个参数不是第一次请求并且不是热点数据,因为LRU算法的特性之前将它从队列中移除了,这种情况也同样需要初始化令牌桶以及添加令牌的时间
  5. 如果获取到最后添加令牌的时间会根据当前时间与最后一次添加令牌的时间进行计算时间差
  6. 如果计算结果>流控规则中的统计时长(DurationInSec)则需要重置令牌桶和添加令牌的时间
  7. 如果计算结果<=流控规则中的统计时长(DurationInSec)则从令牌桶中扣减令牌
  8. 如果扣减后令牌桶中的令牌个数>0则放行流量
  9. 如果扣减后令牌桶中的令牌个数<=0则直接拒绝当前流量

QPS超过阈值-匀速排队

在QPS超过阈值匀速排队的流控行为中会用到统计结构中的RuleTImeCounter,与QPS直接拒绝的流控行为实现不同的是,在匀速排队场景下只使用了RuleTImeCounter,利用RuleTImeCounter记录热点参数值对应的最后通过时间。

匀速排队里有个很重要的概念就是流量的预期通过时间(漏桶算法)。 流量预期通过时间=流控周期(durationInSec)/ 限流阈值(Threshold)+ 当前热点参数值最后的通过时间

详细流程如下:

  1. 首先当流量请求经过时会到统计结构(ParamsMetric)中的RuleTimeCounter(LRU)中获取当前参数值最后一次通过的时间
  2. 如果当前流量预期通过的时间<=当前时间则直接更新最后通过时间为当前时间并放行流量
  3. 如果当前流量预期通过时间>当前时间,则需要计算两个时间的差值,利用差值和流控规则中最大排队时间(MaxQueueingTImeMs)进行比对
  4. 如果差值<最大排队时间说明当前流量需要进行排队等待
  5. 如果差值>=最大排队时间说明当前流量不能进行排队等待需要被拒绝