引言
Sentinel
作為ali開源的一款輕量級流控框架,主要以流量為切入點,從流量控制、熔斷降級、系統負載保護等多個維度來幫助用戶保護服務的穩定性。相比於Hystrix
,Sentinel
的設計更加簡單,在 Sentinel
中資源定義和規則配置是分離的,也就是說用戶可以先通過Sentinel API
給對應的業務邏輯定義資源(埋點),然后在需要的時候再配置規則,通過這種組合方式,極大的增加了Sentinel
流控的靈活性。
引入Sentinel
帶來的性能損耗非常小。只有在業務單機量級超過25W QPS的時候才會有一些顯著的影響(5% - 10% 左右),單機QPS不太大的時候損耗幾乎可以忽略不計。
Sentinel
提供兩種埋點方式:
try-catch
方式(通過SphU.entry(...)
),用戶在 catch 塊中執行異常處理 / fallbackif-else
方式(通過SphO.entry(...)
),當返回 false 時執行異常處理 / fallback
寫在前面
在此之前,需要先了解一下Sentinel
的工作流程
在 Sentinel
里面,所有的資源都對應一個資源名稱(resourceName
),每次資源調用都會創建一個 Entry
對象。Entry
可以通過對主流框架的適配自動創建,也可以通過注解的方式或調用 SphU API
顯式創建。Entry
創建的時候,同時也會創建一系列功能插槽(slot chain
),這些插槽有不同的職責,例如默認情況下會創建一下7個插槽:
NodeSelectorSlot
負責收集資源的路徑,並將這些資源的調用路徑,以樹狀結構存儲起來,用於根據調用路徑來限流降級;ClusterBuilderSlot
則用於存儲資源的統計信息以及調用者信息,例如該資源的RT, QPS, thread count
等等,這些信息將用作為多維度限流,降級的依據;StatisticSlot
則用於記錄、統計不同緯度的runtime
指標監控信息;FlowSlot
則用於根據預設的限流規則以及前面slot
統計的狀態,來進行流量控制;AuthoritySlot
則根據配置的黑白名單和調用來源信息,來做黑白名單控制;DegradeSlot
則通過統計信息以及預設的規則,來做熔斷降級;SystemSlot
則通過系統的狀態,例如load1
等,來控制總的入口流量
注意:這里的插槽鏈都是一一對應資源名稱的
上面的所介紹的插槽(slot chain
)是Sentinel
非常重要的概念。同時還有一個非常重要的概念那就是Node
,為了幫助理解,盡我所能畫了下面這張圖,可以看到整個結構非常的像一棵樹:
簡單解釋下上圖:
- 頂部藍色的
node
節點為根節點,全局唯一 - 下面黃色的節點為入口節點,每個
CentextName
(上下文名稱)一一對應一個- 可以有多個子節點(對應多種資源)
- 中間綠色框框中的節點都是屬於同一個資源的(相同的
ResourceName
) - 最底下紫色的節點是集群節點,可以理解成綠色框框中Node資源的整合
- 最右邊的指的是不同的來源(origin)流量,同一個EntranceNode可以有多個來源
以上2個概念務必要理清楚,之后再一步一步看源碼會比較清晰
下面我們將從入口源碼開始一步一步分析整個調用過程:
源碼分析
下面的是一個Sentinel
使用的示例代碼,我們就從這里切入開始分析
// 創建一個名稱為entrance1,來源為appA 的上下文Context
ContextUtil.enter("entrance1", "appA");
// 創建一個資源名稱nodeA的Entry
Entry nodeA = SphU.entry("nodeA");
if (nodeA != null) {
nodeA.exit();
}
// 清除上下文
ContextUtil.exit();
ContextUtil.enter("entrance1", "appA")
public static Context enter(String name, String origin) {
// 判斷上下文名稱是否為默認的名稱(sentinel_default_context) 是的話直接拋出異常
if (Constants.CONTEXT_DEFAULT_NAME.equals(name)) {
throw new ContextNameDefineException(
"The " + Constants.CONTEXT_DEFAULT_NAME + " can't be permit to defined!");
}
return trueEnter(name, origin);
}
protected static Context trueEnter(String name, String origin) {
// 先從ThreadLocal中嘗試獲取,獲取到則直接返回
Context context = contextHolder.get();
if (context == null) {
Map<String, DefaultNode> localCacheNameMap = contextNameNodeMap;
// 嘗試從緩存中獲取該上下文名稱對應的 入口節點
DefaultNode node = localCacheNameMap.get(name);
if (node == null) {
// 判斷緩存中入口節點數量是否大於2000
if (localCacheNameMap.size() > Constants.MAX_CONTEXT_NAME_SIZE) {
setNullContext();
return NULL_CONTEXT;
} else {
try {
// 加鎖
LOCK.lock();
// 雙重檢查鎖
node = contextNameNodeMap.get(name);
if (node == null) {
// 判斷緩存中入口節點數量是否大於2000
if (contextNameNodeMap.size() > Constants.MAX_CONTEXT_NAME_SIZE) {
setNullContext();
return NULL_CONTEXT;
} else {
// 根據上下文名稱生成入口節點(entranceNode)
node = new EntranceNode(new StringResourceWrapper(name, EntryType.IN), null);
// 加入至全局根節點下
Constants.ROOT.addChild(node);
// 加入緩存中
Map<String, DefaultNode> newMap = new HashMap<>(contextNameNodeMap.size() + 1);
newMap.putAll(contextNameNodeMap);
newMap.put(name, node);
contextNameNodeMap = newMap;
}
}
} finally {
LOCK.unlock();
}
}
}
// 初始化上下文對象
context = new Context(node, name);
context.setOrigin(origin);
// 設置到當前線程中
contextHolder.set(context);
}
return context;
}
主要做了2件事情
- 根據
ContextName
生成entranceNode
,並加入緩存,每個ContextName
對應一個入口節點entranceNode
- 根據
ContextName
和entranceNode
初始化上下文對象,並將上下文對象設置到當前線程中
這里有幾點需要注意:
- 入口節點數量不能大於2000,大於會直接拋異常
- 每個
ContextName
對應一個入口節點entranceNode
- 每個
entranceNode
都有共同的父節點。也就是根節點
Entry nodeA = SphU.entry("nodeA")
// SphU.class
public static Entry entry(String name) throws BlockException {
// 默認為 出口流量類型,單位統計數為1
return Env.sph.entry(name, EntryType.OUT, 1, OBJECTS0);
}
// CtSph.class
public Entry entry(String name, EntryType type, int count, Object... args) throws BlockException {
// 生成資源對象
StringResourceWrapper resource = new StringResourceWrapper(name, type);
return entry(resource, count, args);
}
public Entry entry(ResourceWrapper resourceWrapper, int count, Object... args) throws BlockException {
return entryWithPriority(resourceWrapper, count, false, args);
}
上面的代碼比較簡單,不指定EntryType
的話,則默認為出口流量類型,最終會調用entryWithPriority
方法,主要業務邏輯也都在這個方法中
- entryWithPriority方法
private Entry entryWithPriority(ResourceWrapper resourceWrapper, int count, boolean prioritized, Object... args)
throws BlockException {
// 獲取當前線程上下文對象
Context context = ContextUtil.getContext();
// 上下文名稱對應的入口節點是否已經超過閾值2000,超過則會返回空 CtEntry
if (context instanceof NullContext) {
return new CtEntry(resourceWrapper, null, context);
}
if (context == null) {
// 如果沒有指定上下文名稱,則使用默認名稱,也就是默認入口節點
context = InternalContextUtil.internalEnter(Constants.CONTEXT_DEFAULT_NAME);
}
// 全局開關
if (!Constants.ON) {
return new CtEntry(resourceWrapper, null, context);
}
// 生成插槽鏈
ProcessorSlot<Object> chain = lookProcessChain(resourceWrapper);
/*
* 表示資源(插槽鏈)超過6000,因此不會進行規則檢查。
*/
if (chain == null) {
return new CtEntry(resourceWrapper, null, context);
}
// 生成 Entry 對象
Entry e = new CtEntry(resourceWrapper, chain, context);
try {
// 開始執行插槽鏈 調用邏輯
chain.entry(context, resourceWrapper, null, count, prioritized, args);
} catch (BlockException e1) {
// 清除上下文
e.exit(count, args);
throw e1;
} catch (Throwable e1) {
// 除非Sentinel內部存在錯誤,否則不應發生這種情況。
RecordLog.info("Sentinel unexpected exception", e1);
}
return e;
}
這個方法可以說是涵蓋了整個Sentinel的核心邏輯
- 獲取上下文對象,如果上下文對象還未初始化,則使用默認名稱初始化。初始化邏輯在上文已經分析過
- 判斷全局開關
- 根據給定的資源生成插槽鏈,插槽鏈是跟資源相關的,Sentinel最關鍵的邏輯也都在各個插槽中。初始化的邏輯在
lookProcessChain(resourceWrapper);
中,下文會分析 - 依順序執行每個插槽邏輯
lookProcessChain(resourceWrapper)方法
lookProcessChain
方法為指定資源生成插槽鏈,下面我們來看下它的初始化邏輯
ProcessorSlot<Object> lookProcessChain(ResourceWrapper resourceWrapper) {
// 根據資源嘗試從全局緩存中獲取
ProcessorSlotChain chain = chainMap.get(resourceWrapper);
if (chain == null) {
// 非常常見的雙重檢查鎖
synchronized (LOCK) {
chain = chainMap.get(resourceWrapper);
if (chain == null) {
// 判斷資源數是否大於6000
if (chainMap.size() >= Constants.MAX_SLOT_CHAIN_SIZE) {
return null;
}
// 初始化插槽鏈
chain = SlotChainProvider.newSlotChain();
Map<ResourceWrapper, ProcessorSlotChain> newMap = new HashMap<ResourceWrapper, ProcessorSlotChain>(
chainMap.size() + 1);
newMap.putAll(chainMap);
newMap.put(resourceWrapper, chain);
chainMap = newMap;
}
}
}
return chain;
}
- 根據資源嘗試從全局緩存中獲取插槽鏈。每個資源對應一個插槽鏈(資源嘴多只能定義6000個)
- 初始化插槽鏈上的插槽(
SlotChainProvider.newSlotChain()
方法中)
下面我們看下初始化插槽鏈上的插槽的邏輯
SlotChainProvider.newSlotChain()
public static ProcessorSlotChain newSlotChain() {
// 判斷是否已經初始化過
if (builder != null) {
return builder.build();
}
// 加載 SlotChain
resolveSlotChainBuilder();
// 加載失敗則使用默認 插槽鏈
if (builder == null) {
RecordLog.warn("[SlotChainProvider] Wrong state when resolving slot chain builder, using default");
builder = new DefaultSlotChainBuilder();
}
// 構建完成
return builder.build();
}
/**
* java自帶 SPI機制 加載 slotChain
*/
private static void resolveSlotChainBuilder() {
List<SlotChainBuilder> list = new ArrayList<SlotChainBuilder>();
boolean hasOther = false;
// 嘗試獲取自定義SlotChainBuilder,通過JAVA SPI機制擴展
for (SlotChainBuilder builder : LOADER) {
if (builder.getClass() != DefaultSlotChainBuilder.class) {
hasOther = true;
list.add(builder);
}
}
if (hasOther) {
builder = list.get(0);
} else {
// 未獲取到自定義 SlotChainBuilder 則使用默認的
builder = new DefaultSlotChainBuilder();
}
RecordLog.info("[SlotChainProvider] Global slot chain builder resolved: "
+ builder.getClass().getCanonicalName());
}
- 首先會嘗試獲取自定義的
SlotChainBuilder
來構建插槽鏈,自定義的SlotChainBuilder
可以通過JAVA SPI機制來擴展 - 如果未配置自定義的
SlotChainBuilder
,則會使用默認的DefaultSlotChainBuilder
來構建插槽鏈,DefaultSlotChainBuilder
所構建的插槽就是文章開頭我們提到的7種Slot
。每個插槽都有其對應的職責,各司其職,后面我們會詳細分析這幾個插槽的源碼,及所承擔的職責。
總結
文章開頭的提到的兩個點(插槽鏈和Node),這是Sentinel的重點,理解這兩點對於閱讀源碼來說事半功倍
Sentinel系列