sentinel限流控制器

在SytemRule,DegradeRule等规则中,有针对保护系统资源做限流,有针对rt,异常等做降级限流的。但是如何针对具体资源限定,进行限流。
在FlowRule中,需要获取一个节点,以这个节点,做数值参考,例如有ClusterNode,有OriginNode,或者默认的DefaultNode。在选取Node时,FlowRule都有对应接口TrafficShapingController控制工具做为限流判断。

  void checkFlow(ResourceWrapper resource, Context context, DefaultNode node, int count, boolean prioritized) throws BlockException {
        // Flow rule map cannot be null.
        Map> flowRules = FlowRuleManager.getFlowRuleMap();

        List rules = flowRules.get(resource.getName());
        if (rules != null) {
            for (FlowRule rule : rules) {
                if (!canPassCheck(rule, context, node, count, prioritized)) {
                    throw new FlowException(rule.getLimitApp(), rule);
                }
            }
        }
    }

在FlowSlot中,通过resource中的name,资源命名从FlowRule管理器中得到他的所有规则,然后进行遍历,check是否通过。
在TrafficShapingController 控制器中具体有四种实现方式,默认控制器,速率控制器,预热控制器,速率与预热结合的控制器。

• DefaultController 默认控制器。
主要通过node中获取的线程数量,或者档期那qps与规则中的阈值比较,不符合就限制。

    public boolean canPass(Node node, int acquireCount, boolean prioritized) {
        int curCount = avgUsedTokens(node);
        if (curCount + acquireCount > count) {
            if (prioritized && grade == RuleConstant.FLOW_GRADE_QPS) {
                long currentTime;
                long waitInMs;
                currentTime = TimeUtil.currentTimeMillis();
                waitInMs = node.tryOccupyNext(currentTime, acquireCount, count);
                if (waitInMs < OccupyTimeoutProperty.getOccupyTimeout()) {
                    node.addWaitingRequest(currentTime + waitInMs, acquireCount);
                    node.addOccupiedPass(acquireCount);
                    sleep(waitInMs);

                    // PriorityWaitException indicates that the request will pass after waiting for {@link @waitInMs}.
                    throw new PriorityWaitException(waitInMs);
                }
            }
            return false;
        }
        return true;
    }

    private int avgUsedTokens(Node node) {
        if (node == null) {
            return DEFAULT_AVG_USED_TOKENS;
        }
        return grade == RuleConstant.FLOW_GRADE_THREAD ? node.curThreadNum() : (int)(node.passQps());
    }

在该段核心代码中,avgUsedTokens方法中,其实时获取当前节点线程数或者当前的qps,与当前规则中count,阈值比较。其中,如果存在优先级的资源申请,不能因为当前的限制,将有优先级请求直接否定,这边如何解决的?有优先级申请资源时,虽然当前状况下资源已近消耗完,但是可以占用将来的一个令牌,并且node通过addOccupiedPass方法增加资源数量,然后sleep一段时间,时间到后,抛出PriortyWaite异常,该异常不会被记录异常数值中。在StatisticSlot中体现,对PriortyWaite异常抓取,并且只是增加线程数量增加,并没有对pass增加,为什么?看一下如何实现。
StatisticNode类中 tryOccupyNext方法:

    public long tryOccupyNext(long currentTime, int acquireCount, double threshold) {
        double maxCount = threshold * IntervalProperty.INTERVAL / 1000;
        long currentBorrow = rollingCounterInSecond.waiting();
        if (currentBorrow >= maxCount) {
            return OccupyTimeoutProperty.getOccupyTimeout();
        }
        int windowLength = IntervalProperty.INTERVAL / SampleCountProperty.SAMPLE_COUNT;
        long earliestTime = currentTime - currentTime % windowLength + windowLength - IntervalProperty.INTERVAL;
        int idx = 0;
        long currentPass = rollingCounterInSecond.pass();
        while (earliestTime < currentTime) {
            // 距离idx+下一个窗口的时间
            long waitInMs = idx * windowLength + windowLength - currentTime % windowLength;
            if (waitInMs >= OccupyTimeoutProperty.getOccupyTimeout()) {
                break;
            }
            // 得到最近窗口的pass值
            long windowPass = rollingCounterInSecond.getWindowPass(earliestTime);
            if (currentPass + currentBorrow + acquireCount - windowPass <= maxCount) {
                return waitInMs;
            }
            earliestTime += windowLength;
            currentPass -= windowPass;
            idx++;
        }
        return OccupyTimeoutProperty.getOccupyTimeout();
    }

maxCount是指在周期内,最大的数值;
currentBorrow是指当前已经等待资源的数量(下一个周期成功借了多少资源,在未来时间内,占好坑的);
windowLength指一个窗口的时间长度;
earliestTime指当前时间窗口为周期的,开始窗口的时间点,例如一个周期是由4个窗口组成的,假设当前窗口是处于第一个窗口(该窗口为距离当前时间最近的窗口),那么earliestTime时间是这段周期的开始时间,即为第二个窗口(该窗口与第一个窗口组成了一个周期);
currentPass指这段周期已经pass的值;
整个方法最终结果是想得到一个等待的时间,但是该等待时间也是有最大限制的。在while循环内,earliestTime一定要小于currentTime的,currentTime比earliestTime大((SampleCountProperty.SAMPLE_COUNT-1)个窗口时间+currentTime % windowLength)这么多时间值,idx表示已经遍历次数。当第一次时,waitInMs的时间其实时当前时间窗口的下一个窗口开始时间减去当前时间,意思就是等待waitInMs,就是下一个窗口的开始时间了。通过earliestTime获取与他关联的窗口pass值。那么在接下来的
(currentPass + currentBorrow + acquireCount - windowPass <= maxCount) 判断,maxCount是在一个完整周期内最大的阈值,减去windowPass是相当于减去了一个窗口的统计值,剩余已经pass的值与已经占用的资源是否小于等于maxCount。在条件不满足情况下,会将earliestTime增加一个窗口长度,currentPass也会减去一个窗口的记录pass值。
此地需要图解释
如何记录未来已经占用的资源。
在StatisticNode节点中rollingCounterInSecond属性中,在构造器中,默认由OccupiableBucketLeapArray类去控制,而该类也是继承了LeapArray的,但是他由内置属性FutureBucketLeapArray,其实就是通过该类型记录未来资源占用。这里有个思考,未来占用的资源,到达时间点后,这些资源也应该记录在当前已经申请的资源中,确实要记录,在LeapArray获取窗口时,如果窗口过期都会去重置窗口,那么OccupiableBucketLeapArray也重写了窗口重置方法:

  protected WindowWrap resetWindowTo(WindowWrap w, long time) {
        // Update the start time and reset value.
        w.resetTo(time);
        MetricBucket borrowBucket = borrowArray.getWindowValue(time);
        if (borrowBucket != null) {
            w.value().reset();
            w.value().addPass((int)borrowBucket.pass());
        } else {
            w.value().reset();
        }

        return w;
    }

先重置窗口的开始时间,然后通过time,在borrowArray中得到该窗口的统计槽,并且将borrowBucket中的pass值赋值给重置窗口中,这样就将已经出借的一个窗口下的pass值给了当前窗口了。所以在抛出PriorityWaitException异常时StatiticSlot没有对node进行添加pass动作,只是增加了线程动作。
在得到满足条件的一个waitInMs值时,并且waitInMs其实是当前时间距离下一个(或者多个)窗口开始时间的时间差,那么currentTime + waitInMs就是未来某一个窗口的开始时间,并且对该窗口中增加wait资源,然后线程sleep掉waitInMs时间。这是有优先级资源申请时的逻辑。

• RateLimiterController 限速控制器
其实他与本身node统计的数值没有多大关系,而且与自身设置的最大等待时间maxQueueingTimeMs与qps设定的count有关。canPass方法

  public boolean canPass(Node node, int acquireCount, boolean prioritized) {      
        if (acquireCount <= 0) {
            return true;
        }      
        if (count <= 0) {
            return false;
        }
        long currentTime = TimeUtil.currentTimeMillis();
        // Calculate the interval between every two requests.
        long costTime = Math.round(1.0 * (acquireCount) / count * 1000);
        // Expected pass time of this request.
        long expectedTime = costTime + latestPassedTime.get();
        if (expectedTime <= currentTime) {
            // Contention may exist here, but it's okay.
            latestPassedTime.set(currentTime);
            return true;
        } else {
            // Calculate the time to wait.
            long waitTime = costTime + latestPassedTime.get() - TimeUtil.currentTimeMillis();
            if (waitTime > maxQueueingTimeMs) {
                return false;
            } else {
                long oldTime = latestPassedTime.addAndGet(costTime);
                try {
                    waitTime = oldTime - TimeUtil.currentTimeMillis();
                    if (waitTime > maxQueueingTimeMs) {
                        latestPassedTime.addAndGet(-costTime);
                        return false;
                    }
                    // in race condition waitTime may <= 0
                    if (waitTime > 0) {
                        Thread.sleep(waitTime);
                    }
                    return true;
                } catch (InterruptedException e) {
                }
            }
        }
        return false;
    }

costTime:acquireCount个数资源在count下的预估所需耗时;
expectedTime:预期的时间点;
latestPassedTime:最近通过的时间点;
在预期时间小于等于当前时间,就会判断通过。
waitTime表示预计耗时加上latestPassedTime时间,最后减去当前时间,即为耗时。如果该waitTime大于了最大等待时间,则不能通过。在latestPassedTime尝试增加耗时,如果增加后得到的值减去当前时间,任然大于最大等待时间,那么latestPassedTime需要减去已经增加的costTime,如果通过判断,则线程sleep时间waitTime。latestPassedTime是原子的,不用考虑线程安全问题。那么在高并发下同一时间点,最多能申请多少资源呢?
一个资源需要消耗Math.round(1.0 * 1/ count * 1000)时间,然后有n个请求进来,那么一个公式 n*Math.round(1.0 * 1/ count * 1000)<= maxQueueingTimeMs。因为latestPassedTime时间是通过currentTime增加n个申请资源耗时的时间累加得到的。所以可以想象一下,有多个请求进来时,他们并不会并发执行,而是有时间顺序的依次执行。

• WarmUpController 预热控制器
预热是将请求随着时间推移,慢慢增加通过量,防止系统在短时间内,收到比较大的流量冲击,保护系统稳定,防止出现过载等情形。

    public WarmUpController(double count, int warmUpPeriodInSec, int coldFactor) {
        construct(count, warmUpPeriodInSec, coldFactor);
    }

    public WarmUpController(double count, int warmUpPeriodInSec) {
        construct(count, warmUpPeriodInSec, 3);
    }

    private void construct(double count, int warmUpPeriodInSec, int coldFactor) {

        if (coldFactor <= 1) {
            throw new IllegalArgumentException("Cold factor should be larger than 1");
        }     
        this.count = count;
        this.coldFactor = coldFactor;

        // thresholdPermits = 0.5 * warmupPeriod / stableInterval.
        // warningToken = 100;
        warningToken = (int)(warmUpPeriodInSec * count) / (coldFactor - 1);
        // / maxPermits = thresholdPermits + 2 * warmupPeriod /
        // (stableInterval + coldInterval)
        maxToken = warningToken + (int)(2 * warmUpPeriodInSec * count / (1.0 + coldFactor));
        // slope
        // slope = (coldIntervalMicros - stableIntervalMicros) / (maxPermits
        // - thresholdPermits);
        slope = (coldFactor - 1.0) / count / (maxToken - warningToken);

    }

在构造器中,初始化了最大令牌数maxToken,需要警告的令牌数warningToken,和需要算法指定的斜率slope。思考一下,什么是预热,他是保证在一定的请求量进来时,顺着时间的推移,越来约放宽,能够申请资源的请求会越来越多的功能。其实sentinel的越热思想,参考了guava中预热的计算思想

到达脉冲的请求可能会拖累长时间空闲的系统,即使它在稳定期间具有更大的处理能力。它通常发生在需要额外时间进行初始化的场景中,例如,DB建立连接、连接到远程服务等。所以我们需要“热身”。
Sentinel的“预热”实现基于guava的算法。然而,guava的实现集中于调整请求间隔,这类似于漏桶。Sentinel更注重在不计算其间隔的情况下控制每秒传入请求的计数,这类似于令牌桶算法。
桶中剩余的令牌用于测量系统实用程序。假设一个系统可以每秒处理B个请求。每秒钟B令牌将被添加到bucket中,直到bucket满为止。当系统处理一个请求时,它从桶中获取一个令牌。存储桶中剩余的令牌越多,系统的利用率就越低;当令牌存储桶中的令牌高于某个阈值时,我们称之为“饱和”状态。

所以sentinel给出的策略是什么?即当前令牌数获取的越多,那么系统饱和度越高,如果系统令牌获取少,则系统利用率低,这时候需要进行更加严格的限流措施。
了解一下canPass方法:

    public boolean canPass(Node node, int acquireCount, boolean prioritized) {
        long passQps = (long) node.passQps();
        long previousQps = (long) node.previousPassQps();
        syncToken(previousQps);
        // 开始计算它的斜率
        // 如果进入了警戒线,开始调整他的qps
        long restToken = storedTokens.get();
        if (restToken >= warningToken) {
            // 剩余的令牌数量较多,并且超过了警戒令牌数量,那么需要进行严格限制
            long aboveToken = restToken - warningToken;        

            // current interval = restToken*slope+1/count
            double warningQps = Math.nextUp(1.0 / (aboveToken * slope + 1.0 / count));          
            if (passQps + acquireCount <= warningQps) {
                return true;
            }
        } else {
            if (passQps + acquireCount <= count) {
                return true;
            }
        }
        return false;
    }

该方法中有两个ops,passOps是当前时间的qps,而previousOps可以认为是上一秒的pqs,storeTokens是指当前存储的token令牌,当storeTokens越小,则系统的使用率是越高的。但是系统是来预热,所以在当前令牌数越大,系统就需要进行限制。所以是否通过,就需要通过当前剩余的令牌数量进行相应的公式,得到一个可以通过的warningQps,这个pqs可以看到公式,随着aboveToken 越来越大,qps是越小的,就限制了pass的阈值的。当然,storeTokens的令牌如何变化,例如如何减少,如何重置的。
同步syncToken()方法:

    protected void syncToken(long passQps) {
        long currentTime = TimeUtil.currentTimeMillis();
        // 将时间只为秒整数,去除了毫秒
        currentTime = currentTime - currentTime % 1000;

        long oldLastFillTime = lastFilledTime.get();
        if (currentTime <= oldLastFillTime) {
            // 如果最近一次填充时间与当前时间在同一秒内
            return;
        }
        // 不在同一秒
        long oldValue = storedTokens.get();
        // 在不同时间内,新累积的令牌数量
        long newValue = coolDownTokens(currentTime, passQps);

        if (storedTokens.compareAndSet(oldValue, newValue)) {
            // 并且重置storedTokens令牌,并且扣减掉已经使用的passQps
            long currentValue = storedTokens.addAndGet(0 - passQps);
            if (currentValue < 0) {
                storedTokens.set(0L);
            }
            lastFilledTime.set(currentTime);
        }

    }

其中lastFilledTime是记录最近一次更新storeToken的时间的,但是这个有个限制,如果记录的时间与当前时间currentTime是同一秒的,就不会更新storeToken值的。所以简单的理解,storeToken值是按秒数更新的,并且在同一秒内,warningQps的值是一致的,对在同一秒内请求通过的资源是同等对待的。那么,storeToken是如何随着时间推移,慢慢去变化的?
通过coolDownTokens方法得到的新token值,然后storeTokens去重置,并且减去了上一秒的pqs(passQps)更新了lastFilledTime时间,storeTokens剩下部分是认为没有被获取的token值。好,考虑一下,storeToken值的重置,肯定是当前时间与lastFilledTime有关系的,随着时间的推移,storeToken也可能会增多。看一下coolDownTokens方法:

  private long coolDownTokens(long currentTime, long passQps) {
        long oldValue = storedTokens.get();
        long newValue = oldValue;

        // 添加令牌的判断前提条件:
        // 当令牌的消耗程度远远低于警戒线的时候
        if (oldValue < warningToken) {
            newValue = (long)(oldValue + (currentTime - lastFilledTime.get()) * count / 1000);
        } else if (oldValue > warningToken) {
            if (passQps < (int)count / coldFactor) {
                // qps 较低,系统利用率低,那么需要增加一些token令牌
                newValue = (long)(oldValue + (currentTime - lastFilledTime.get()) * count / 1000);
            }
        }
        return Math.min(newValue, maxToken);
    }

首先当storedToken剩余的数量已经小于warningToken,那么,确实要增加点令牌进去,他的公式是将剩余的令牌数+(当前时间-lastFilledTime时间)*每秒内的qps。
但是当storedTokens大于了警戒位,并没说类似之前的增加令牌公式一下。而且多了一层判断,上一秒的passQps与设定的比例小,才加上去,如果passQps较大,那storedToken任然是旧值。为什么需要这样?我们知道,storeToken值越小,那么准许进入的限制就会放开,该预热设计也是一秒一秒,慢慢夸大流量进入,那么count / coldFactor这个公式,是判断申请资源增强的标志,要不然会出现什么情况呢?预热控制器会一直绑死在越热阶段,并不会随着时间推移,流量增多而夸大pqs的阈值。

总结

每个限流控制器的策略不尽相同,但是目的都是在保护系统。

你可能感兴趣的:(sentinel限流控制器)