逅弈 轉載請註明原創出處,謝謝!
系列文章
我們已經知道了sentinel實現限流降級的原理,其核心就是一堆Slot組成的呼叫鏈。
這裡大概的介紹下每種Slot的功能職責:
NodeSelectorSlot
負責收集資源的路徑,並將這些資源的呼叫路徑,以樹狀結構儲存起來,用於根據呼叫路徑來限流降級;ClusterBuilderSlot
則用於儲存資源的統計資訊以及呼叫者資訊,例如該資源的 RT, QPS, thread count 等等,這些資訊將用作為多維度限流,降級的依據;StatisticsSlot
則用於記錄,統計不同維度的 runtime 資訊;SystemSlot
則通過系統的狀態,例如 load1 等,來控制總的入口流量;AuthoritySlot
則根據黑白名單,來做黑白名單控制;FlowSlot
則用於根據預設的限流規則,以及前面 slot 統計的狀態,來進行限流;DegradeSlot
則通過統計資訊,以及預設的規則,來做熔斷降級;
每個Slot執行完業務邏輯處理後,會呼叫fireEntry()方法,該方法將會觸發下一個節點的entry方法,下一個節點又會呼叫他的fireEntry,以此類推直到最後一個Slot,由此就形成了sentinel的責任鏈。
下面我們就來詳細研究下這些Slot的原理。
NodeSelectorSlot
NodeSelectorSlot
是用來構造呼叫鏈的,具體的是將資源的呼叫路徑,封裝成一個一個的節點,再組成一個樹狀的結構來形成一個完整的呼叫鏈,NodeSelectorSlot
是所有Slot中最關鍵也是最複雜的一個Slot,這裡涉及到以下幾個核心的概念:
- Resource
資源是 Sentinel 的關鍵概念。它可以是 Java 應用程式中的任何內容,例如,由應用程式提供的服務,或由應用程式呼叫的其它服務,甚至可以是一段程式碼。
只要通過 Sentinel API 定義的程式碼,就是資源,能夠被 Sentinel 保護起來。大部分情況下,可以使用方法簽名,URL,甚至服務名稱作為資源名來標示資源。
簡單來說,資源就是 Sentinel 用來保護系統的一個媒介。原始碼中用來包裝資源的類是:com.alibaba.csp.sentinel.slotchain.ResourceWrapper
,他有兩個子類:StringResourceWrapper
和 MethodResourceWrapper
,通過名字就知道可以將一段字串或一個方法包裝為一個資源。
打個比方,我有一個服務A,請求非常多,經常會被陡增的流量沖垮,為了防止這種情況,簡單的做法,我們可以定義一個 Sentinel 的資源,通過該資源來對請求進行調整,使得允許通過的請求不會把服務A搞崩潰。
每個資源的狀態也是不同的,這取決於資源後端的服務,有的資源可能比較穩定,有的資源可能不太穩定。那麼在整個呼叫鏈中,Sentinel 需要對不穩定資源進行控制。當呼叫鏈路中某個資源出現不穩定,例如,表現為 timeout,或者異常比例升高的時候,則對這個資源的呼叫進行限制,並讓請求快速失敗,避免影響到其它的資源,最終導致雪崩的後果。
- Context
上下文是一個用來儲存呼叫鏈當前狀態的後設資料的類,每次進入一個資源時,就會建立一個上下文。**相同的資源名可能會建立多個上下文。**一個Context中包含了三個核心的物件:
1)當前呼叫鏈的根節點:EntranceNode
2)當前的入口:Entry
3)當前入口所關聯的節點:Node
上下文中只會儲存一個當前正在處理的入口Entry,另外還會儲存呼叫鏈的根節點。需要注意的是,每次進入一個新的資源時,都會建立一個新的上下文。
- Entry
每次呼叫 SphU#entry()
都會生成一個Entry入口,該入口中會儲存了以下資料:入口的建立時間,當前入口所關聯的節點,當前入口所關聯的呼叫源對應的節點。Entry是一個抽象類,他只有一個實現類,在CtSph中的一個靜態類:CtEntry
- Node
節點是用來儲存某個資源的各種實時統計資訊的,他是一個介面,通過訪問節點,就可以獲取到對應資源的實時狀態,以此為依據進行限流和降級操作。
可能看到這裡,大家還是比較懵,這麼多類到底有什麼用,接下來就讓我們更進一步,挖掘一下這些類的作用,在這之前,我先給大家展示一下他們之間的關係,如下圖所示:
這裡把幾種Node的作用先大概介紹下:
節點 | 作用 |
---|---|
StatisticNode | 執行具體的資源統計操作 |
DefaultNode | 該節點持有指定上下文中指定資源的統計資訊,當在同一個上下文中多次呼叫entry方法時,該節點可能下會建立有一系列的子節點。 另外每個DefaultNode中會關聯一個ClusterNode |
ClusterNode | 該節點中儲存了資源的總體的執行時統計資訊,包括rt,執行緒數,qps等等,相同的資源會全域性共享同一個ClusterNode,不管他屬於哪個上下文 |
EntranceNode | 該節點表示一棵呼叫鏈樹的入口節點,通過他可以獲取呼叫鏈樹中所有的子節點 |
Context的建立與銷燬
首先我們要清楚的一點就是,每次執行entry()方法,試圖衝破一個資源時,都會生成一個上下文。這個上下文中會儲存著呼叫鏈的根節點和當前的入口。
Context是通過ContextUtil建立的,具體的方法是trueEntry,程式碼如下:
protected static Context trueEnter(String name, String origin) {
// 先從ThreadLocal中獲取
Context context = contextHolder.get();
if (context == null) {
// 如果ThreadLocal中獲取不到Context
// 則根據name從map中獲取根節點,只要是相同的資源名,就能直接從map中獲取到node
Map<String, DefaultNode> localCacheNameMap = contextNameNodeMap;
DefaultNode node = localCacheNameMap.get(name);
if (node == null) {
// 省略部分程式碼
try {
LOCK.lock();
node = contextNameNodeMap.get(name);
if (node == null) {
// 省略部分程式碼
// 建立一個新的入口節點
node = new EntranceNode(new StringResourceWrapper(name, EntryType.IN), null);
Constants.ROOT.addChild(node);
// 省略部分程式碼
}
} finally {
LOCK.unlock();
}
}
// 建立一個新的Context,並設定Context的根節點,即設定EntranceNode
context = new Context(node, name);
context.setOrigin(origin);
// 將該Context儲存到ThreadLocal中去
contextHolder.set(context);
}
return context;
}
複製程式碼
上面的程式碼中我省略了部分程式碼,只保留了核心的部分。從原始碼中還是可以比較清晰的看出生成Context的過程:
- 1.先從ThreadLocal中獲取,如果能獲取到直接返回,如果獲取不到則繼續第2步
- 2.從一個static的map中根據上下文的名稱獲取,如果能獲取到則直接返回,否則繼續第3步
- 3.加鎖後進行一次double check,如果還是沒能從map中獲取到,則建立一個EntranceNode,並把該EntranceNode新增到一個全域性的ROOT節點中去,然後將該節點新增到map中去(這部分程式碼在上述程式碼中省略了)
- 4.根據EntranceNode建立一個上下文,並將該上下文儲存到ThreadLocal中去,下一個請求可以直接獲取
那儲存在ThreadLocal中的上下文什麼時候會清除呢?從程式碼中可以看到具體的清除工作在ContextUtil的exit方法中,當執行該方法時,會將儲存在ThreadLocal中的context物件清除,具體的程式碼非常簡單,這裡就不貼程式碼了。
那ContextUtil.exit方法什麼時候會被呼叫呢?有兩種情況:一是主動呼叫ContextUtil.exit的時候,二是當一個入口Entry要退出,執行該Entry的trueExit方法的時候,此時會觸發ContextUtil.exit的方法。但是有一個前提,就是當前Entry的父Entry為null時,此時說明該Entry已經是最頂層的根節點了,可以清除context。
呼叫鏈樹
當在一個上下文中多次呼叫了 SphU#entry() 方法時,就會建立一棵呼叫鏈樹。具體的程式碼在entry方法中建立CtEntry物件時:
CtEntry(ResourceWrapper resourceWrapper, ProcessorSlot<Object> chain, Context context) {
super(resourceWrapper);
this.chain = chain;
this.context = context;
// 獲取「上下文」中上一次的入口
parent = context.getCurEntry();
if (parent != null) {
// 然後將當前入口設定為上一次入口的子節點
((CtEntry)parent).child = this;
}
// 設定「上下文」的當前入口為該類本身
context.setCurEntry(this);
}
複製程式碼
這裡可能看程式碼沒有那麼直觀,可以用一些圖形來描述一下這個過程。
構造樹幹
建立context
context的建立在上面已經分析過了,初始化的時候,context中的curEntry屬性是沒有值的,如下圖所示:
建立Entry
每建立一個新的Entry物件時,都會重新設定context的curEntry,並將context原來的curEntry設定為該新Entry物件的父節點,如下圖所示:
退出Entry
某個Entry退出時,將會重新設定context的curEntry,當該Entry是最頂層的一個入口時,將會把ThreadLocal中儲存的context也清除掉,如下圖所示:
構造葉子節點
上面的過程是構造了一棵呼叫鏈的樹,但是這棵樹只有樹幹,沒有葉子,那葉子節點是在什麼時候建立的呢?DefaultNode就是葉子節點,在葉子節點中儲存著目標資源在當前狀態下的統計資訊。通過分析,我們知道了葉子節點是在NodeSelectorSlot的entry方法中建立的。具體的程式碼如下:
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, Object obj, int count, Object... args) throws Throwable {
// 根據「上下文」的名稱獲取DefaultNode
// 多執行緒環境下,每個執行緒都會建立一個context,
// 只要資源名相同,則context的名稱也相同,那麼獲取到的節點就相同
DefaultNode node = map.get(context.getName());
if (node == null) {
synchronized (this) {
node = map.get(context.getName());
if (node == null) {
// 如果當前「上下文」中沒有該節點,則建立一個DefaultNode節點
node = Env.nodeBuilder.buildTreeNode(resourceWrapper, null);
// 省略部分程式碼
}
// 將當前node作為「上下文」的最後一個節點的子節點新增進去
// 如果context的curEntry.parent.curNode為null,則新增到entranceNode中去
// 否則新增到context的curEntry.parent.curNode中去
((DefaultNode)context.getLastNode()).addChild(node);
}
}
// 將該節點設定為「上下文」中的當前節點
// 實際是將當前節點賦值給context中curEntry的curNode
// 在Context的getLastNode中會用到在此處設定的curNode
context.setCurNode(node);
fireEntry(context, resourceWrapper, node, count, args);
}
複製程式碼
上面的程式碼可以分解成下面這些步驟: 1)獲取當前上下文對應的DefaultNode,如果沒有的話會為當前的呼叫新生成一個DefaultNode節點,它的作用是對資源進行各種統計度量以便進行流控; 2)將新建立的DefaultNode節點,新增到context中,作為「entranceNode」或者「curEntry.parent.curNode」的子節點; 3)將DefaultNode節點,新增到context中,作為「curEntry」的curNode。
上面的第2步,不是每次都會執行。我們先看第3步,把當前DefaultNode設定為context的curNode,實際上是把當前節點賦值給context中curEntry的curNode,用圖形表示就是這樣:
多次建立不同的Entry,並且執行NodeSelectorSlot的entry方法後,就會變成這樣一棵呼叫鏈樹:
PS:這裡圖中的node0,node1,node2可能是相同的node,因為在同一個context中從map中獲取的node是同一個,這裡只是為了表述的更清楚所以用了不同的節點名。
儲存子節點
上面已經分析了葉子節點的構造過程,葉子節點是儲存在各個Entry的curNode屬性中的。
我們知道context中只儲存了入口節點和當前Entry,那子節點是什麼時候儲存的呢,其實子節點就是上面程式碼中的第2步中儲存的。
下面我們來分析上面的第2步的情況:
第一次呼叫NodeSelectorSlot的entry方法時,map中肯定是沒有DefaultNode的,那就會進入第2步中,建立一個node,建立完成後會把該節點加入到context的lastNode的子節點中去。我們先看一下context的getLastNode方法:
public Node getLastNode() {
// 如果curEntry不存在時,返回entranceNode
// 否則返回curEntry的lastNode,
// 需要注意的是curEntry的lastNode是獲取的parent的curNode,
// 如果每次進入的資源不同,就會每次都建立一個CtEntry,則parent為null,
// 所以curEntry.getLastNode()也為null
if (curEntry != null && curEntry.getLastNode() != null) {
return curEntry.getLastNode();
} else {
return entranceNode;
}
}
複製程式碼
程式碼中我們可以知道,lastNode的值可能是context中的entranceNode也可能是curEntry.parent.curNode,但是他們都是「DefaultNode」型別的節點,DefaultNode的所有子節點是儲存在一個HashSet中的。
第一次呼叫getLastNode方法時,context中curEntry是null,因為curEntry是在第3步中才賦值的。所以,lastNode最初的值就是context的entranceNode。那麼將node新增到entranceNode的子節點中去之後就變成了下面這樣:
緊接著再進入一次,資源名不同,會再次生成一個新的Entry,上面的圖形就變成下圖這樣:
此時再次呼叫context的getLastNode方法,因為此時curEntry的parent不再是null了,所以獲取到的lastNode是curEntry.parent.curNode,在上圖中可以很方便的看出,這個節點就是node0。那麼把當前節點node1新增到lastNode的子節點中去,上面的圖形就變成下圖這樣:
然後將當前node設定給context的curNode,上面的圖形就變成下圖這樣:
假如再建立一個Entry,然後再進入一次不同的資源名,上面的圖就變成下面這樣:
至此NodeSelectorSlot的基本功能已經大致分析清楚了。
PS:以上的分析是基於每次執行SphU.entry(name)時,資源名都是不一樣的前提下。如果資源名都一樣的話,那麼生成的node都相同,則只會再第一次把node加入到entranceNode的子節點中去,其他的時候,只會建立一個新的Entry,然後替換context中的curEntry的值。
ClusterBuilderSlot
NodeSelectorSlot的entry方法執行完之後,會呼叫fireEntry方法,此時會觸發ClusterBuilderSlot的entry方法。
ClusterBuilderSlot的entry方法比較簡單,具體程式碼如下:
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args) throws Throwable {
if (clusterNode == null) {
synchronized (lock) {
if (clusterNode == null) {
// Create the cluster node.
clusterNode = Env.nodeBuilder.buildClusterNode();
// 將clusterNode儲存到全域性的map中去
HashMap<ResourceWrapper, ClusterNode> newMap = new HashMap<ResourceWrapper, ClusterNode>(16);
newMap.putAll(clusterNodeMap);
newMap.put(node.getId(), clusterNode);
clusterNodeMap = newMap;
}
}
}
// 將clusterNode塞到DefaultNode中去
node.setClusterNode(clusterNode);
// 省略部分程式碼
fireEntry(context, resourceWrapper, node, count, args);
}
複製程式碼
NodeSelectorSlot的職責比較簡單,主要做了兩件事:
一、為每個資源建立一個clusterNode,然後把clusterNode塞到DefaultNode中去
二、將clusterNode保持到全域性的map中去,用資源作為map的key
PS:一個資源只有一個ClusterNode,但是可以有多個DefaultNode
StatistcSlot
StatisticSlot負責來統計資源的實時狀態,具體的程式碼如下:
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args) throws Throwable {
try {
// 觸發下一個Slot的entry方法
fireEntry(context, resourceWrapper, node, count, args);
// 如果能通過SlotChain中後面的Slot的entry方法,說明沒有被限流或降級
// 統計資訊
node.increaseThreadNum();
node.addPassRequest();
// 省略部分程式碼
} catch (BlockException e) {
context.getCurEntry().setError(e);
// Add block count.
node.increaseBlockedQps();
// 省略部分程式碼
throw e;
} catch (Throwable e) {
context.getCurEntry().setError(e);
// Should not happen
node.increaseExceptionQps();
// 省略部分程式碼
throw e;
}
}
@Override
public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
DefaultNode node = (DefaultNode)context.getCurNode();
if (context.getCurEntry().getError() == null) {
long rt = TimeUtil.currentTimeMillis() - context.getCurEntry().getCreateTime();
if (rt > Constants.TIME_DROP_VALVE) {
rt = Constants.TIME_DROP_VALVE;
}
node.rt(rt);
// 省略部分程式碼
node.decreaseThreadNum();
// 省略部分程式碼
}
fireExit(context, resourceWrapper, count);
}
複製程式碼
程式碼分成了兩部分,第一部分是entry方法,該方法首先會觸發後續slot的entry方法,即SystemSlot、FlowSlot、DegradeSlot等的規則,如果規則不通過,就會丟擲BlockException,則會在node中統計被block的數量。反之會在node中統計通過的請求數和執行緒數等資訊。第二部分是在exit方法中,當退出該Entry入口時,會統計rt的時間,並減少執行緒數。
這些統計的實時資料會被後續的校驗規則所使用,具體的統計方式是通過 滑動視窗
來實現的。後面我會詳細分析滑動視窗的原理。
SystemSlot
SystemSlot就是根據總的請求統計資訊,來做流控,主要是防止系統被搞垮,具體的程式碼如下:
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args)
throws Throwable {
SystemRuleManager.checkSystem(resourceWrapper);
fireEntry(context, resourceWrapper, node, count, args);
}
public static void checkSystem(ResourceWrapper resourceWrapper) throws BlockException {
// 省略部分程式碼
// total qps
double currentQps = Constants.ENTRY_NODE.successQps();
if (currentQps > qps) {
throw new SystemBlockException(resourceWrapper.getName(), "qps");
}
// total thread
int currentThread = Constants.ENTRY_NODE.curThreadNum();
if (currentThread > maxThread) {
throw new SystemBlockException(resourceWrapper.getName(), "thread");
}
double rt = Constants.ENTRY_NODE.avgRt();
if (rt > maxRt) {
throw new SystemBlockException(resourceWrapper.getName(), "rt");
}
// 完全按照RT,BBR演算法來
if (highestSystemLoadIsSet && getCurrentSystemAvgLoad() > highestSystemLoad) {
if (currentThread > 1 &&
currentThread > Constants.ENTRY_NODE.maxSuccessQps() * Constants.ENTRY_NODE.minRt() / 1000) {
throw new SystemBlockException(resourceWrapper.getName(), "load");
}
}
}
複製程式碼
其中的Constants.ENTRY_NODE是一個全域性的ClusterNode,該節點的值是在StatisticsSlot中進行統計的。
AuthoritySlot
AuthoritySlot做的事也比較簡單,主要是根據黑白名單進行過濾,只要有一條規則校驗不通過,就丟擲異常。
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args) throws Throwable {
AuthorityRuleManager.checkAuthority(resourceWrapper, context, node, count);
fireEntry(context, resourceWrapper, node, count, args);
}
public static void checkAuthority(ResourceWrapper resource, Context context, DefaultNode node, int count) throws BlockException {
if (authorityRules == null) {
return;
}
// 根據資源名稱獲取相應的規則
List<AuthorityRule> rules = authorityRules.get(resource.getName());
if (rules == null) {
return;
}
for (AuthorityRule rule : rules) {
// 只要有一條規則校驗不通過,就丟擲AuthorityException
if (!rule.passCheck(context, node, count)) {
throw new AuthorityException(context.getOrigin());
}
}
}
複製程式碼
FlowSlot
FlowSlot主要是根據前面統計好的資訊,與設定的限流規則進行匹配校驗,如果規則校驗不通過則進行限流,具體的程式碼如下:
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args) throws Throwable {
FlowRuleManager.checkFlow(resourceWrapper, context, node, count);
fireEntry(context, resourceWrapper, node, count, args);
}
public static void checkFlow(ResourceWrapper resource, Context context, DefaultNode node, int count) throws BlockException {
List<FlowRule> rules = flowRules.get(resource.getName());
if (rules != null) {
for (FlowRule rule : rules) {
if (!rule.passCheck(context, node, count)) {
throw new FlowException(rule.getLimitApp());
}
}
}
}
複製程式碼
DegradeSlot
DegradeSlot主要是根據前面統計好的資訊,與設定的降級規則進行匹配校驗,如果規則校驗不通過則進行降級,具體的程式碼如下:
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args) throws Throwable {
DegradeRuleManager.checkDegrade(resourceWrapper, context, node, count);
fireEntry(context, resourceWrapper, node, count, args);
}
public static void checkDegrade(ResourceWrapper resource, Context context, DefaultNode node, int count) throws BlockException {
List<DegradeRule> rules = degradeRules.get(resource.getName());
if (rules != null) {
for (DegradeRule rule : rules) {
if (!rule.passCheck(context, node, count)) {
throw new DegradeException(rule.getLimitApp());
}
}
}
}
複製程式碼
總結
sentinel的限流降級等功能,主要是通過一個SlotChain實現的。在鏈式插槽中,有7個核心的Slot,這些Slot各司其職,可以分為以下幾種型別:
一、進行資源呼叫路徑構造的NodeSelectorSlot和ClusterBuilderSlot
二、進行資源的實時狀態統計的StatisticsSlot
三、進行系統保護,限流,降級等規則校驗的SystemSlot、AuthoritySlot、FlowSlot、DegradeSlot
後面幾個Slot依賴於前面幾個Slot統計的結果。至此,每種Slot的功能已經基本分析清楚了。
更多原創好文,請關注公眾號「逅弈逐碼」