揭秘 Sentinel-Go [流量控制] 的实现原理

Posted by Binbin Zhang on Thu, Aug 3, 2023

1. 介绍

在上文中我们介绍了 Sentinel-Go 的基石【基于滑动时间窗口实现的统计数据结构】,Sentinel-Go利用底层的数据结构,在上层建筑了多种流量治理的场景,例如流量控制、熔断降级、热点参数等等。

本文将重点介绍 Sentinel-Go 流量控制的底层实现与设计思考,其中也会涉及到令牌桶,漏桶,冷启动等算法与架构的工程实践。

2. 流控规则

在正式开始前先了解下 Sentinel-Go 的流控规则的详细参数,字段含义使用注释标注

 1type Rule struct {
 2   // 资源名,即规则的作用目标。
 3   Resource               string                 `json:"resource"`
 4   // 当前流量控制器的Token计算策略。Direct表示直接使用Threshold 作为阈值;
 5   // WarmUp表示使用预热方式计算Token的阈值
 6   // MemoryAdaptive表示使用内存自适应方式计算Token的阈值
 7   TokenCalculateStrategy TokenCalculateStrategy `json:"tokenCalculateStrategy"`
 8   // 控制行为,Reject表示直接拒绝,Throttling表示匀速排队
 9   ControlBehavior        ControlBehavior        `json:"controlBehavior"`
10   // 表示流控阈值;如果字段 StatIntervalInMs 是1000(也就是1秒),
11   // 那么Threshold就表示QPS,流量控制器也就会依据资源的QPS来做流控。
12   Threshold        float64          `json:"threshold"`
13   // 调用关系限流策略,CurrentResource表示使用当前规则的resource做流控;
14   // AssociatedResource表示使用关联的resource做流控,关联的resource在字段 RefResource 定义;
15   RelationStrategy RelationStrategy `json:"relationStrategy"`
16   // 关联的resource
17   RefResource      string           `json:"refResource"`
18   // 匀速排队的最大等待时间,该字段仅仅对控制行为是匀速排队时生效
19   MaxQueueingTimeMs uint32 `json:"maxQueueingTimeMs"`
20   // 预热的时间长度,该字段仅仅对Token计算策略是WarmUp时生效;
21   WarmUpPeriodSec   uint32 `json:"warmUpPeriodSec"`
22   // 预热的因子,默认是3,该值的设置会影响预热的速度,该字段仅仅对Token计算策略是WarmUp时生效
23   WarmUpColdFactor  uint32 `json:"warmUpColdFactor"`
24   // 规则对应的流量控制器的独立统计结构的统计周期。如果StatIntervalInMs是1000,也就是统计QPS。
25   StatIntervalInMs uint32 `json:"statIntervalInMs"`
26   // 内存低使用率时的限流阈值,该字段仅在Token计算策略是MemoryAdaptive时生效
27   LowMemUsageThreshold  int64 `json:"lowMemUsageThreshold"`
28   // 内存高使用率时的限流阈值,该字段仅在Token计算策略是MemoryAdaptive时生效
29   HighMemUsageThreshold int64 `json:"highMemUsageThreshold"`
30   // 内存低水位标记字节大小,该字段仅在Token计算策略是MemoryAdaptive时生效
31   MemLowWaterMarkBytes  int64 `json:"memLowWaterMarkBytes"`
32   // 内存高水位标记字节大小,该字段仅在Token计算策略是MemoryAdaptive时生效
33   MemHighWaterMarkBytes int64 `json:"memHighWaterMarkBytes"`
34}

3. 流量控制

何谓流量控制,在 Sentinel-Go 中其本质是监控资源(Resource)的统计指标,然后根据 Token 计算策略来计算资源的可用 Token (也就是阈值),从而根据流量控制策略对流量进行控制,避免系统被瞬时的流量高峰冲垮,保障应用的高可用性。

3.1 流量控制策略

流量控制策略由规则中的 TokenCalculateStrategy (Token计算策略)和 ControlBehavior (控制行为) 两个字段共同决定。两个字段可以按实际场景按需组合使用,不同的组合对应的流量控制效果是不同的。

在Sentinel-Go中将Token计算策略和控制行为抽象为两个interface,在初始化时会根据流控规则创建对应的流量控制器,其中流量控制器中包含了下面两个接口的实现以及统计结构。

  • TrafficShapingCalculator:计算流量控制的实际阈值(Token)

  • TrafficShapingChecker: 根据实际阈值与统计结构中的指标进行流量控制

 1// TrafficShapingCalculator 根据规则的阈值和token计算策略计算实际的阈值
 2type TrafficShapingCalculator interface {
 3   BoundOwner() *TrafficShapingController
 4   CalculateAllowedTokens(batchCount uint32, flag int32) float64
 5}
 6
 7// TrafficShapingChecker 根据当前指标和控制行为进行检查,生成token控制结果。
 8type TrafficShapingChecker interface {
 9   BoundOwner() *TrafficShapingController
10   DoCheck(resStat base.StatNode, batchCount uint32, threshold float64) *base.TokenResult
11}

Sentinel-Go提供三种Token计算策略以及两种控制流量的行为

  • Token计算策略

  • 固定的限流阈值: 使用流控规则中的限流阈值,该方式是默认的流量控制方式,当QPS超过任意规则的阈值后,新的请求就会被立即拒绝,这种方式适用于对系统处理能力确切已知的情况下,比如通过压测确定了系统的准确水位时

  • WarmUp(预热):该方式主要用于系统长期处于低水位的情况下,当流量突然增加时,直接把系统拉升到高水位可能瞬间把系统压垮。通过"冷启动",让通过的流量缓慢增加,在一定时间内逐渐增加到阈值上限,给冷系统一个预热的时间,避免冷系统被压垮的情况

  • 内存自适应:该方式主要用于保护系统内存不会随着流量的增长而无限增长,在内存的安全界限内动态调整限流阈值,尽可能的提升吞吐。

  • 流控行为

  • 拒绝:当流量超过阈值时后面的流量将直接拒绝。这种行为简单粗暴可以很好的控制系统的负载,但是有损被拒绝掉的流量不会被服务处理。

  • 匀速排队:当QPS超过阈值时后面的流量将会按照固定时间依次排队通过,起到削峰填谷的效果。此行为是利用漏桶算法实现,适合用于间隔性流量突发的场景,例如:消息队列。

下面将详细介绍不同的Token计算策略和控制行为对应的效果以及实现方式。

3.2 Token计算策略

3.2.1 Direct(Thresold)

当流控规则中TokenCalculateStrategy设置为Direct,则代表使用规则中的Thresold当作阈值进行流控。 在Direct策略下Token不需要计算,使用规则中配置的固定值即可。

1func (d *DirectTrafficShapingCalculator) CalculateAllowedTokens(uint32, int32) float64 {
2   return d.threshold
3}

3.2.2 WarmUp(预热)

介绍

当流控规则中TokenCalculateStrategy设置为WarmUp,则代表使用预热(冷启动)方式计算Token(限流阈值),当系统长期处于低水位的情况下,当流量突然增加时,直接把系统拉升到高水位可能瞬间把系统压垮。通过"冷启动",让通过的流量缓慢增加,在一定时间内逐渐增加到阈值上限,给冷系统一个预热的时间,避免冷系统被压垮

通常冷启动的过程系统允许通过的 QPS 曲线如下图所示:

原理

在Sentinel-Go中的WarmUp实现参考了Guava的SmoothRateLimited,本质是一个令牌桶算法。

常规的令牌桶算法:有一个负责生产令牌的生产者以及负责获取令牌的消费者,当令牌桶满的时候生产者生产的令牌直接丢弃,否则将令牌存储到令牌桶中。当有流量经过时令牌消费者先从令牌桶中获取令牌,如果获取成功则放行当前流量,如果令牌桶中没有令牌则将当前流量拒绝。

按照令牌桶算法对应到Sentinel-Go中,负责生产令牌的生产者即是WarmUp的Token计算策略,负责消费令牌的消费者则是行为控制

在Sentinel-Go的WarmUp的令牌生成算法模型如下:

(这个图要从右向左进行理解,本质是要根据预热时间、限流阈值等参数推导出令牌生成的时间间隔(y轴),从而可以知道每秒能生成多少个令牌,即可达到动态生成限流阈值的效果)

  • x轴:令牌桶容量

  • y轴:每个令牌生成的时间间隔

  • warningToken:令牌预警数量,即令牌桶中的剩余令牌数量到达预警值时,预热结束。

  • maxToken:令牌桶最大容量,当令牌桶到达容量后,生成的令牌将被丢弃

  • slope:斜率,用来计算当前令牌生成的时间间隔

  • warmUpPeriodSec:系统冷启动的时间,单位秒

假设规则如下:阈值设置为100,冷启动时间为10S,冷却因子默认为3,滑动时间窗口统计周期为1000毫秒

1{
2   TokenCalculateStrategy: flow.WarmUp,
3   ControlBehavior:        flow.Reject,
4   Threshold:              100,
5   WarmUpPeriodSec:        10,
6   WarmUpColdFactor:       3,
7   StatIntervalInMs:       1000,
8}

在初始化WarmUp计算策略func中,会计算对应的warningToken=500, maxToken=1000, slope=0.00004

1warningToken := uint64((float64(rule.WarmUpPeriodSec) * rule.Threshold) / float64(rule.WarmUpColdFactor-1))
2
3
4maxToken := warningToken + uint64(2*float64(rule.WarmUpPeriodSec)*rule.Threshold/float64(1.0+rule.WarmUpColdFactor))
5
6
7slope := float64(rule.WarmUpColdFactor-1.0) / rule.Threshold / float64(maxToken-warningToken)

初始化后,对应参数如下:

接下来重点看下计算Token的算法:

 1func (c *WarmUpTrafficShapingCalculator) CalculateAllowedTokens(_ uint32, _ int32) float64 {
 2   // 获取滑动时间窗口前一个统计周期的QPS,依托于底层的统计结构
 3   metricReadonlyStat := c.BoundOwner().boundStat.readOnlyMetric
 4   previousQps := metricReadonlyStat.GetPreviousQPS(base.MetricEventPass)
 5   // 同步令牌桶中的令牌(包括生成和丢弃)
 6   c.syncToken(previousQps)
 7   // 原子获取令牌桶中的令牌数
 8   restToken := atomic.LoadInt64(&c.storedTokens)
 9   if restToken < 0 {
10      restToken = 0
11   }
12   // 如果桶中令牌数>=令牌预警线(500),代表还在冷启动阶段
13   if restToken >= int64(c.warningToken) {
14      // 计算桶中令牌和预警线的差值(也就是还有多少个令牌可用)
15      aboveToken := restToken - int64(c.warningToken)
16      // 动态计算出每秒允许通过的QPS阈值
17      warningQps := math.Nextafter(1.0/(float64(aboveToken)*c.slope+1.0/c.threshold), math.MaxFloat64)
18      return warningQps
19   } else {
20   // 如果桶中令牌数<令牌预警线,则说明冷启动已经结束,直接返回规则中的阈值
21      return c.threshold
22   }
23}

假设令牌桶中有1000(restToken)个令牌,slope=0.00004,warningToken=500, 此时在预热期间。那么aboveToken=500。

计算每秒通过的QPS阈值,首先看这段公式的:通过斜率算出当前token的生成时间间隔0.03毫秒(也就是求出X轴=1000时,Y轴的的数值=0.03)

1(float64(aboveToken)*c.slope+1.0/c.threshold=500*0.00004+1/100=0.03

然后通过当前token生成时间间隔计算出每秒可通过的QPS(token阈值)=33.33

1math.Nextafter(1.0/(float64(aboveToken)*c.slope+1.0/c.threshold), math.MaxFloat64)

在syncToken中主要是更新令牌桶中令牌的数量:

 1func (c *WarmUpTrafficShapingCalculator) syncToken(passQps float64) {
 2   // 获取当前时间(毫秒)
 3   currentTime := util.CurrentTimeMillis()
 4   // 获取当前时间(秒)
 5   currentTime = currentTime - currentTime%1000
 6
 7
 8   // 最后填充令牌桶时间
 9   oldLastFillTime := atomic.LoadUint64(&c.lastFilledTime)
10   // 如果当前时间小于最后填充时间,说明出现了时间回拨,则不需要填充/丢弃令牌
11   // 如果当前时间等于最后填充时间,说明在同一秒内已经同步过令牌桶了,避免重复填充/丢弃令牌
12   if currentTime <= oldLastFillTime {
13      return
14   }
15    
16   // 获取当前桶中的令牌数量
17   oldValue := atomic.LoadInt64(&c.storedTokens)
18   // 初始化/生成令牌
19   newValue := c.coolDownTokens(currentTime, passQps)
20   // 利用cas存储最新的令牌数量,避免并发不安全问题。
21   if atomic.CompareAndSwapInt64(&c.storedTokens, oldValue, newValue) {
22      // 最终桶中令牌数=桶中令牌数-已经通过的QPS
23      if currentValue := atomic.AddInt64(&c.storedTokens, int64(-passQps)); currentValue < 0 {
24         atomic.StoreInt64(&c.storedTokens, 0)
25      }
26      // 更新最后更新令牌桶的时间
27      atomic.StoreUint64(&c.lastFilledTime, currentTime)
28   }
29}

在coolDownTokens中初始化令牌桶以及填充令牌:

 1func (c *WarmUpTrafficShapingCalculator) coolDownTokens(currentTime uint64, passQps float64) int64 {
 2   oldValue := atomic.LoadInt64(&c.storedTokens)
 3   newValue := oldValue
 4
 5
 6   // 如果令牌桶中的令牌数量小于令牌预警线
 7   // 初始化时桶中令牌=0一定小于warningToken
 8   // 预热结束后,令牌桶中的数量也会小于预警线
 9   if oldValue < int64(c.warningToken) {
10     // 填充令牌=桶中令牌数+每秒应该生成的令牌数100
11      newValue = int64(float64(oldValue) + (float64(currentTime)-float64(atomic.LoadUint64(&c.lastFilledTime)))*c.threshold/1000.0)
12   } else if oldValue > int64(c.warningToken) {
13   // 如果令牌数量大于预警线,说明应该在预热期间 
14   // 但是如果通过的请求数(消费的令牌数)小于冷却数量,说明并没有真正的开始预热
15   // 则需要填充令牌,让桶中令牌维持在maxToken
16      if passQps < float64(uint32(c.threshold)/c.coldFactor) {
17         newValue = int64(float64(oldValue) + float64(currentTime-atomic.LoadUint64(&c.lastFilledTime))*c.threshold/1000.0)
18      }
19   }
20   // 当前生成的令牌小于最大令牌数
21   if newValue <= int64(c.maxToken) {
22      return newValue
23   } else {
24   // 如果但前令牌大雨最大令牌,则丢弃多余令牌,让桶中始终最多拥有maxToken
25      return int64(c.maxToken)
26   }
27}

总结

在Sentinel-Go中的WarmUp模型本质是令牌桶算法,在初始化时将令牌桶中"填满",预热期间扣减令牌,用剩余的令牌与斜率推导出每秒可以扣减的令牌数量,从而动态得出预热期间的限流阈值。

3.2.3 内存自适应

介绍

当流控规则中TokenCalculateStrategy设置为MemoryAdaptive,则代表使用内存自适应方式计算Token(限流阈值)。流量大小会间接影响系统内存使用率,当系统内存使用率过大时一定程度上也会影响吞吐量与请求耗时,在内存自适应中可以根据系统的内存使用情况动态的计算限流阈值,从而让系统更加平稳的运行 

原理

在Sentinel-Go中使用内存自适应流控的规则如下:(需要指定低和高水位内存的字节数以及内存低和高水位时对应的限流阈值)

 1{
 2   Resource:               resName,
 3   TokenCalculateStrategy: flow.MemoryAdaptive,
 4   ControlBehavior:        flow.Reject,
 5   StatIntervalInMs:       1000,
 6   LowMemUsageThreshold:   1000, // 低水位限流阈值
 7   HighMemUsageThreshold:  100,  // 高水位限流阈值
 8   MemLowWaterMarkBytes:  1024,  // 低水位内存字节
 9   MemHighWaterMarkBytes: 2048,  // 高水位内存字节
10},

本质上Sentinel-Go将内存划分为三个区域,分别是内存高水位区域,低水位区域以及阈值动态波动区域。当内存使用在高水位或低水位区域时则使用对应的水位阈值,如果内存在阈值的波动区域(高水位字节减低水位字节),那么则需要动态计算流控阈值。

Token计算逻辑也相对简单,只需要动态的获取内存已使用的字节(使用工具库gopsutil),然后去对比当前内存使用的字节在哪一个区域内,如果在动态的波动区域内则简单的数学公式计算出对应的动态流控阈值

1(float64(m.highMemUsageThreshold-m.lowMemUsageThreshold)/float64(m.memHighWaterMark-m.memLowWaterMark))*float64(mem-m.memLowWaterMark) + float64(m.lowMemUsageThreshold)

总结

内存自适应算法中提前将内存的最低以及最高水位对应的流控阈值规划好,然后根据内存使用情况在“安全的”范围内去动态控制流控阈值

3.3 控制行为(流控行为)

3.3.1 直接拒绝

介绍

当流控规则中ControlBehavior设置为Reject,则代表使用“拒绝”的流控行为,当请求流量大于限流阈值时则将超出阈值的请求直接返回。

原理

在拒绝的控制行为中实现相对简单,主要是依托于上文介绍的滑动时间轮统计结构,有了统计结构可以很轻松的获取到已通过的请求数,从而与限流阈值进行比对。

 1// 参数中threshold则是token计算策略中计算出的限流阈值
 2func (d *RejectTrafficShapingChecker) DoCheck(resStat base.StatNode, batchCount uint32, threshold float64) *base.TokenResult {
 3   // 获取统计结构
 4   metricReadonlyStat := d.BoundOwner().boundStat.readOnlyMetric
 5   if metricReadonlyStat == nil {
 6      return nil
 7   }
 8   // 获取当前统计周期内已通过的请求数量
 9   curCount := float64(metricReadonlyStat.GetSum(base.MetricEventPass))
10   // 已通过的请求+当前请求>限流阈值则直接返回限流结果
11   if curCount+float64(batchCount) > threshold {
12      msg := "flow reject check blocked"
13      return base.NewTokenResultBlockedWithCause(base.BlockTypeFlow, msg, d.rule, curCount)
14   }
15   return nil
16}

3.3.2 匀速排队

介绍

当流控规则中ControlBehavior设置为Throttling,则代表使用“匀速排队”(也叫匀速器)的流控行为。与直接拒绝的控制行为不同的是当请求流量大于限流阈值时则将超出阈值的请求按照一定的间隔时间匀速通过,对应的是漏桶算法。

原理

在Sentinel-Go中匀速排队的流控行为,本质上就是一个漏桶。漏桶的特点:流入速率不固定,但是流出速率是固定的。当前流量+已通过的流量大于阈值时将当前流量放入漏桶中,然后在漏桶里对流量按照固定的时间间隔排队通过。

排队的过程如下:

匀速排队的实现代码,在注释中讲解了细节的实现:

 1func (c *ThrottlingChecker) DoCheck(_ base.StatNode, batchCount uint32, threshold float64) *base.TokenResult {
 2   // 获取当前时间(单位纳秒,方便更精确的计算排队时间)
 3   curNano := int64(util.CurrentTimeNano())
 4
 5   // 计算每个流量排队的时间间隔,这里假设当前流量(batchCount=1),流控阈值(threshold=100),统计周期:单位纳秒(statIntervalNs=1000000000)
 6   intervalNs := int64(math.Ceil(float64(batchCount) / threshold * float64(c.statIntervalNs)))
 7   // 最后一个流量排队通过的时间
 8   loadedLastPassedTime := atomic.LoadInt64(&c.lastPassedTime)
 9   // 当前请求预计的通过时间=最后一个流量排队通过的时间+排队的时间间隔
10   expectedTime := loadedLastPassedTime + intervalNs
11   // 如果预计通过的时间小于当前时间,则说明不需要排队,直接放行。
12   if expectedTime <= curNano {
13      if swapped := atomic.CompareAndSwapInt64(&c.lastPassedTime, loadedLastPassedTime, curNano); swapped {
14         // nil means pass
15         return nil
16      }
17   }
18   // 预估排队等待的时长=当前请求预计的通过时间-当前时间
19   estimatedQueueingDuration := atomic.LoadInt64(&c.lastPassedTime) + intervalNs - curNano
20   // 如果预估排队时长大于设定的最大等待时长,则直接被拒绝掉当前流量
21   if estimatedQueueingDuration > c.maxQueueingTimeNs {
22      return base.NewTokenResultBlockedWithCause(base.BlockTypeFlow, BlockMsgQueueing, rule, nil)
23   }
24   // 原子操作:得到当前流量的通过时间(这里主要避免并发导致lastPassedTime不是最新的)
25   oldTime := atomic.AddInt64(&c.lastPassedTime, intervalNs)
26   // 预估排队等待的时长
27   estimatedQueueingDuration = oldTime - curNano
28   if estimatedQueueingDuration > c.maxQueueingTimeNs {
29      // 如果大于了设定的最大等待时长,这里减去排队间隔,因为不需要排队了,直接拒绝当前流量。
30      atomic.AddInt64(&c.lastPassedTime, -intervalNs)
31      return base.NewTokenResultBlockedWithCause(base.BlockTypeFlow, BlockMsgQueueing, rule, nil)
32   }
33   // 如果预估排队等待的时长大于0,则按照排队等待的时长进行sleep
34   if estimatedQueueingDuration > 0 {
35      return base.NewTokenResultShouldWait(time.Duration(estimatedQueueingDuration))
36   } else {
37      return base.NewTokenResultShouldWait(0)
38   }
39}

总结

在匀速排队的控制行为中将超过阈值的流量,按照固定的时间间隔依次的排队等待。 等待的时间是根据前一个流量的排队通过时间+排队的时间间隔计算出来的,等待的动作是sleep。符合漏桶的特点,按照固定的速率放行流量。

4. 基于调用关系的流量控制

Sentinel-Go 支持关联流量控制策略。当两个资源之间具有资源争抢或者依赖关系的时候,这两个资源便具有了关联。比如对数据库同一个字段的读操作和写操作存在争抢,读的速度过高会影响写得速度,写的速度过高会影响读的速度。如果放任读写操作争抢资源,则争抢本身带来的开销会降低整体的吞吐量。可使用关联限流来避免具有关联关系的资源之间过度的争抢。

当流控规则中RelationStrategy=AssociatedResource时则代表使用基于调用关系的流量控制

1{
2   Resource:               resName,
3   TokenCalculateStrategy: flow.Direct,
4   ControlBehavior:        flow.Reject,
5   RelationStrategy:       flow.AssociatedResource,
6   RefResource:            "db-write",
7   Threshold:              10,
8   StatIntervalInMs:       1000,
9}

这种方式的 Token 计算策略和流控行为的实现方式都和上面介绍的一样,只不过在初始化Token计算策略和流控行为时绑定的是关联资源的统计结构。这样在计算阈值以及获取当前QPS等指标都是获取的关联资源的数据,那么就可以依据关联资源的指标数据进行流控了。

5. 总结

Sentinel-Go 在流量控制中提供了多种流量控制场景。实现的方式是通过组合Token计算策略和流控行为,并基于底层的滑动时间窗口统计数据结构来进行流量控制(其中最有特点的是冷启动+匀速排队的组合方式,相当于令牌桶+漏桶的组合)本文通过对流量控制的实现细节进行详解,相信大家在实际运用中可以更好的选择对应的组合方式。

作者介绍

Github 账号:binbin0325,公众号:柠檬汁CodeSentinel-Golang Committer 、ChaosBlade Committer 、 Nacos PMC 、Apache Dubbo-Go Committer。目前主要关注于混沌工程、中间件以及云原生方向。