一丶什麼是Sentinel
Sentinel 是面向分散式、多語言異構化服務架構的流量治理元件,主要以流量為切入點
,從流量路由
、流量控制
、流量整形
、熔斷降級
、系統自適應過載保護
、熱點流量防護
等多個維度來幫助開發者保障微服務的穩定性。
流量整形:限制流出某一網路的某一連線的流量與突發,使這類報文以比較均勻的速度向外傳送。流量整形通常使用緩衝區和令牌桶來完成,當報文的傳送速度過快時,首先在緩衝區進行快取,在令牌桶的控制下再均勻地傳送這些被緩衝的報文
二丶主要功能
1.流量控制
任意時間到來的請求往往是隨機不可控的,而系統的處理能力是有限的。我們需要根據系統的處理能力對流量進行控制
流量控制有以下幾個角度:
- 資源的呼叫關係,例如資源的呼叫鏈路,資源和資源之間的關係;
- 執行指標,例如 QPS、執行緒池、系統負載等;
- 控制的效果,例如直接限流、冷啟動、排隊等。
2.熔斷降級
當呼叫鏈路中某個資源出現不穩定,例如,表現為 timeout,異常比例升高的時候,則對這個資源的呼叫進行限制,並讓請求快速失敗,避免影響到其它的資源,最終產生雪崩的效果。
為何發生雪崩:如下圖中服務D奄奄一息,其他服務對它具備依賴,對服務D發起呼叫的時候常常超時,RT很大,導致服務G執行緒都block在呼叫服務D的這一步中,導致服務G也奄奄一息,久而久之其他服務都處於不可服務的狀態。
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.控制檯設定限流規則
5.控制檯設定降級規則
四丶基本原理
1.基本概念
-
Entry
在Sentinel中,所有的資源都對應一個資源名稱以及一個 Entry。
Entry負責記錄當前呼叫的資訊:
- 建立時間:用於統計rt
- 當前節點Node:當前上下文中資源的統計資訊(Node是Sentinel中的一個介面,負責記錄實時統計資訊)。
- 來源Node:呼叫來源方資訊。
-
Slot
每一個 Entry 建立的時候,同時也會建立一系列功能插槽(slot chain)。這些插槽有不同的職責。
2.原理圖
五丶原始碼學習
Sentinel不僅支援單機流控,還支援叢集流控制,個人認為在流量分配均勻的情況下,單機流控完全夠用了,並且叢集流控需要額外的資源來進行叢集伺服器資訊同步,感覺用處不是很大。
0.Sentinel SPI
一個框架需要考慮到擴充套件性,實現擴充套件性的一個很好的方式就是SPI(Service Provider Interface)SPI可與實現將裝配的控制權移到程式之外,實現使用方和提供方的解耦。
Sentinel提供了SpiLoader方便進行SPI服務實現的載入,Sentinel中很多核心元件都依賴此類進行載入。常見使用如下
SpiLoader.of(xxx.class).loadInstanceListSorted()
0.1 of 方法建立SpiLoader例項
這種double check + synchronized實現執行緒安全的方式在Sentinel中非常常見。
0.2 SpiLoader物件載入服務提供者
SpiLoader會使用ClassLoader讀取META-INF/services/服務提供者class全限定類名
檔案中的資料,並解析@Spi註解中的內容,決定是否單例,是否預設實現,以及順序等內容,載入服務實現並返回結果。
1.FlowRuleManager 管理FlowRule
- FlowRule: 表示對資源採取何種流控手段。
- FlowRuleManager:提供FlowRule的儲存,查詢等功能。
FlowRuleManager內部使用map來儲存管理資源和對應的流控規則(一個資源可存在多個流控規則)。
另外還提供PropertyListener來實現FlowRule變化後的回撥。
2.SphU.entry 進行流控
2.1 Env 呼叫InitFunc#init
這裡會使用Env中的static final單例CtSph物件進行流控規則,並且會進行InitFunc#init的呼叫
2.2 包裝資源為StringResourceWrapper
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),
Node在Sentinel中用來生成樹狀結構,描述方法的呼叫
2.3.2 lookProcessChain構建Slot執行鏈條
slot鏈條式sentinel實現流量統計,和限流的核心,獲取slot鏈條ProcessorSlot的程式碼如下
- 從快取map中獲取
-
SPI獲取SlotChainBuilder
這裡是一個擴充套件點,我們可與實現基於配置中心的chainBuilder,實現slot鏈條配置化
-
DefaultSlotChainBuilder 構造ProcessorSlotChain
ProcessorSlotChain也是一個ProcessorSlot處理器插槽,內部使用net屬性串聯下一個AbstractLinkedProcessorSlot。
我們可擴充套件自己的ProcessorSlot,使用SPI機制輕鬆加入到ProcessorSlotChain中。
2.3.2 依次執行ProcessorSlot#entry
1.NodeSelectorSlot
NodeSelectorSlot 負責收集資源的路徑,並將這些資源的呼叫路徑,以樹狀結構儲存起來,用於根據呼叫路徑來限流降級
2.ClusterBuilderSlot
ClusterBuilderSlot會生成用於儲存資源的統計資訊以及呼叫者資訊的ClusterNode,ClusterNode會負責儲存該資源的 RT, QPS, thread count 等等,這些資訊將用作為多維度限流,降級的依據。
3.StatisticSlot
用於記錄、統計不同緯度的 runtime 指標監控資訊
統計請求透過數量使用了StatisticNode#addPassRequest方法
最終都是使用ArrayMetric進行記錄, ArrayMetric 內部使用OccupiableBucketLeapArray或者BucketLeapArray進行計數,具體如何記錄在後續章節中分析。
4.AuthoritySlot
基於白名單黑名單邏輯的許可權校驗,預設情況是沒有啟用的。
5.SystemSlot
透過系統的狀態,例如 load,cpu使用率等,來控制總的入口流量。qps使用的是滑動視窗演算法進行統計,load,cpu使用率這些指標透過com.sun.management.OperatingSystemMXBean獲取。
6.FlowSlot
根據預設的限流規則以及前面 slot 統計的狀態,來進行流量控制。
可看到,每一個配置的FlowRule,都會呼叫canPassCheck檢查,只要存在任何一個不滿足要求,都會丟擲FlowException
這裡會根據FlowRule#setControlBehavior的不同選擇不同的TrafficShapingController進行校驗,也可以透過FlowRule#setRater直接指定的實現。
-
DefaultController :預設流量整形控制器,超過任何規則的閾值後,新的請求就會立即拒絕,拒絕方式為丟擲
FlowException
這裡程式碼邏輯較為簡單,獲取當前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; } }
其實這個程式碼也不是天衣無縫:
這段程式碼也有很妙的地方:
-
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
統計rt,錯誤次數,錯誤率都是基於LeapArray(滑動視窗演算法)進行統計。
-
在DegradeSlot&DefaultCircuitBreakerSlot的entry方法(限流操作會呼叫到此方法)此方法會輪流呼叫CircuitBreaker#tryPass進行短路校驗。
這裡都會使用AbstractCircuitBreaker#tryPass
CircuitBreaker具備三種狀態:
- Open,斷路器開啟,此時會判斷是否大於介面恢復時間,如果大於那麼修改為半開,讓一個請求先試試水,如果成功那麼從半開修改為開。
- Close:斷路器關閉了,此時所有請求都可以透過。並且根據指標(rt,錯誤率,錯誤次數)來決定是否將斷路器修改為開
- Half open,半開狀態,此時除了試水的執行緒可以透過,其他執行緒都會丟擲DegradeException,試水執行緒會根據是否呼叫異常,來決定修改為開還是關
可以看出斷路器最重要的是維護這三種狀態,並且狀態的切換需要保證執行緒安全
如果斷路器處於Open,首先會透過retryTimeoutArrived方法判斷當前時間是否大於恢復時間點,如果不是,說明短路了返回false,DegradeSlot會丟擲DegradeException,阻止呼叫。如果大於恢復時間點,會呼叫fromOpenToHalfOpen 嘗試cas open->half_open,並且註冊回撥,此回撥會在 entry.close()的時候被觸發。
-
在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方法
這裡的data便是LeapArray的子類BucketLeapArray,或者OccupiableBucketLeapArray
2.4.1構造方法
從構造方法我們可以看出LeapArray的構成
可以看到資料的儲存使用了AtomicReferenceArray,其中sampleCount = 2,intervalInMs = 1000,也就說預設只有兩個視窗,時間跨度為1s。
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 改變桶記錄的值
上面看了是如何拿到當前時間對應下標的桶的,那麼桶中記錄的資料是如何變更的?
可以看到這裡使用了LongAdder進行計數,因為一個桶可能存在多個執行緒併發更新,使用LongAdder實現熱點資料分離,也減少快取偽共享帶來的開銷。(JUC原始碼學習筆記4——原子類原始碼分析,CAS,Volatile記憶體屏障,快取偽共享與UnSafe相關方法)
2.4.4 如何獲取當前qps
上面我們直到了,透過的請求,根據時間對應了ArrayMetric滑動視窗陣列中的一個元素,這個元素是MetricBucket型別,內部有一個LongAdder的陣列,來記錄pass,block(透過的請求,被攔截的請求)的數目,那麼怎麼根據這些資料獲取當前qps暱?FlowSlot根據qps限流得使用這個資料呀!
我們看一下這裡的pass方法
再看下values方法,入參就是當前時間,這個當前時間timeMills用來排除過期元素
拿透過次數自然是使用了LongAdder#sum方法,但是LongAdder#sum不具備瞬時一致性,也就說可能遍歷到n位置,但是其他執行緒更改n-1位置,n-1的變化不會體現在sum的總和中。
這裡有一段程式碼我覺得有點秒(個人理解,一點拙見不一定對)
就是這裡統計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,發現自己還是太狹隘了。
- 學習到了SPI和責任鏈,這兩大解耦+擴充套件利器。學習到了自旋+CAS的操作。
-
不足:
-
對Sentinel的Node理解不夠,文件上說Sentine支援呼叫鏈路關係進行限流,這個功能挺牛的,但是我並沒有詳細閱讀這部分原始碼。
-
對預熱的實現沒有深入理解,主要是Guava的預熱模型,讓二陽的我有點暈了,後續結合guava中的限流進行學習。
-