作者:逅弈
來源:逅弈逐碼

Sentinel 是阿里中間件團隊開源的,面向分佈式服務架構的輕量級高可用流量控制組件,主要以流量爲切入點,從流量控制、熔斷降級、系統負載保護等多個維度來幫助用戶保護服務的穩定性。

大家可能會問:Sentinel 和之前常用的熔斷降級庫 Netflix Hystrix 有什麼異同呢?Sentinel官網有一個對比的文章,這裏摘抄一個總結的表格,具體的對比可以點此 鏈接 查看。

限流降級神器-哨兵(sentinel)原理分析

限流降級神器-哨兵(sentinel)原理分析

從對比的表格可以看到,Sentinel比Hystrix在功能性上還要強大一些,本文讓我們一起來瞭解下Sentinel的源碼,揭開Sentinel的神祕面紗。

項目結構

將Sentinel的源碼fork到自己的github庫中,接着把源碼clone到本地,然後開始源碼閱讀之旅吧。

首先我們看一下Sentinel項目的整個結構:

限流降級神器-哨兵(sentinel)原理分析

  • sentinel-core 核心模塊,限流、降級、系統保護等都在這裏實現
  • sentinel-dashboard 控制檯模塊,可以對連接上的sentinel客戶端實現可視化的管理
  • sentinel-transport 傳輸模塊,提供了基本的監控服務端和客戶端的API接口,以及一些基於不同庫的實現
  • sentinel-extension 擴展模塊,主要對DataSource進行了部分擴展實現
  • sentinel-adapter 適配器模塊,主要實現了對一些常見框架的適配
  • sentinel-demo 樣例模塊,可參考怎麼使用sentinel進行限流、降級等
  • sentinel-benchmark 基準測試模塊,對核心代碼的精確性提供基準測試

運行樣例

基本上每個框架都會帶有樣例模塊,有的叫example,有的叫demo,sentinel也不例外。

那我們從sentinel的demo中找一個例子運行下看看大致的情況吧,上面說過了sentinel主要的核心功能是做限流、降級和系統保護,那我們就從“限流”開始看sentinel的實現原理吧。

限流降級神器-哨兵(sentinel)原理分析

可以看到sentinel-demo模塊中有很多不同的樣例,我們找到basic模塊下的flow包,這個包下面就是對應的限流的樣例,但是限流也有很多種類型的限流,我們就找根據qps限流的類看吧,其他的限流方式原理上都大差不差。

執行上面的代碼後,打印出如下的結果:

限流降級神器-哨兵(sentinel)原理分析

可以看到,上面的結果中,pass的數量和我們的預期並不相同,我們預期的是每秒允許pass的請求數是20個,但是目前有很多pass的請求數是超過20個的。

原因是,我們這裏測試的代碼使用了多線程,注意看 threadCount 的值,一共有32個線程來模擬, 而在RunTask的run方法中執行資源保護時,即在 SphU.entry 的內部是沒有加鎖的,所以就會導致在高併發下,pass的數量會高於20。

可以用下面這個模型來描述下,有一個TimeTicker線程在做統計,每1秒鐘做一次。有N個RunTask線程在模擬請求,被訪問的business code被資源key保護着,根據規則,每秒只允許20個請求通過。

由於pass、block、total等計數器是全局共享的,而多個RunTask線程在執行SphU.entry申請獲取entry時,內部沒有鎖保護,所以會存在pass的個數超過設定的閾值。

限流降級神器-哨兵(sentinel)原理分析

那爲了證明在單線程下限流的正確性與可靠性,那我們的模型就應該變成了這樣:

限流降級神器-哨兵(sentinel)原理分析

那接下來我把 threadCount 的值改爲1,只有一個線程來執行這個方法,看下具體的限流結果,執行上面的代碼後打印的結果如下:

限流降級神器-哨兵(sentinel)原理分析

可以看到pass數基本上維持在20,但是第一次統計的pass值還是超過了20。這又是什麼原因導致的呢?

其實仔細看下Demo中的代碼可以發現,模擬請求是用的一個線程,統計結果是用的另外一個線程,統計線程每1秒鐘統計一次結果,這兩個線程之間是有時間上的誤差的。從TimeTicker線程打印出來的時間戳可以看出來,雖然每隔一秒進行統計,但是當前打印時的時間和上一次的時間還是有誤差的,不完全是1000ms的間隔。

要真正驗證每秒限制20個請求,保證數據的精準性,需要做基準測試,這個不是本篇文章的重點,有興趣的同學可以去了解下jmh,sentinel中的基準測試也是通過jmh做的。

深入原理

通過一個簡單的示例程序,我們瞭解了sentinel可以對請求進行限流,除了限流外,還有降級和系統保護等功能。那現在我們就撥開雲霧,深入源碼內部去一窺sentinel的實現原理吧。

首先從入口開始: SphU.entry() 。這個方法會去申請一個entry,如果能夠申請成功,則說明沒有被限流,否則會拋出BlockException,表面已經被限流了。

從 SphU.entry() 方法往下執行會進入到 Sph.entry() ,Sph的默認實現類是 CtSph ,在CtSph中最終會執行到 entry(ResourceWrapperresourceWrapper,intcount,Object...args)throwsBlockException 這個方法。

我們來看一下這個方法的具體實現:

這個方法可以分爲以下幾個部分:

  • 1.對參數和全局配置項做檢測,如果不符合要求就直接返回了一個CtEntry對象,不會再進行後面的限流檢測,否則進入下面的檢測流程。
  • 2.根據包裝過的資源對象獲取對應的SlotChain
  • 3.執行SlotChain的entry方法
  • 3.1.如果SlotChain的entry方法拋出了BlockException,則將該異常繼續向上拋出

  • 3.2.如果SlotChain的entry方法正常執行了,則最後會將該entry對象返回
  • 4.如果上層方法捕獲了BlockException,則說明請求被限流了,否則請求能正常執行

其中比較重要的是第2、3兩個步驟,我們來分解一下這兩個步驟。

創建SlotChain

首先看一下lookProcessChain的方法實現:

該方法使用了一個HashMap做了緩存,key是資源對象。這裏加了鎖,並且做了 doublecheck 。具體構造chain的方法是通過: Env.slotsChainbuilder.build() 這句代碼創建的。那就進入這個方法看看吧。

Chain是鏈條的意思,從build的方法可看出,ProcessorSlotChain是一個鏈表,裏面添加了很多個Slot。具體的實現需要到DefaultProcessorSlotChain中去看。

DefaultProcessorSlotChain中有兩個AbstractLinkedProcessorSlot類型的變量:first和end,這就是鏈表的頭結點和尾節點。

創建DefaultProcessorSlotChain對象時,首先創建了首節點,然後把首節點賦值給了尾節點,可以用下圖表示:

限流降級神器-哨兵(sentinel)原理分析

將第一個節點添加到鏈表中後,整個鏈表的結構變成了如下圖這樣:

限流降級神器-哨兵(sentinel)原理分析

將所有的節點都加入到鏈表中後,整個鏈表的結構變成了如下圖所示:

限流降級神器-哨兵(sentinel)原理分析

這樣就將所有的Slot對象添加到了鏈表中去了,每一個Slot都是繼承自AbstractLinkedProcessorSlot。而AbstractLinkedProcessorSlot是一種責任鏈的設計,每個對象中都有一個next屬性,指向的是另一個AbstractLinkedProcessorSlot對象。其實責任鏈模式在很多框架中都有,比如Netty中是通過pipeline來實現的。

知道了SlotChain是如何創建的了,那接下來就要看下是如何執行Slot的entry方法的了。

執行SlotChain的entry方法

lookProcessChain方法獲得的ProcessorSlotChain的實例是DefaultProcessorSlotChain,那麼執行chain.entry方法,就會執行DefaultProcessorSlotChain的entry方法,而DefaultProcessorSlotChain的entry方法是這樣的:

也就是說,DefaultProcessorSlotChain的entry實際是執行的first屬性的transformEntry方法。

而transformEntry方法會執行當前節點的entry方法,在DefaultProcessorSlotChain中first節點重寫了entry方法,具體如下:

first節點的entry方法,實際又是執行的super的fireEntry方法,那繼續把目光轉移到fireEntry方法,具體如下:

從這裏可以看到,從fireEntry方法中就開始傳遞執行entry了,這裏會執行當前節點的下一個節點transformEntry方法,上面已經分析過了,transformEntry方法會觸發當前節點的entry,也就是說fireEntry方法實際是觸發了下一個節點的entry方法。具體的流程如下圖所示:

限流降級神器-哨兵(sentinel)原理分析

從圖中可以看出,從最初的調用Chain的entry()方法,轉變成了調用SlotChain中Slot的entry()方法。從上面的分析可以知道,SlotChain中的第一個Slot節點是NodeSelectorSlot。

執行Slot的entry方法

現在可以把目光轉移到SlotChain中的第一個節點NodeSelectorSlot的entry方法中去了,具體的代碼如下:

從代碼中可以看到,NodeSelectorSlot節點做了一些自己的業務邏輯處理,具體的大家可以深入源碼繼續追蹤,這裏大概的介紹下每種Slot的功能職責:

  • NodeSelectorSlot 負責收集資源的路徑,並將這些資源的調用路徑,以樹狀結構存儲起來,用於根據調用路徑來限流降級;
  • ClusterBuilderSlot 則用於存儲資源的統計信息以及調用者信息,例如該資源的 RT, QPS, thread count 等等,這些信息將用作爲多維度限流,降級的依據;
  • StatistcSlot 則用於記錄,統計不同緯度的 runtime 信息;
  • FlowSlot 則用於根據預設的限流規則,以及前面 slot 統計的狀態,來進行限流;
  • AuthorizationSlot 則根據黑白名單,來做黑白名單控制;
  • DegradeSlot 則通過統計信息,以及預設的規則,來做熔斷降級;
  • SystemSlot 則通過系統的狀態,例如 load1 等,來控制總的入口流量;

執行完業務邏輯處理後,調用了fireEntry()方法,由此觸發了下一個節點的entry方法。此時我們就知道了sentinel的責任鏈就是這樣傳遞的:每個Slot節點執行完自己的業務後,會調用fireEntry來觸發下一個節點的entry方法。

所以可以將上面的圖完整了,具體如下:

限流降級神器-哨兵(sentinel)原理分析

至此就通過SlotChain完成了對每個節點的entry()方法的調用,每個節點會根據創建的規則,進行自己的邏輯處理,當統計的結果達到設置的閾值時,就會觸發限流、降級等事件,具體是拋出BlockException異常。

總結

sentinel主要是基於7種不同的Slot形成了一個鏈表,每個Slot都各司其職,自己做完分內的事之後,會把請求傳遞給下一個Slot,直到在某一個Slot中命中規則後拋出BlockException而終止。

前三個Slot負責做統計,後面的Slot負責根據統計的結果結合配置的規則進行具體的控制,是Block該請求還是放行。

控制的類型也有很多可選項:根據qps、線程數、冷啓動等等。

然後基於這個核心的方法,衍生出了很多其他的功能:

  • 1、dashboard控制檯,可以可視化的對每個連接過來的sentinel客戶端 (通過發送heartbeat消息)進行控制,dashboard和客戶端之間通過http協議進行通訊。
  • 2、規則的持久化,通過實現DataSource接口,可以通過不同的方式對配置的規則進行持久化,默認規則是在內存中的
  • 3、對主流的框架進行適配,包括servlet,dubbo,rRpc等

Dashboard控制檯

sentinel-dashboard是一個單獨的應用,通過spring-boot進行啓動,主要提供一個輕量級的控制檯,它提供機器發現、單機資源實時監控、集羣資源彙總,以及規則管理的功能。

我們只需要對應用進行簡單的配置,就可以使用這些功能。

1 啓動控制檯

1.1 下載代碼並編譯控制檯

  • 下載 控制檯 工程
  • 使用以下命令將代碼打包成一個 fat jar: mvn cleanpackage

1.2 啓動

使用如下命令啓動編譯後的控制檯:

上述命令中我們指定了一個JVM參數, -Dserver.port=8080 用於指定 Spring Boot 啓動端口爲 8080 。

2 客戶端接入控制檯

控制檯啓動後,客戶端需要按照以下步驟接入到控制檯。

2.1 引入客戶端jar包

通過 pom.xml 引入 jar 包:

2.2 配置啓動參數

啓動時加入 JVM 參數 -Dcsp.sentinel.dashboard.server=consoleIp:port 指定控制檯地址和端口。若啓動多個應用,則需要通過 -Dcsp.sentinel.api.port=xxxx 指定客戶端監控 API 的端口(默認是 8719)。

除了修改 JVM 參數,也可以通過配置文件取得同樣的效果。更詳細的信息可以參考 啓動配置項。

2.3 觸發客戶端初始化

確保客戶端有訪問量,Sentinel 會在 客戶端首次調用的時候 進行初始化,開始向控制檯發送心跳包。

sentinel-dashboard是一個獨立的web應用,可以接受客戶端的連接,然後與客戶端之間進行通訊,他們之間使用http協議進行通訊。他們之間的關係如下圖所示:

限流降級神器-哨兵(sentinel)原理分析

dashboard

dashboard啓動後會等待客戶端的連接,具體的做法是在 MachineRegistryController 中有一個 receiveHeartBeat 的方法,客戶端發送心跳消息,就是通過http請求這個方法。

dashboard接收到客戶端的心跳消息後,會把客戶端的傳遞過來的ip、port等信息封裝成一個 MachineInfo 對象,然後將該對象通過 MachineDiscovery 接口的 addMachine 方法添加到一個ConcurrentHashMap中保存起來。

這裏會有問題,因爲客戶端的信息是保存在dashboard的內存中的,所以當dashboard應用重啓後,之前已經發送過來的客戶端信息都會丟失掉。

client

client在啓動時,會通過CommandCenterInitFunc選擇一個,並且只選擇一個CommandCenter進行啓動。

啓動之前會通過spi的方式掃描獲取到所有的CommandHandler的實現類,然後將所有的CommandHandler註冊到一個HashMap中去,待後期使用。

PS:考慮一下,爲什麼CommandHandler不需要做持久化,而是直接保存在內存中。

註冊完CommandHandler之後,緊接着就啓動CommandCenter了,目前CommandCenter有兩個實現類:

  • SimpleHttpCommandCenter 通過ServerSocket啓動一個服務端,接受socket連接
  • NettyHttpCommandCenter 通過Netty啓動一個服務端,接受channel連接

CommandCenter啓動後,就等待dashboard發送消息過來了,當接收到消息後,會把消息通過具體的CommandHandler進行處理,然後將處理的結果返回給dashboard。

這裏需要注意的是,dashboard給client發送消息是通過異步的httpClient進行發送的,在HttpHelper類中。

但是詭異的是,既然通過異步發送了,又通過一個CountDownLatch來等待消息的返回,然後獲取結果,那這樣不就失去了異步的意義的嗎?具體的代碼如下:

主流框架的適配

sentinel也對一些主流的框架進行了適配,使得在使用主流框架時,也可以享受到sentinel的保護。目前已經支持的適配器包括以下這些:

  • Web Servlet
  • Dubbo
  • Spring Boot / Spring Cloud
  • gRPC
  • Apache RocketMQ

其實做適配就是通過那些主流框架的擴展點,然後在擴展點上加入sentinel限流降級的代碼即可。拿Servlet的適配代碼看一下,具體的代碼是:

通過Servlet的Filter進行擴展,實現一個Filter,然後在doFilter方法中對請求進行限流控制,如果請求被限流則將請求重定向到一個默認頁面,否則將請求放行給下一個Filter。

規則持久化,動態化

Sentinel 的理念是開發者只需要關注資源的定義,當資源定義成功,可以動態增加各種流控降級規則。

Sentinel 提供兩種方式修改規則:

  • 通過 API 直接修改 ( loadRules )
  • 通過 DataSource 適配不同數據源修改

通過 API 修改比較直觀,可以通過以下三個 API 修改不同的規則:

DataSource 擴展

上述 loadRules() 方法只接受內存態的規則對象,但應用重啓後內存中的規則就會丟失,更多的時候規則最好能夠存儲在文件、數據庫或者配置中心中。

DataSource 接口給我們提供了對接任意配置源的能力。相比直接通過 API 修改規則,實現 DataSource 接口是更加可靠的做法。

官方推薦通過控制檯設置規則後將規則推送到統一的規則中心,用戶只需要實現 DataSource 接口,來監聽規則中心的規則變化,以實時獲取變更的規則。

DataSource 拓展常見的實現方式有:

  • 拉模式:客戶端主動向某個規則管理中心定期輪詢拉取規則,這個規則中心可以是 SQL、文件,甚至是 VCS 等。這樣做的方式是簡單,缺點是無法及時獲取變更;
  • 推模式:規則中心統一推送,客戶端通過註冊監聽器的方式時刻監聽變化,比如使用 Nacos、Zookeeper 等配置中心。這種方式有更好的實時性和一致性保證。

至此,sentinel的基本情況都已經分析了,更加詳細的內容,可以繼續閱讀源碼來研究。

相關文章