Spring Cloud Alibaba | Sentinel: 服務限流高階篇

極客挖掘機發表於2019-07-20

Spring Cloud Alibaba | Sentinel: 服務限流高階篇

Springboot: 2.1.6.RELEASE

SpringCloud: Greenwich.SR1

如無特殊說明,本系列文章全採用以上版本

上一篇《Spring Cloud Alibaba | Sentinel: 服務限流基礎篇》我們介紹了資源和規則,幾種主流框架的預設適配,我們接著聊一下熔斷降級和幾種其他的限流方式。

1. 熔斷降級

除了流量控制以外,對呼叫鏈路中不穩定的資源進行熔斷降級也是保障高可用的重要措施之一。由於呼叫關係的複雜性,如果呼叫鏈路中的某個資源不穩定,最終會導致請求發生堆積。Sentinel 熔斷降級會在呼叫鏈路中某個資源出現不穩定狀態時(例如呼叫超時或異常比例升高),對這個資源的呼叫進行限制,讓請求快速失敗,避免影響到其它的資源而導致級聯錯誤。當資源被降級後,在接下來的降級時間視窗之內,對該資源的呼叫都自動熔斷(預設行為是丟擲 DegradeException)。

1.1 降級策略

我們通常用以下幾種方式來衡量資源是否處於穩定的狀態:

  • 平均響應時間 (DEGRADE_GRADE_RT):當 1s 內持續進入 5 個請求,對應時刻的平均響應時間(秒級)均超過閾值(count,以 ms 為單位),那麼在接下的時間視窗(DegradeRule 中的 timeWindow,以 s 為單位)之內,對這個方法的呼叫都會自動地熔斷(丟擲 DegradeException)。注意 Sentinel 預設統計的 RT 上限是 4900 ms,超出此閾值的都會算作 4900 ms,若需要變更此上限可以通過啟動配置項 -Dcsp.sentinel.statistic.max.rt=xxx 來配置。

  • 異常比例 (DEGRADE_GRADE_EXCEPTION_RATIO):當資源的每秒請求量 >= 5,並且每秒異常總數佔通過量的比值超過閾值(DegradeRule 中的 count)之後,資源進入降級狀態,即在接下的時間視窗(DegradeRule 中的 timeWindow,以 s 為單位)之內,對這個方法的呼叫都會自動地返回。異常比率的閾值範圍是 [0.0, 1.0],代表 0% - 100%。

  • 異常數 (DEGRADE_GRADE_EXCEPTION_COUNT):當資源近 1 分鐘的異常數目超過閾值之後會進行熔斷。注意由於統計時間視窗是分鐘級別的,若 timeWindow 小於 60s,則結束熔斷狀態後仍可能再進入熔斷狀態。

注意:異常降級僅針對業務異常,對 Sentinel 限流降級本身的異常(BlockException)不生效。為了統計異常比例或異常數,需要通過 Tracer.trace(ex) 記錄業務異常。示例:

Entry entry = null;
try {
  entry = SphU.entry(key, EntryType.IN, key);

  // Write your biz code here.
  // <<BIZ CODE>>
} catch (Throwable t) {
  if (!BlockException.isBlockException(t)) {
    Tracer.trace(t);
  }
} finally {
  if (entry != null) {
    entry.exit();
  }
}

開源整合模組,如 Sentinel Dubbo Adapter, Sentinel Web Servlet Filter 或 @SentinelResource 註解會自動統計業務異常,無需手動呼叫。

2. 熱點引數限流

何為熱點?熱點即經常訪問的資料。很多時候我們希望統計某個熱點資料中訪問頻次最高的 Top K 資料,並對其訪問進行限制。比如:

  • 商品 ID 為引數,統計一段時間內最常購買的商品 ID 並進行限制
  • 使用者 ID 為引數,針對一段時間內頻繁訪問的使用者 ID 進行限制

熱點引數限流會統計傳入引數中的熱點引數,並根據配置的限流閾值與模式,對包含熱點引數的資源呼叫進行限流。熱點引數限流可以看做是一種特殊的流量控制,僅對包含熱點引數的資源呼叫生效。

Spring Cloud Alibaba | Sentinel: 服務限流高階篇

Sentinel 利用 LRU 策略統計最近最常訪問的熱點引數,結合令牌桶演算法來進行引數級別的流控。

2.1 專案依賴

<dependency>
  <groupId>com.alibaba.csp</groupId>
  <artifactId>sentinel-parameter-flow-control</artifactId>
  <version>x.y.z</version>
</dependency>

然後為對應的資源配置熱點引數限流規則,並在 entry 的時候傳入相應的引數,即可使熱點引數限流生效。

注:若自行擴充套件並註冊了自己實現的 SlotChainBuilder,並希望使用熱點引數限流功能,則可以在 chain 裡面合適的地方插入 ParamFlowSlot

那麼如何傳入對應的引數以便 Sentinel 統計呢?我們可以通過 SphU 類裡面幾個 entry 過載方法來傳入:

public static Entry entry(String name, EntryType type, int count, Object... args) throws BlockException

public static Entry entry(Method method, EntryType type, int count, Object... args) throws BlockException

其中最後的一串 args 就是要傳入的引數,有多個就按照次序依次傳入。比如要傳入兩個引數 paramAparamB,則可以:

// paramA in index 0, paramB in index 1.
// 若需要配置例外項或者使用叢集維度流控,則傳入的引數只支援基本型別。
SphU.entry(resourceName, EntryType.IN, 1, paramA, paramB);

注意 :若 entry 的時候傳入了熱點引數,那麼 exit 的時候也一定要帶上對應的引數(exit(count, args)),否則可能會有統計錯誤。正確的示例:

Entry entry = null;
try {
    entry = SphU.entry(resourceName, EntryType.IN, 1, paramA, paramB);
    // Your logic here.
} catch (BlockException ex) {
    // Handle request rejection.
} finally {
    if (entry != null) {
        entry.exit(1, paramA, paramB);
    }
}

對於 @SentinelResource 註解方式定義的資源,若註解作用的方法上有引數,Sentinel 會將它們作為引數傳入 SphU.entry(res, args)。比如以下的方法裡面 uidtype 會分別作為第一個和第二個引數傳入 Sentinel API,從而可以用於熱點規則判斷:

@SentinelResource("myMethod")
public Result doSomething(String uid, int type) {
  // some logic here...
}

2.2 熱點引數規則

熱點引數規則(ParamFlowRule)類似於流量控制規則(FlowRule):

屬性 說明 預設值
resource 資源名,必填
count 限流閾值,必填
grade 限流模式 QPS 模式
durationInSec 統計視窗時間長度(單位為秒),1.6.0 版本開始支援 1s
controlBehavior 流控效果(支援快速失敗和勻速排隊模式),1.6.0 版本開始支援 快速失敗
maxQueueingTimeMs 最大排隊等待時長(僅在勻速排隊模式生效),1.6.0 版本開始支援 0ms
paramIdx 熱點引數的索引,必填,對應 SphU.entry(xxx, args) 中的引數索引位置
paramFlowItemList 引數例外項,可以針對指定的引數值單獨設定限流閾值,不受前面 count 閾值的限制。僅支援基本型別
clusterMode 是否是叢集引數流控規則 false
clusterConfig 叢集流控相關配置

我們可以通過 ParamFlowRuleManagerloadRules 方法更新熱點引數規則,下面是一個示例:

ParamFlowRule rule = new ParamFlowRule(resourceName)
    .setParamIdx(0)
    .setCount(5);
// 針對 int 型別的引數 PARAM_B,單獨設定限流 QPS 閾值為 10,而不是全域性的閾值 5.
ParamFlowItem item = new ParamFlowItem().setObject(String.valueOf(PARAM_B))
    .setClassType(int.class.getName())
    .setCount(10);
rule.setParamFlowItemList(Collections.singletonList(item));

ParamFlowRuleManager.loadRules(Collections.singletonList(rule));

3. 系統自適應限流

Sentinel 系統自適應限流從整體維度對應用入口流量進行控制,結合應用的 Load、總體平均 RT、入口 QPS 和執行緒數等幾個維度的監控指標,讓系統的入口流量和系統的負載達到一個平衡,讓系統儘可能跑在最大吞吐量的同時保證系統整體的穩定性。

在開始之前,先回顧一下 Sentinel 做系統自適應限流的目的:

  • 保證系統不被拖垮

  • 在系統穩定的前提下,保持系統的吞吐量

3.1 背景

長期以來,系統自適應保護的思路是根據硬指標,即系統的負載 (load1) 來做系統過載保護。當系統負載高於某個閾值,就禁止或者減少流量的進入;當 load 開始好轉,則恢復流量的進入。這個思路給我們帶來了不可避免的兩個問題:

  • load 是一個“果”,如果根據 load 的情況來調節流量的通過率,那麼就始終有延遲性。也就意味著通過率的任何調整,都會過一段時間才能看到效果。當前通過率是使 load 惡化的一個動作,那麼也至少要過 1 秒之後才能觀測到;同理,如果當前通過率調整是讓 load 好轉的一個動作,也需要 1 秒之後才能繼續調整,這樣就浪費了系統的處理能力。所以我們看到的曲線,總是會有抖動。

  • 恢復慢。想象一下這樣的一個場景(真實),出現了這樣一個問題,下游應用不可靠,導致應用 RT 很高,從而 load 到了一個很高的點。過了一段時間之後下游應用恢復了,應用 RT 也相應減少。這個時候,其實應該大幅度增大流量的通過率;但是由於這個時候 load 仍然很高,通過率的恢復仍然不高。

TCP BBR 的思想給了我們一個很大的啟發。我們應該根據系統能夠處理的請求,和允許進來的請求,來做平衡,而不是根據一個間接的指標(系統 load)來做限流。最終我們追求的目標是 在系統不被拖垮的情況下,提高系統的吞吐率,而不是 load 一定要到低於某個閾值。如果我們還是按照固有的思維,超過特定的 load 就禁止流量進入,系統 load 恢復就放開流量,這樣做的結果是無論我們怎麼調引數,調比例,都是按照果來調節因,都無法取得良好的效果。

Sentinel 在系統自適應保護的做法是,用 load1 作為啟動控制流量的值,而允許通過的流量由處理請求的能力,即請求的響應時間以及當前系統正在處理的請求速率來決定。

3.2 系統規則

系統保護規則是從應用級別的入口流量進行控制,從單臺機器的總體 Load、RT、入口 QPS 和執行緒數四個維度監控應用資料,讓系統儘可能跑在最大吞吐量的同時保證系統整體的穩定性。

系統保護規則是應用整體維度的,而不是資源維度的,並且僅對入口流量生效。入口流量指的是進入應用的流量(EntryType.IN),比如 Web 服務或 Dubbo 服務端接收的請求,都屬於入口流量。

系統規則支援四種閾值型別:

  • Load(僅對 Linux/Unix-like 機器生效):當系統 load1 超過閾值,且系統當前的併發執行緒數超過系統容量時才會觸發系統保護。系統容量由系統的 maxQps * minRt 計算得出。設定參考值一般是 CPU cores * 2.5。

  • RT:當單臺機器上所有入口流量的平均 RT 達到閾值即觸發系統保護,單位是毫秒。

  • 執行緒數:當單臺機器上所有入口流量的併發執行緒數達到閾值即觸發系統保護。

  • 入口 QPS:當單臺機器上所有入口流量的 QPS 達到閾值即觸發系統保護。

3.3 原理

先用經典圖來鎮樓:

Spring Cloud Alibaba | Sentinel: 服務限流高階篇

我們把系統處理請求的過程想象為一個水管,到來的請求是往這個水管灌水,當系統處理順暢的時候,請求不需要排隊,直接從水管中穿過,這個請求的RT是最短的;反之,當請求堆積的時候,那麼處理請求的時間則會變為:排隊時間 + 最短處理時間。

  • 推論一: 如果我們能夠保證水管裡的水量,能夠讓水順暢的流動,則不會增加排隊的請求;也就是說,這個時候的系統負載不會進一步惡化。

我們用 T 來表示(水管內部的水量),用RT來表示請求的處理時間,用P來表示進來的請求數,那麼一個請求從進入水管道到從水管出來,這個水管會存在 P * RT 個請求。換一句話來說,當 T ≈ QPS * Avg(RT) 的時候,我們可以認為系統的處理能力和允許進入的請求個數達到了平衡,系統的負載不會進一步惡化。

接下來的問題是,水管的水位是可以達到了一個平衡點,但是這個平衡點只能保證水管的水位不再繼續增高,但是還面臨一個問題,就是在達到平衡點之前,這個水管裡已經堆積了多少水。如果之前水管的水已經在一個量級了,那麼這個時候系統允許通過的水量可能只能緩慢通過,RT會大,之前堆積在水管裡的水會滯留;反之,如果之前的水管水位偏低,那麼又會浪費了系統的處理能力。

  • 推論二: 當保持入口的流量是水管出來的流量的最大的值的時候,可以最大利用水管的處理能力。

然而,和 TCP BBR 的不一樣的地方在於,還需要用一個系統負載的值(load1)來激發這套機制啟動。

注:這種系統自適應演算法對於低 load 的請求,它的效果是一個“兜底”的角色。對於不是應用本身造成的 load 高的情況(如其它程式導致的不穩定的情況),效果不明顯。

3.4 示例

public class SystemGuardDemo {

    private static AtomicInteger pass = new AtomicInteger();
    private static AtomicInteger block = new AtomicInteger();
    private static AtomicInteger total = new AtomicInteger();

    private static volatile boolean stop = false;
    private static final int threadCount = 100;

    private static int seconds = 60 + 40;

    public static void main(String[] args) throws Exception {

        tick();
        initSystemRule();

        for (int i = 0; i < threadCount; i++) {
            Thread entryThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    while (true) {
                        Entry entry = null;
                        try {
                            entry = SphU.entry("methodA", EntryType.IN);
                            pass.incrementAndGet();
                            try {
                                TimeUnit.MILLISECONDS.sleep(20);
                            } catch (InterruptedException e) {
                                // ignore
                            }
                        } catch (BlockException e1) {
                            block.incrementAndGet();
                            try {
                                TimeUnit.MILLISECONDS.sleep(20);
                            } catch (InterruptedException e) {
                                // ignore
                            }
                        } catch (Exception e2) {
                            // biz exception
                        } finally {
                            total.incrementAndGet();
                            if (entry != null) {
                                entry.exit();
                            }
                        }
                    }
                }

            });
            entryThread.setName("working-thread");
            entryThread.start();
        }
    }

    private static void initSystemRule() {
        List<SystemRule> rules = new ArrayList<SystemRule>();
        SystemRule rule = new SystemRule();
        // max load is 3
        rule.setHighestSystemLoad(3.0);
        // max cpu usage is 60%
        rule.setHighestCpuUsage(0.6);
        // max avg rt of all request is 10 ms
        rule.setAvgRt(10);
        // max total qps is 20
        rule.setQps(20);
        // max parallel working thread is 10
        rule.setMaxThread(10);

        rules.add(rule);
        SystemRuleManager.loadRules(Collections.singletonList(rule));
    }

    private static void tick() {
        Thread timer = new Thread(new TimerTask());
        timer.setName("sentinel-timer-task");
        timer.start();
    }

    static class TimerTask implements Runnable {
        @Override
        public void run() {
            System.out.println("begin to statistic!!!");
            long oldTotal = 0;
            long oldPass = 0;
            long oldBlock = 0;
            while (!stop) {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                }
                long globalTotal = total.get();
                long oneSecondTotal = globalTotal - oldTotal;
                oldTotal = globalTotal;

                long globalPass = pass.get();
                long oneSecondPass = globalPass - oldPass;
                oldPass = globalPass;

                long globalBlock = block.get();
                long oneSecondBlock = globalBlock - oldBlock;
                oldBlock = globalBlock;

                System.out.println(seconds + ", " + TimeUtil.currentTimeMillis() + ", total:"
                    + oneSecondTotal + ", pass:"
                    + oneSecondPass + ", block:" + oneSecondBlock);
                if (seconds-- <= 0) {
                    stop = true;
                }
            }
            System.exit(0);
        }
    }
}

4. 黑白名單控制

很多時候,我們需要根據呼叫方來限制資源是否通過,這時候可以使用 Sentinel 的黑白名單控制的功能。黑白名單根據資源的請求來源(origin)限制資源是否通過,若配置白名單則只有請求來源位於白名單內時才可通過;若配置黑名單則請求來源位於黑名單時不通過,其餘的請求通過。

呼叫方資訊通過 ContextUtil.enter(resourceName, origin) 方法中的 origin 引數傳入。

4.1 規則配置

黑白名單規則(AuthorityRule)非常簡單,主要有以下配置項:

  • resource:資源名,即限流規則的作用物件

  • limitApp:對應的黑名單/白名單,不同 origin 用 , 分隔,如 appA,appB

  • strategy:限制模式,AUTHORITY_WHITE 為白名單模式,AUTHORITY_BLACK 為黑名單模式,預設為白名單模式

4.2 示例

比如我們希望控制對資源 test 的訪問設定白名單,只有來源為 appA 和 appB 的請求才可通過,則可以配置如下白名單規則:

AuthorityRule rule = new AuthorityRule();
rule.setResource("test");
rule.setStrategy(RuleConstant.AUTHORITY_WHITE);
rule.setLimitApp("appA,appB");
AuthorityRuleManager.loadRules(Collections.singletonList(rule));

參考:

https://github.com/alibaba/Sentinel

相關文章