Sentinel基本使用與原始碼分析

Cuzzz發表於2023-05-18

系列文章目錄和關於我

一丶什麼是Sentinel

Sentinel官網

Sentinel 是面向分散式、多語言異構化服務架構的流量治理元件,主要以流量為切入點,從流量路由流量控制流量整形熔斷降級系統自適應過載保護熱點流量防護等多個維度來幫助開發者保障微服務的穩定性。

流量整形:限制流出某一網路的某一連線的流量與突發,使這類報文以比較均勻的速度向外傳送。流量整形通常使用緩衝區和令牌桶來完成,當報文的傳送速度過快時,首先在緩衝區進行快取,在令牌桶的控制下再均勻地傳送這些被緩衝的報文

二丶主要功能

1.流量控制

任意時間到來的請求往往是隨機不可控的,而系統的處理能力是有限的。我們需要根據系統的處理能力對流量進行控制

image-20230517163817107

流量控制有以下幾個角度:

  • 資源的呼叫關係,例如資源的呼叫鏈路,資源和資源之間的關係;
  • 執行指標,例如 QPS、執行緒池、系統負載等;
  • 控制的效果,例如直接限流、冷啟動、排隊等。

2.熔斷降級

當呼叫鏈路中某個資源出現不穩定,例如,表現為 timeout,異常比例升高的時候,則對這個資源的呼叫進行限制,並讓請求快速失敗,避免影響到其它的資源,最終產生雪崩的效果。

為何發生雪崩:如下圖中服務D奄奄一息,其他服務對它具備依賴,對服務D發起呼叫的時候常常超時,RT很大,導致服務G執行緒都block在呼叫服務D的這一步中,導致服務G也奄奄一息,久而久之其他服務都處於不可服務的狀態。

image-20230517164902309

3.系統負載保護

當系統負載較高的時候,如果還持續讓請求進入,可能會導致系統崩潰,無法響應。Sentinel 提供了對應的保護機制,讓系統的入口流量和系統的負載達到一個平衡,保證系統在能力範圍之內處理最多的請求。

三丶基本使用

1.sentinel依賴引入

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-core</artifactId>
    <version>版本號</version>
</dependency>

2.初始化限流規則

 private static void initFlowQpsRule() {
        //限流規則列表
        List<FlowRule> rules = new ArrayList<FlowRule>();
        //第一個規則
        FlowRule rule1 = new FlowRule();
        //rule1針對什麼資源(資源:指你要對什麼限流,一般是方法名稱)
        rule1.setResource(KEY);
        // 設定限流閾值為20
        rule1.setCount(20);
        //限流策略:
        // 0:執行緒數(那麼就是20個執行緒併發訪問的時候限流)
        //1:qps:每秒訪問資源的數量,超過20進行限流
        rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
        //將受來源限制的應用程式名稱
        rule1.setLimitApp("default");
        rules.add(rule1);
        FlowRuleManager.loadRules(rules);
    }

3.限流使用

Entry entry = null;
try {
    entry= SphU.entry("資源名稱");
    //執行正常業務邏輯
} catch (BlockException e) {
    //執行被限流後的業務邏輯
} finally {
    if (entry != null) {
        entry.close();
    }
}

4.控制檯設定限流規則

img

5.控制檯設定降級規則

image-20230518213825490

四丶基本原理

1.基本概念

  • Entry

    在Sentinel中,所有的資源都對應一個資源名稱以及一個 Entry。

    Entry負責記錄當前呼叫的資訊:

    • 建立時間:用於統計rt
    • 當前節點Node:當前上下文中資源的統計資訊(Node是Sentinel中的一個介面,負責記錄實時統計資訊)。
    • 來源Node:呼叫來源方資訊。
  • Slot

    每一個 Entry 建立的時候,同時也會建立一系列功能插槽(slot chain)。這些插槽有不同的職責。

2.原理圖

image-20230517182315451

五丶原始碼學習

Sentinel不僅支援單機流控,還支援叢集流控制,個人認為在流量分配均勻的情況下,單機流控完全夠用了,並且叢集流控需要額外的資源來進行叢集伺服器資訊同步,感覺用處不是很大。

0.Sentinel SPI

一個框架需要考慮到擴充套件性,實現擴充套件性的一個很好的方式就是SPI(Service Provider Interface)SPI可與實現將裝配的控制權移到程式之外,實現使用方和提供方的解耦

Sentinel提供了SpiLoader方便進行SPI服務實現的載入,Sentinel中很多核心元件都依賴此類進行載入。常見使用如下

SpiLoader.of(xxx.class).loadInstanceListSorted()

0.1 of 方法建立SpiLoader例項

image-20230517190553089

這種double check + synchronized實現執行緒安全的方式在Sentinel中非常常見。

0.2 SpiLoader物件載入服務提供者

SpiLoader會使用ClassLoader讀取META-INF/services/服務提供者class全限定類名檔案中的資料,並解析@Spi註解中的內容,決定是否單例,是否預設實現,以及順序等內容,載入服務實現並返回結果。

1.FlowRuleManager 管理FlowRule

  • FlowRule: 表示對資源採取何種流控手段。
  • FlowRuleManager:提供FlowRule的儲存,查詢等功能。

FlowRuleManager內部使用map來儲存管理資源和對應的流控規則(一個資源可存在多個流控規則)。

另外還提供PropertyListener來實現FlowRule變化後的回撥。

image-20230517192654718

2.SphU.entry 進行流控

2.1 Env 呼叫InitFunc#init

這裡會使用Env中的static final單例CtSph物件進行流控規則,並且會進行InitFunc#init的呼叫

image-20230517193009060

2.2 包裝資源為StringResourceWrapper

image-20230517193413242

ResourceWrapper是對資源的包裝,存在兩個實現,MethodResourceWrapper在呼叫SphU#entry(Method method)的時候使用到。

上面原理圖中提到,Sentinel具備slot鏈條,在獲取鏈條的時候,會根據ResourceWrapper從快取map中獲取,hash的規則是ResourceWrapper的名稱,MethodResourceWrapper的名稱是:方法定義類全限定名稱:方法名稱(引數型別全限定類名)

2.3 entryWithPriority 進行流控

2.3.1 獲取Context

Context儲存當前呼叫後設資料,首先會透過ContextUtil從ThreadLocal中獲取,如果不存在會new一個,並且設定到ThreadLocal中,隨著Entry#close方法的時候會進行資源釋放。

構建Context的時候,會設定其中的Node(名稱為sentinel_default_context的EntranceNode),

image-20230517195524416

Node在Sentinel中用來生成樹狀結構,描述方法的呼叫

image-20230517195831116

2.3.2 lookProcessChain構建Slot執行鏈條

slot鏈條式sentinel實現流量統計,和限流的核心,獲取slot鏈條ProcessorSlot的程式碼如下

  • 從快取map中獲取

image-20230517200200497

  • SPI獲取SlotChainBuilder

    image-20230517200612846

    這裡是一個擴充套件點,我們可與實現基於配置中心的chainBuilder,實現slot鏈條配置化

  • DefaultSlotChainBuilder 構造ProcessorSlotChain

image-20230517201003079

image-20230517201109133

ProcessorSlotChain也是一個ProcessorSlot處理器插槽,內部使用net屬性串聯下一個AbstractLinkedProcessorSlot。

我們可擴充套件自己的ProcessorSlot,使用SPI機制輕鬆加入到ProcessorSlotChain中。

image-20230517201434556

2.3.2 依次執行ProcessorSlot#entry

1.NodeSelectorSlot

image-20230517202652646

NodeSelectorSlot 負責收集資源的路徑,並將這些資源的呼叫路徑,以樹狀結構儲存起來,用於根據呼叫路徑來限流降級

2.ClusterBuilderSlot

ClusterBuilderSlot會生成用於儲存資源的統計資訊以及呼叫者資訊的ClusterNode,ClusterNode會負責儲存該資源的 RT, QPS, thread count 等等,這些資訊將用作為多維度限流,降級的依據。

image-20230517203052800

3.StatisticSlot

用於記錄、統計不同緯度的 runtime 指標監控資訊

image-20230517203428014

統計請求透過數量使用了StatisticNode#addPassRequest方法

image-20230518183033650

最終都是使用ArrayMetric進行記錄, ArrayMetric 內部使用OccupiableBucketLeapArray或者BucketLeapArray進行計數,具體如何記錄在後續章節中分析。

4.AuthoritySlot

基於白名單黑名單邏輯的許可權校驗,預設情況是沒有啟用的。

image-20230517203649538

5.SystemSlot

透過系統的狀態,例如 load,cpu使用率等,來控制總的入口流量。qps使用的是滑動視窗演算法進行統計,load,cpu使用率這些指標透過com.sun.management.OperatingSystemMXBean獲取。

6.FlowSlot

根據預設的限流規則以及前面 slot 統計的狀態,來進行流量控制。

image-20230517204446659

可看到,每一個配置的FlowRule,都會呼叫canPassCheck檢查,只要存在任何一個不滿足要求,都會丟擲FlowException

image-20230517204607751

image-20230517204832875

這裡會根據FlowRule#setControlBehavior的不同選擇不同的TrafficShapingController進行校驗,也可以透過FlowRule#setRater直接指定的實現。

image-20230517205053293

  • DefaultController :預設流量整形控制器,超過任何規則的閾值後,新的請求就會立即拒絕,拒絕方式為丟擲FlowException

    image-20230517205809127

    這裡程式碼邏輯較為簡單,獲取當前qps或者執行緒數,如果acquireCount加上當前qps大於count(閾值)那麼返回false。

    另外可以看到Sentinel提供了預佔能力。

    實現的難點在於怎麼統計qps,統計執行緒數,這部分在後續章節中進行學習。

  • ThrottlingController :均速排隊,嚴格控制請求透過的時間間隔,也即是讓請求以均勻的速度透過,對應的是漏桶演算法。下面是判斷是否透過的程式碼:

    private boolean checkPassUsingCachedMs(int acquireCount, double maxCountPerStat) {
        //當前時間
        long currentTime = TimeUtil.currentTimeMillis();
        
        //statDurationMs = 1000ms 即1s(假設qps限制為20,maxCountPerStat=20)
        //1000ms * 1(請求數量)/ maxCountPerStat = 產生1個令牌所需要耗費的時間
        long costTime = Math.round(1.0d * statDurationMs * acquireCount / maxCountPerStat);
    
        // costTime + 上一個請求透過的時間 = 什麼時間,此令牌可以產生
        long expectedTime = costTime + latestPassedTime.get();
    	
        //如果當前時間小於 滿足令牌要求的時間(expectedTime)
        if (expectedTime <= currentTime) {
            // Contention may exist here, but it's okay.
            //設定最後透過請求的時間(latestPassedTime是AtomicLong)
            latestPassedTime.set(currentTime);
            return true;
        } else {
            // 計算等待的時間
            long waitTime = costTime + latestPassedTime.get() - TimeUtil.currentTimeMillis();
            //等待的時間超過了排隊等待的閾值(預設情況下500ms)那麼返回false
            if (waitTime > maxQueueingTimeMs) {
                return false;
            }
    		
            //上一次請求透過的時間,加上產生令牌需要的時間(addAndGet是自旋+cas操作)
            long oldTime = latestPassedTime.addAndGet(costTime);
            //等待時間
            waitTime = oldTime - TimeUtil.currentTimeMillis();
            //如果超過了等待閾值 那麼cas減小時間,相當於回滾操作
            if (waitTime > maxQueueingTimeMs) {
                latestPassedTime.addAndGet(-costTime);
                return false;
            }
            // sleep當前執行緒進行等待
            if (waitTime > 0) {
                sleepMs(waitTime);
            }
            return true;
        }
    }
    

    其實這個程式碼也不是天衣無縫:image-20230518144815024

    這段程式碼也有很妙的地方:

    image-20230518145440130

  • WarmUpRateLimiterController/WarmUpController: 預熱/冷啟動方式。當系統長期處理低水平的情況下,當流量突然增加時,直接把系統拉昇到高水位可能瞬間把系統壓垮。透過"冷啟動",讓透過的流量緩慢增加,在一定時間內逐漸增加到閾值的上限,給系統一個預熱的時間,避免冷系統被壓垮。

    這部分原始碼設計到guava的預熱演算法,後續瞭解學習

7.DegradeSlot&DefaultCircuitBreakerSlot

二者都是熔斷器插槽,並且短路原理一致,DegradeSlot在使用者個資源指定DegradeRule(降級規則)的時候會根據DegradeRule構造出斷路器CircuitBreaker,而DefaultCircuitBreakerSlot則是使用者配置同一的短路規則,並沒有給資源指定特定規則的時候,會使用預設規則生成CircuitBreaker。

DegradeRule分為三種:

  • RuleConstant.DEGRADE_GRADE_RT

根據rt來進行熔斷,對應ResponseTimeCircuitBreaker(響應時間斷路器)

  • RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO

根據異常比列進行熔斷,對應ExceptionCircuitBreaker

  • RuleConstant.DEGRADE_GRADE_EXCEPTION_COUNT

根據錯誤進行熔斷,對應ExceptionCircuitBreaker

image-20230518165434399

統計rt,錯誤次數,錯誤率都是基於LeapArray(滑動視窗演算法)進行統計。

  • 在DegradeSlot&DefaultCircuitBreakerSlot的entry方法(限流操作會呼叫到此方法)此方法會輪流呼叫CircuitBreaker#tryPass進行短路校驗。

    這裡都會使用AbstractCircuitBreaker#tryPass

    CircuitBreaker具備三種狀態:

    • Open,斷路器開啟,此時會判斷是否大於介面恢復時間,如果大於那麼修改為半開,讓一個請求先試試水,如果成功那麼從半開修改為開。
    • Close:斷路器關閉了,此時所有請求都可以透過。並且根據指標(rt,錯誤率,錯誤次數)來決定是否將斷路器修改為開
    • Half open,半開狀態,此時除了試水的執行緒可以透過,其他執行緒都會丟擲DegradeException,試水執行緒會根據是否呼叫異常,來決定修改為開還是關

    image-20230518180547673

    可以看出斷路器最重要的是維護這三種狀態,並且狀態的切換需要保證執行緒安全

    image-20230518165739128

    如果斷路器處於Open,首先會透過retryTimeoutArrived方法判斷當前時間是否大於恢復時間點,如果不是,說明短路了返回false,DegradeSlot會丟擲DegradeException,阻止呼叫。如果大於恢復時間點,會呼叫fromOpenToHalfOpen 嘗試cas open->half_open,並且註冊回撥,此回撥會在 entry.close()的時候被觸發。

    image-20230518170809931

  • 在DegradeSlot&DefaultCircuitBreakerSlot的exit方法中,會觸發circuitBreaker#onRequestComplete(如果執行順利沒有出現BlockException異常的話)

    這裡會根據DegradeRule呼叫不同的CircuitBreaker

    • ExceptionCircuitBreaker

    • 從Entry當中拿到執行的錯誤,如果具備錯誤,那麼更新滑動視窗中的錯誤數

      private void handleStateChangeWhenThresholdExceeded(Throwable error) {
          //當前是開,那麼什麼也不做,降級slot在開的狀態會判斷當前時間和恢復時間,實現降級效果,so,這裡不需要做什麼
          if (currentState.get() == State.OPEN) {
              return;
          }
          	
          //半開
          if (currentState.get() == State.HALF_OPEN) {
              // 半開但是執行過程中無異常,那麼調整為close,說明被呼叫方法介面已經穩定了,可以修改為close
              if (error == null) {
                  fromHalfOpenToClose();
              } else {
                  //如果具備異常,那麼修改為開,並且更新介面恢復時間,實現熔斷
                  fromHalfOpenToOpen(1.0d);
              }
              return;
          }
          
          //開狀態,滑動視窗統計錯誤數,和總數
          List<SimpleErrorCounter> counters = stat.values();
          long errCount = 0;
          long totalCount = 0;
          //統計錯誤數,和總數
          for (SimpleErrorCounter counter : counters) {
              errCount += counter.errorCount.sum();
              totalCount += counter.totalCount.sum();
          }
          //小於 最小請求數(預設5)認為樣本太少,直接啥也不做
          if (totalCount < minRequestAmount) {
              return;
          }
          //預設根據錯誤次數,curCount記錄錯誤次數
          double curCount = errCount;
          //根據錯誤比例,curCount記錄錯誤率
          if (strategy == DEGRADE_GRADE_EXCEPTION_RATIO) {
              // Use errorRatio
              curCount = errCount * 1.0d / totalCount;
          }
          //錯誤比例 or 錯誤率超過了閾值,那麼調整為開,並且更新介面恢復時間,實現熔斷
          if (curCount > threshold) {
              transformToOpen(curCount);
          }
      }
      
    • ResponseTimeCircuitBreaker

    和上面類似,只不過是根據rt來判斷,而不是錯誤次數,錯誤率。同使用滑動視窗實現計數。

2.4 LeapArry是如何實現滑動視窗計數的

ArrayMetirc被StatisticSlot呼叫addPass方法

image-20230518184215738

這裡的data便是LeapArray的子類BucketLeapArray,或者OccupiableBucketLeapArray

image-20230518183836895

2.4.1構造方法

從構造方法我們可以看出LeapArray的構成

image-20230518202715241

可以看到資料的儲存使用了AtomicReferenceArray,其中sampleCount = 2,intervalInMs = 1000,也就說預設只有兩個視窗,時間跨度為1s。

image-20230518203404659

2.4.2 獲取當前時間對應的桶

這一段就是滑動視窗的精髓

public WindowWrap<T> currentWindow(long timeMillis) {
    if (timeMillis < 0) {
        return null;
    }
    //根據當前時間,計算當前時間位於視窗陣列中的下標
    // 比如當前是 1600ms,視窗總大小為1000ms,一共兩個格子,每一個格子大小為500ms
    //1600ms 落在(1600/500)% 2 = 1
    int idx = calculateTimeIdx(timeMillis);
    // 計算當前時間 對應的起始 => 1600 - 1600%500 = 1500
    // |0~500|500~1000|1000~1500|1500~2000| 1600位於1500~2000中所以起始為1500
    long windowStart = calculateWindowStart(timeMillis);

    while (true) {
        //獲取當前時間對應的桶
        WindowWrap<T> old = array.get(idx);
        //如果桶為null
        if (old == null) {
            //建立新元素
            WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
            //cas 設定到陣列中
            if (array.compareAndSet(idx, null, window)) {
                return window;
            } else {
                //如果cas失敗,說明在另外一個執行緒也是這個index,讓當前執行緒yield放棄cpu
                Thread.yield();
            }
        } else if (windowStart == old.windowStart()) {
            //舊桶的start和當前桶一樣:意味著是同一秒
            //比如600ms也是位於下標1,但是start 是500,就和當前1600ms的start 1500不同 意味著不是同一秒
            return old;
        } else if (windowStart > old.windowStart()) {
           //比如600ms也是位於下標1,但是start 是500,
            // 就和當前1600ms的start 1500,1500>500,意味著當前這個桶已經過期了
            //上鎖
            if (updateLock.tryLock()) {
                try {
                    //設定windowStart 並且清空舊桶
                    return resetWindowTo(old, windowStart);
                } finally {
                    updateLock.unlock();
                }
            } else {
                //上鎖失敗,那麼
                Thread.yield();
            }
        } else if (windowStart < old.windowStart()) {
            //當前start小於舊桶start
            //這種情況不應該發生,因為時間時越來越來大的
            //除非當前執行緒 一值分配不到時間片?
            return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
        }
    }
}

這裡有一個變數windowStart,就如同一個版本,標誌了元素是否過期(舊元素windowStart小於當前windowStart說明舊元素過期),是否位於一個桶(windowStart相同說明位於同一個下標,比如1600,和1700,windowStart都是1500)

2.4.3 改變桶記錄的值

上面看了是如何拿到當前時間對應下標的桶的,那麼桶中記錄的資料是如何變更的?

image-20230518210130119

image-20230518210322766

可以看到這裡使用了LongAdder進行計數,因為一個桶可能存在多個執行緒併發更新,使用LongAdder實現熱點資料分離,也減少快取偽共享帶來的開銷。(JUC原始碼學習筆記4——原子類原始碼分析,CAS,Volatile記憶體屏障,快取偽共享與UnSafe相關方法

2.4.4 如何獲取當前qps

上面我們直到了,透過的請求,根據時間對應了ArrayMetric滑動視窗陣列中的一個元素,這個元素是MetricBucket型別,內部有一個LongAdder的陣列,來記錄pass,block(透過的請求,被攔截的請求)的數目,那麼怎麼根據這些資料獲取當前qps暱?FlowSlot根據qps限流得使用這個資料呀!

image-20230518211106909

我們看一下這裡的pass方法

image-20230518211454247

再看下values方法,入參就是當前時間,這個當前時間timeMills用來排除過期元素

image-20230518211317826

拿透過次數自然是使用了LongAdder#sum方法,但是LongAdder#sum不具備瞬時一致性,也就說可能遍歷到n位置,但是其他執行緒更改n-1位置,n-1的變化不會體現在sum的總和中。

這裡有一段程式碼我覺得有點秒(個人理解,一點拙見不一定對)

image-20230518211930908

就是這裡統計pass會先執行data.currentWindow(),把當前時間對應的滑動視窗元素進行初始化。

比如執行data.currentWindow()當前時間點是1001ms,對應桶為null,那麼這一步會初始化1001ms對應的桶元素。

後續values遍歷的時間是1003ms,這時候就可以拿到1001ms初始化桶的引用返回,後續加入存在另外一個執行緒在1004ms進行更新,當前執行緒執行pass累加的時候就又更大機率統計到

為什麼我說更大機率,因為如果不初始化的化,1004ms另外一個執行緒去初始化會new一個桶元素,這個過程是更耗時的,當前執行緒values遍歷可能就沒辦法拿到1004ms這個執行緒初始化的桶了(有道理,但是不多doge)。

六丶總結

  • 學到了啥:

    • 學習到了SPI和責任鏈,這兩大解耦+擴充套件利器。學習到了自旋+CAS的操作。
      • SPI由一方制定規範,比如SpringBoot的spring.factories,另一方進行擴充套件,實現服務使用方和提供方的解耦合,以及修改SPI檔案內容進行擴充套件!
      • 責任鏈簡直無處不在,在眾多框架中,平時工作也常常使用到,使用責任鏈,責任單一,並且透過改變鏈條實現擴充套件!
    • 對熔斷有了更深刻的理解,特別Open狀態根據恢復時間判斷+cas修改為半開,讓一個執行緒試試水的操作,我大受震撼!
    • 瞭解到Sentinel還有除了針對單個資源進行限流,還有系統負載限流的功能,這個有點厲害,我一開始還想這個該咋實現,看了SystemSlot,發現自己還是太狹隘了。
  • 不足:

    • 對Sentinel的Node理解不夠,文件上說Sentine支援呼叫鏈路關係進行限流,這個功能挺牛的,但是我並沒有詳細閱讀這部分原始碼。

    • 對預熱的實現沒有深入理解,主要是Guava的預熱模型,讓二陽的我有點暈了,後續結合guava中的限流進行學習。

相關文章