四. 集羣容錯
在客戶端已經從註冊中心拉取和訂閱服務列表完畢的前提下,Dubbo 完成一次完整的 RPC 調用,流程如下:
- 服務列表聚合;
- 路由;
- 負載均衡;
- 選擇一臺機器進行 RPC 調用;
- 請求交給底層 I/O 線程池處理;
- 讀寫、序列化、反序列化;
- 方法調用;
將上面的步驟進行細化,在一次 RPC 調用過程中,Cluster 層的流程如下:
- 根據不同的容錯機制,生成 Invoker 對象,調用 AbstractClusterInvoker 的 Invoker 方法;
- 獲得可調用的服務列表;
- 使用 Router 接口處理服務列表,根據路由規則過濾一部分服務;
- 負載均衡;
- RPC 調用;
其中步驟 1, 2, 3 是模板方法,使用通用的校驗、參數準備等準備工作。最終,不同的容錯機制的子類實現不同的 doInvoke
方法,每個子類方法都有各自的路由、負載均衡實現策略。
本章節主要總結 RPC 在 Cluster 層的工作,涉及步驟 1, 2, 3, 4,其中容錯機制見[5.1](##5.1 容錯機制),容錯過程中獲取 Invoker 列表需要用到 Directory,見[5.2](##5.2 Directory);Directory 過程中需要用到路由,見[5.3](##5.3 路由);負載均衡見[5.4](##5.4 負載均衡)。剩餘步驟 5, 6, 7 是具體的 RPC 調用,見[第六章](#6. 遠程調用)。
4.1 容錯機制
容錯過程是在各容錯機制實現子類的 doInvoke
方法重寫實現的。容錯過程對上層用戶是完全透明的,上層用戶不用關心容錯過程是怎麼實現的,同時用戶也可以通過不同的配置項來選擇不同的容錯機制。支持的容錯機制如下:
注:
大部分容錯機制的核心步驟都是:
- 校驗;
- 獲取配置參數;
- 實現各自容錯機制的調用;
在上述步驟 3 容錯機制的調用中,主要步驟都是:
- 校驗;
- 負載均衡;
- RPC 調用;
如果有不同,在各自條目中進行說明
- Failover:重試失敗,默認策略
- 調用失敗,嘗試調用其他服務器;
- 根據配置的重試次數,進行重試;如果有成功,則返回;全部重試失敗之後,拋出異常;
- Failfast:快速失敗
- RPC 調用失敗後,將異常封裝爲
RpcException
,拋出並返回,不做任何重試;
- RPC 調用失敗後,將異常封裝爲
- Failsafe:安全失敗
- 出現異常時忽略;
- Failback:定時重試失敗
- 調用失敗後,將該失敗的
invocation
緩存到ConcurrentHashMap
中,並返回空結果集;同時設置定時線程池,定時時間到了就將失敗的任務投入線程池,重新請求; - 如果重新請求成功,則從緩存中移除,請求失敗則判斷失敗次數;如果失敗次數少於設定的閾值,則重新投入定時線程池;如果多於設定的閾值,打印錯誤並放棄該請求;
- 定時重試失敗的實現思路,可以用於 Kafka 的重試隊列;
- 調用失敗後,將該失敗的
- Forking:並行
- 根據設定的並行數量,循環執行負載均衡,篩選出可調用的 Invoker 列表;
- 循環使用線程池,同時調用多個相同的服務;多個服務中,只要其中一個返回,就立即返回結果;所有線程調用失敗,則拋出異常;
- 該部分的實現是通過阻塞隊列
BlockingQueue
實現的;將多個調用任務投入線程池後,任務執行結果投入BlockingQueue
; - 如果任務執行結果是異常類型,投入
BlockingQueue
拋出異常;此時記錄異常次數,只有到記錄異常次數等於服務數量時,說明所有服務都拋出異常,此時再將異常信息投入BlockingQueue
- 調用任務投入線程池之後,就立即調用
BlockingQueue # poll(int)
方法拉取結果,拉取到第一個結果就返回。如果返回值正常,就是其中一個服務的返回結果;如果返回值爲Exception
類型,說明所有服務都出現異常;
- 該部分的實現是通過阻塞隊列
- Broadcast:廣播
- 廣播調用所有可用服務,循環遍歷所有 Invoker,每個 Invoker 分別做 RPC 調用;
- 如果有任意一個節點報錯,等待廣播最後完成之後拋出;如果多個節點異常,最後一個節點拋出的異常會覆蓋前面拋出的異常;
- Available:可用
- 最簡單的方式,請求不會做負載均衡,遍歷所有服務列表,找到第一個可用節點,直接請求並返回結果;
- Mock:仿真
- 調用失敗時返回僞造的響應結果,或者直接強行返回僞造結果;
- Mergeable:合併:將多個節點請求的結果合併;
4.2 Directory
容錯過程中需要獲取 Invoker 列表,用於後續的路由和負載均衡。這個過程需要用到 Directory # list
方法執行。Directory 接口有一個抽象類 AbstractDirectory,以及兩個主要實現類:動態列表 RegistryDirectory,以及靜態列表 StaticDirectory。主要總結的是動態列表 RegistryDirectory
,以及封裝了基礎方法的抽象類 AbstractDirectory
。
RegistryDirectory
主要實現了兩個功能:
- 與註冊中心的訂閱,動態更新本地的 Invoker 列表;
- 實現父類的
doList
方法;
4.2.1 訂閱與動態更新
註冊中心訂閱的部分主要在 ZookeeperRegistry # doSubscribe()
方法中實現,見[第二章註冊中心](#二. 註冊中心)部分。
在監聽到註冊中心對應 URL 變化後,觸發 RegistryDirectory
對各種本地配置的動態更新。更新的配置包括:
- 路由信息:通過路由工廠
RouterFactory
將 URL 包裝成路由規則(見[5.3](#5.3 路由)),更新本地路由信息;- 更新路由規則,是通過 override 協議實現的;
- 服務提供者配置 Configurator:管理員可以在 dubbo-admin 下動態修改生產者的參數,這些參數會保存在配置中心的 configurators 類目錄下;
- Invoker 修改:如果監聽到的 Invoker 類型 URL 不爲空,則將新的 URL 與本地舊 URL 合併,同時銷燬舊 Invoker;
4.2.2 doList
doList
方法主要作用,就是調用路由方法。
4.3 路由
注:路由的整體思路與筆者設計的動態彙總統計業務不謀而合,通過表達式的方式實現數據的處理。
路由會根據用戶配置的不同路由策略,對 Invoker 列表進行過濾。主要分爲條件路由、文本路由、腳本路由。路由工廠 RouterFactory
是一個 SPI 接口,用戶可以自行通過實現 Router
接口擴展 Router 類;在調用的時候,在 URL 的 protocol
參數中可以設置 file / script / condition,分別尋找對應的實現類。
4.3.1 條件路由 (ConditionRouter)
條件路由使用的是 condition://協議
,URL 形式是:“condition://0.0.0.0/com.foo.DemoService?category=routers&dynamic=false&rule=” + URL.encode(“host = 10.20.153.10 => host = 10.20.153.11”)
;每個參數都是有含義的:
參數名 | 含義 |
---|---|
condition:// | 路由類型爲條件路由(可擴展) |
0.0.0.0 | 對全部 IP 生效,填入具體 IP,則只對該 IP 生效 |
com.foo.DemoService | 對指定服務生效,必填 |
category=routers | 當前設置指該數據爲動態配置類型,必填 |
dynamic=false | 當前設置表示該數據爲持久數據,必填 |
enable=true | 覆蓋規則生效,默認生效 |
force=false | 路由結果爲空時,是否強制執行,默認爲 false,路由爲空時將自動失效 |
rule=… | 路由規則內容,必填 |
條件路由最關鍵的部分在於 rule 的路由規則。以下面的路由規則爲例:
method = find* => host = 192.168.1.22
- 該路由規則的意義:所有調用
find
開頭的方法,都會被路由到 192.168.1.22 的服務節點上; =>
之前部分是服務消費者匹配條件;- 如果匹配條件爲空,則表示應用於所有消費者;
=>
之後部分是服務提供者列表的過濾條件;- 如果過濾條件爲空,則表示禁止訪問;
- 表示規則的表達式支持
$protocol
等佔位符方式,也支持=, !=
等條件,也支持通配符*
。
條件路由的具體實現類是 ConditionRouter
,整體的思想是通過正則表達式,按照 =>
進行分割,然後對符號前後的內容進行正則表達式的匹配,匹配結果存入對象 MatchPair
中。對於上述的佔位符、通配符等,MatchPair
會進行匹配解析。
注:條件路由的整體思路,類似於筆者設計的動態彙總統計業務。
4.3.2 文件路由 (FileRouter)
文件路由通常和腳本路由搭配使用。文件路由將規則寫到文件中,文件中寫的是自定義的腳本規則,腳本可以是 Javascript, Groovy 等,文件路由 FileRouter
找到對應文件,將文件中的腳本內容按照類型匹配腳本路由,執行解析。
4.3.3 腳本路由 (ScriptRouter)
腳本路由使用 JDK 自帶的腳本解析器,對腳本解析並運行,默認使用 Javascript 解析器。在構造腳本路由時初始化腳本執行引擎,根據腳本不同的類型,通過 JDK 提供的 ScriptEngineManager
創建不同的腳本執行器。接收到腳本內容後,執行 route 方法。具體的過濾邏輯需要用戶自行定義。
注:在筆者設計的動態彙總統計業務中,筆者使用了 Aviator 表達式引擎,它與腳本路由中的腳本執行器
ScriptEngineManager
類似。
4.4 負載均衡
很多容錯策略在路由選擇出所有可用 Invoker 列表中實行最後一步篩選,負載均衡。
負載均衡的核心是 LoadBalance
接口及其子類具體實現的,但並不是直接使用 LoadBalance
方法。在容錯策略中的負載均衡先使用了抽象父類 AbstractClusterInvoker
中定義的 Invoker select
方法,它在 LoadBalance
基礎上又封裝了一些特性:
- 粘滯連接:儘可能讓客戶端總是向同一提供者發起調用。
- 類似的策略,也在 Kafka 再均衡策略 StickyAssignor 中用過;
- 可用檢測;
- 避免重複調用;
select
方法也使用了模板模式,在 select
方法中處理通用邏輯,最後提供 doSelect
抽象方法供各子類具體實現。Dubbo 內置了四種負載均衡算法,此外由於 LoadBalance
接口帶有 @SPI 註解,所以用戶也可以自行擴展負載均衡算法。在調用方法時我們可以在 URL 中通過 loadbalance=xxx
動態指定 select 方法的負載均衡算法。
4.4.1 Random
根據權重,設置隨機概率做負載均衡。
4.4.2 RoundRobin
4.4.3 LeastActive
LeastActive 就是最少活躍調用負載均衡,Dubbo 在運行過程中會統計每一次 Invoker 的調用,每次從活躍數最少的 Invoker 中選一個節點。
4.4.4 一致性 Hash
一致性 Hash 的原理見《數據結構與算法》篇第五章。
Dubbo 的一致性 Hash 負載均衡,將接口名 + 方法名作爲 Key 值,類型爲 ConsistentHashSelector
實例對象作爲 Value 存入一個 ConcurrentHashMap 中。每次請求進入,解析請求獲取到方法,將該方法轉爲 Key 值,找到對應的 ConsistentHashSelector
進行負載均衡。所以 ConsistentHashSelector
是 Dubbo 中一致性 Hash 實現的核心。
ConsistentHashSelector
的環形散列是用 TreeMap 實現的,所有真實節點、虛擬節點都放在 TreeMap 中。將節點的 IP + 遞增數字,然後作 MD5 計算,最後進行 Hash 計算,作爲 TreeMap 的 Key 值。TreeMap 的 Value 值爲對應的某個可以調用的節點。關鍵代碼如下:
// 遍歷所有節點
for (Invoker<T> invoker : invokers) {
// 得到每個節點的 IP
String address = invoker.getUrl().getAddress();
// replicaNumber 是生成的虛擬節點數量,默認 160 個
for (int i = 0; i < replicaNumber / 4; i++) {
// 對 IP + 遞增數字作 MD5 計算,作爲節點標識
byte[] digest = md5(address + i);
for (int h = 0; h < 4; h++) {
// 對標識作 Hash 計算,作爲 TreeMap 的 Key 值
long m = hash(digest, h);
// 當前 Invoker 爲 Value
virtualInvokers.put(m, invoker);
}
}
}
每次請求進來後,進行上述的 Key 值運算,每次請求的參數都不同,但是由於 TreeMap 是有序的樹形結構,所以可以調用 TreeMap#ceilingEntry
方法,找到最近一個大於或等於給定 Key 值的節點 Entry。這樣的操作相當於一致性 Hash 算法的順時針向前查找的效果。