Sentinel 原理-呼叫鏈

逅弈逐碼發表於2019-02-28

逅弈 轉載請註明原創出處,謝謝!

系列文章

Sentinel 原理-全解析

Sentinel 原理-滑動視窗

Sentinel 原理-實體類

Sentinel 實戰-限流篇

Sentinel 實戰-控制檯篇

Sentinel 實戰-規則持久化

我們已經知道了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,他有兩個子類:StringResourceWrapperMethodResourceWrapper,通過名字就知道可以將一段字串或一個方法包裝為一個資源。

打個比方,我有一個服務A,請求非常多,經常會被陡增的流量沖垮,為了防止這種情況,簡單的做法,我們可以定義一個 Sentinel 的資源,通過該資源來對請求進行調整,使得允許通過的請求不會把服務A搞崩潰。

resource.png

每個資源的狀態也是不同的,這取決於資源後端的服務,有的資源可能比較穩定,有的資源可能不太穩定。那麼在整個呼叫鏈中,Sentinel 需要對不穩定資源進行控制。當呼叫鏈路中某個資源出現不穩定,例如,表現為 timeout,或者異常比例升高的時候,則對這個資源的呼叫進行限制,並讓請求快速失敗,避免影響到其它的資源,最終導致雪崩的後果。

  • Context

上下文是一個用來儲存呼叫鏈當前狀態的後設資料的類,每次進入一個資源時,就會建立一個上下文。**相同的資源名可能會建立多個上下文。**一個Context中包含了三個核心的物件:

1)當前呼叫鏈的根節點:EntranceNode

2)當前的入口:Entry

3)當前入口所關聯的節點:Node

上下文中只會儲存一個當前正在處理的入口Entry,另外還會儲存呼叫鏈的根節點。需要注意的是,每次進入一個新的資源時,都會建立一個新的上下文。

  • Entry

每次呼叫 SphU#entry() 都會生成一個Entry入口,該入口中會儲存了以下資料:入口的建立時間,當前入口所關聯的節點,當前入口所關聯的呼叫源對應的節點。Entry是一個抽象類,他只有一個實現類,在CtSph中的一個靜態類:CtEntry

  • Node

節點是用來儲存某個資源的各種實時統計資訊的,他是一個介面,通過訪問節點,就可以獲取到對應資源的實時狀態,以此為依據進行限流和降級操作。

可能看到這裡,大家還是比較懵,這麼多類到底有什麼用,接下來就讓我們更進一步,挖掘一下這些類的作用,在這之前,我先給大家展示一下他們之間的關係,如下圖所示:

relations.png

這裡把幾種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屬性是沒有值的,如下圖所示:

create-context.png
建立Entry

每建立一個新的Entry物件時,都會重新設定context的curEntry,並將context原來的curEntry設定為該新Entry物件的父節點,如下圖所示:

new-entry.png
退出Entry

某個Entry退出時,將會重新設定context的curEntry,當該Entry是最頂層的一個入口時,將會把ThreadLocal中儲存的context也清除掉,如下圖所示:

entry-exit.png

構造葉子節點

上面的過程是構造了一棵呼叫鏈的樹,但是這棵樹只有樹幹,沒有葉子,那葉子節點是在什麼時候建立的呢?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,用圖形表示就是這樣:

create-default-node.png

多次建立不同的Entry,並且執行NodeSelectorSlot的entry方法後,就會變成這樣一棵呼叫鏈樹:

create-multi-default-node.png

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的子節點中去之後就變成了下面這樣:

add-child-1.png

緊接著再進入一次,資源名不同,會再次生成一個新的Entry,上面的圖形就變成下圖這樣:

add-child-2.png

此時再次呼叫context的getLastNode方法,因為此時curEntry的parent不再是null了,所以獲取到的lastNode是curEntry.parent.curNode,在上圖中可以很方便的看出,這個節點就是node0。那麼把當前節點node1新增到lastNode的子節點中去,上面的圖形就變成下圖這樣:

add-child-3.png

然後將當前node設定給context的curNode,上面的圖形就變成下圖這樣:

add-child-4.png

假如再建立一個Entry,然後再進入一次不同的資源名,上面的圖就變成下面這樣:

add-child-5.png

至此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的功能已經基本分析清楚了。

更多原創好文,請關注「逅弈逐碼」

更多原創好文,請關注公眾號「逅弈逐碼」

相關文章