文章目錄
Dubbo負載均衡實現原理
Dubbo負載均衡概述
Dubbo在使用負載均衡的時候並沒有直接使用LoadBalance,而是使用的抽象父類AbstractClusterInvoker中定義的Invoker<T> select方法。因爲抽象父類在LoadBalance的基礎上有風裝了一些新的特性。
Dubbo負載均衡特性
- 粘滯連接。Dubbo中有一種特性叫粘滯連接。
粘滯連接用於有狀態服務,儘可能讓客戶端總是向同一提供者發起調用,除非該提供者“掛了”,再連接另一臺。
粘滯連接將自動開啓延遲連接,以減少長連接數<dubbo:protocol name=“dubbo” sticky=“true”>
- 可用檢測。Dubbo調用的URL中,如果含有cluster.availablecheck=false,則不會檢測遠程服務是否可用,直接調用。如果不設置,則會默認開啓檢查,對所有的服務都做是否可用檢查,如果不可用,則再次做負載均衡。
- 避免重複調用。對於已經調用過的遠程服務,避免重複選擇,每次都是用同一個節點。這種特性主要是爲了避免併發場景下,某個節點瞬間接收大量請求。
Dubbo負載均衡邏輯過程
- 檢查URL中是否有配置粘滯連接,如果有則使用粘滯連接的Invoker。如果沒有配置粘滯連接,或者重複調用檢測不通過、可用檢測不通過,則進入第2步。
- 通過ExtensionLoader獲取負載均衡的具體實現,並通過負載均衡做節點的選擇。對選擇出來的節點做重複調用、可用性檢測,通過則直接返回,否則進入第3步。
- 進行節點重新選擇。如果需要做可用性檢測會遍歷Directory中得到的所有節點,過濾不可用的和已經調用過的節點,在剩餘的節點中做負載均衡;如果不需要做可用性檢測,那麼也會遍歷Directory中得到的所有節點,但只過濾已經調用過的,在剩餘的節點中重新做負載均衡。這裏存在一種情況,就是在過濾不可用或已經調用過的節點時,節點全部被過濾,沒有剩餘節點,則進入第4步。
- 遍歷所有已經調用過的節點,選出所有可用的節點,再通過負載均衡選出一個節點並返回。如果還找不到可用節點,則返回null。
Dubbo負載均衡總體結構
Dubbo內置了4種負載均衡,也可以自行擴展,因爲LoadBalance接口上有@SPI註解。
@SPI(RandomLoadBalance.NAME)
public interface LoadBalance {
@Adaptive("loadbalance")
<T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException;
}
從代碼中可以看出默認的負載均衡實現就是RandomLoadBalance,即隨機負載均衡。由於select方法上有@Activate(“loadbalance”)註解,因此我們可以在URL中可以通過loadalance=xxx來動態指定select時的負載均衡算法。
負載均衡算法名稱 | 效果說明 |
---|---|
Random LoadBalance | 隨機,按權重設置隨機概率。在一個節點上碰撞的概率高,但調用量越大分佈越均勻,而且按概率使用權重後也比較均勻,有利於動態調整提供者的權重 |
RoundRobin LoadBalance | 輪詢,按公約後的權重設置比例。存在慢的提供者累積請求的問題。比如:某臺機器很慢,但沒“掛”,當請求到第二臺時就卡在那裏,久而久之,所有的請求都卡在那裏 |
LeastActive LoadBalance | 最少活躍調用數,如果活躍數量相同則隨機調用,活躍數指調用前後的計數差(調用前計數器+1,調用後計數器-1,如果一個服務提供者計數差很大,則說明該服務處理的比較慢,則減少請求量,防止服務請求阻塞,以及服務端崩潰。)。使慢的提供者收到更少的請求,因爲越慢的提供者的調用前後計數差會越大 |
ConsistentHash LoadBalance | 一致性Hash,相同參數的請求總是發到同一提供者。當某臺提供者“掛”掉的時候,原本發往該提供者的請求,基於虛擬節點,會平攤到其他提供者,不會引發劇烈變動。默認根據只對第一個參數hash,可以通過<dubbo:parameter key="hash.arguments" value="0,1"> 配置進行修改。默認使用160個虛擬節點,可以通過<dubbo:parameter key="hash.nodes" value="320"> 配置進行修改。 |
Dubbo負載均衡具體實現
RandomRobin 隨機負載均衡(default)
- 計算權重並判斷每個Invoker的權重是否一樣。遍歷整個Invoker列表,求和總權重。在遍歷過程中,會對比每個Invoker的權重,判斷所有Invoker的權重是否相同。
- 如果權重相同,則說明每個Invoker的概率都一樣,因此直接使用nextInt隨機選一個Invoker返回即可。
- 如果權重不同,則首先得到偏移值,然後根據偏移值找到對應的Invoker,代碼如下:
// 根據總權重計算一個隨機的偏移量
int offset = ThreadLocalRandom.current().nextInt(totalWeight);
// 遍歷所有Invoker,累減,得到被選中的Invoker
for (int i = 0; i < length; i++) {
// 累減的目的是如果輪詢到某一個服務的時候累減完小於0,說明隨機值落在此區間,則返回該服務提供者的Invoker
offset -= weights[i];
if (offset < 0) {
return invokers.get(i);
}
}
RoundRobin 輪詢負載均衡
普通輪詢的好處是每個節點獲得的請求會很均勻,如果某些節點能力明顯較弱,則這個節點會堆積比較多的請求。因此普通輪詢還不能滿足需求,還需要能根據節點權重進行干預。權重輪詢又分爲普通權重輪詢和平滑權重輪詢。普通權重輪詢會造成某個節點會突然被頻繁選中,這樣容易讓一個節點流量暴增。Dubbo的輪詢負載均衡採用的是平滑加權輪詢算法,負載均衡時挑選最大權重返回,並將最大權重數值進行調整(最大權重新值 = 最大權重當前值 - 總權重),然後開啓下一輪,開啓下一輪時,所有權重新值 = 當前值 + 自身基礎權重值,聽上去有點懵,但是列個表就清楚了。
請求次數 | 被選中前Invoker的current值 | 被選中後Invoker的current值 | 被選中的節點 |
---|---|---|---|
1 | {1,6,9} | {1,6,-7} | C |
2 | {2,12,2} | {2,-4,2} | B |
3 | {3,2,11} | {3,2,-5} | C |
4 | {4,8,4} | {4,-8,4} | B |
5 | {5,-2,13} | {5,-2,-3} | C |
6 | {6,4,6} | {-10,4,6} | A |
7 | {-9,10,15} | {-9,10,-1} | C |
8 | {-8,16,8} | {-8,0,8} | B |
9 | {-7,6,17} | {-7,6,1} | C |
10 | {-6,12,10} | {-6,-4,10} | B |
11 | {-5,2,19} | {-5,2,3} | C |
12 | {-4,8,12} | {-4,8,-4} | C |
13 | {-3,14,5} | {-3,-2,5} | B |
14 | {-2,4,14} | {-2,4,-2} | C |
15 | {-1,10,7} | {-1,-6,7} | B |
16 | {0,0,16} | {0,0,0} | C |
跟着表格從上到下捋一遍,自己算一下,思路就很清晰了。從這16次負載均衡來看,我們可以很清楚得知,A剛好被調用1次,B剛好被調用6次,C剛好被調用9次。符合權重輪詢策略,因爲他們的權重比例是1:6:9。此外,C並沒有頻繁地的一直調用,其中會穿插B和A的調用。
LeastActive 最少活躍調用數負載均衡
最少活躍調用數,如果活躍數量相同則隨機調用,活躍數指調用前後的計數差(調用前計數器+1,調用後計數器-1,如果一個服務提供者計數差很大,則說明該服務處理的比較慢,則減少請求量,防止服務請求阻塞,以及服務端崩潰。)。使慢的提供者收到更少的請求,因爲越慢的提供者的調用前後計數差會越大
- 遍歷 invokers 列表,尋找活躍數最小的 Invoker
- 如果有多個 Invoker 具有相同的最小活躍數,此時記錄下這些 Invoker 在 invokers 集合中的下標,並累加它們的權重,比較它們的權重值是否相等
- 如果只有一個 Invoker 具有最小的活躍數,此時直接返回該 Invoker 即可
- 如果有多個 Invoker 具有最小活躍數,且它們的權重不相等,此時處理方式和 RandomLoadBalance 一致
- 如果有多個 Invoker 具有最小活躍數,但它們的權重相等,此時隨機返回一個即可
public class LeastActiveLoadBalance extends AbstractLoadBalance {
public static final String NAME = "leastactive";
private final Random random = new Random();
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
int length = invokers.size();
// 最小的活躍數
int leastActive = -1;
// 具有相同“最小活躍數”的服務者提供者(以下用 Invoker 代稱)數量
int leastCount = 0;
// leastIndexs 用於記錄具有相同“最小活躍數”的 Invoker 在 invokers 列表中的下標信息
int[] leastIndexs = new int[length];
int totalWeight = 0;
// 第一個最小活躍數的 Invoker 權重值,用於與其他具有相同最小活躍數的 Invoker 的權重進行對比,
// 以檢測是否“所有具有相同最小活躍數的 Invoker 的權重”均相等
int firstWeight = 0;
boolean sameWeight = true;
// 遍歷 invokers 列表
for (int i = 0; i < length; i++) {
Invoker<T> invoker = invokers.get(i);
// 獲取 Invoker 對應的活躍數
int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive();
// 獲取權重
int weight = invoker.getUrl().getMethodParameter(invocation.getMethodName(), Constants.WEIGHT_KEY, Constants.DEFAULT_WEIGHT);
// 發現更小的活躍數,重新開始
if (leastActive == -1 || active < leastActive) {
// 使用當前活躍數 active 更新最小活躍數 leastActive
leastActive = active;
// 更新 leastCount 爲 1
leastCount = 1;
// 記錄當前下標值到 leastIndexs 中
leastIndexs[0] = i;
totalWeight = weight;
firstWeight = weight;
sameWeight = true;
// 當前 Invoker 的活躍數 active 與最小活躍數 leastActive 相同
} else if (active == leastActive) {
// 在 leastIndexs 中記錄下當前 Invoker 在 invokers 集合中的下標
leastIndexs[leastCount++] = i;
// 累加權重
totalWeight += weight;
// 檢測當前 Invoker 的權重與 firstWeight 是否相等,
// 不相等則將 sameWeight 置爲 false
if (sameWeight && i > 0
&& weight != firstWeight) {
sameWeight = false;
}
}
}
// 當只有一個 Invoker 具有最小活躍數,此時直接返回該 Invoker 即可
if (leastCount == 1) {
return invokers.get(leastIndexs[0]);
}
// 有多個 Invoker 具有相同的最小活躍數,但它們之間的權重不同
if (!sameWeight && totalWeight > 0) {
// 隨機生成一個 [0, totalWeight) 之間的數字
int offsetWeight = random.nextInt(totalWeight);
// 循環讓隨機數減去具有最小活躍數的 Invoker 的權重值,
// 當 offset 小於等於0時,返回相應的 Invoker
for (int i = 0; i < leastCount; i++) {
int leastIndex = leastIndexs[i];
// 獲取權重值,並讓隨機數減去權重值
offsetWeight -= getWeight(invokers.get(leastIndex), invocation);
if (offsetWeight <= 0)
return invokers.get(leastIndex);
}
}
// 如果權重相同或權重爲0時,隨機返回一個 Invoker
return invokers.get(leastIndexs[random.nextInt(leastCount)]);
}
}
一致性Hash負載均衡
Dubbo一致性Hash負載均衡採用的是一致性Hash算法
doSelect 方法主要做了一些前置工作,比如檢測 invokers 列表是不是變動過,以及創建 ConsistentHashSelector。這些工作做完後,接下來開始調用 ConsistentHashSelector 的 select 方法執行負載均衡邏輯。
// ConsistentHashLoadBalance#doSelect
public class ConsistentHashLoadBalance extends AbstractLoadBalance {
private final ConcurrentMap<String, ConsistentHashSelector<?>> selectors =
new ConcurrentHashMap<String, ConsistentHashSelector<?>>();
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
String methodName = RpcUtils.getMethodName(invocation);
String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;
// 獲取 invokers 原始的 hashcode
int identityHashCode = System.identityHashCode(invokers);
ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);
// 如果 invokers 是一個新的 List 對象,意味着服務提供者數量發生了變化,可能新增也可能減少了。
// 此時 selector.identityHashCode != identityHashCode 條件成立
if (selector == null || selector.identityHashCode != identityHashCode) {
// 創建新的 ConsistentHashSelector
selectors.put(key, new ConsistentHashSelector<T>(invokers, methodName, identityHashCode));
selector = (ConsistentHashSelector<T>) selectors.get(key);
}
// 調用 ConsistentHashSelector 的 select 方法選擇 Invoker
return selector.select(invocation);
}
}
在分析 select 方法之前,先看一下一致性 hash 選擇器 ConsistentHashSelector 的初始化過程
private static final class ConsistentHashSelector<T> {
// 使用 TreeMap 存儲 Invoker 虛擬節點
private final TreeMap<Long, Invoker<T>> virtualInvokers;
private final int replicaNumber;
private final int identityHashCode;
private final int[] argumentIndex;
ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {
this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
this.identityHashCode = identityHashCode;
URL url = invokers.get(0).getUrl();
// 獲取虛擬節點數,默認爲160
this.replicaNumber = url.getMethodParameter(methodName, "hash.nodes", 160);
// 獲取參與 hash 計算的參數下標值,默認對第一個參數進行 hash 運算
String[] index = Constants.COMMA_SPLIT_PATTERN.split(url.getMethodParameter(methodName, "hash.arguments", "0"));
argumentIndex = new int[index.length];
for (int i = 0; i < index.length; i++) {
argumentIndex[i] = Integer.parseInt(index[i]);
}
for (Invoker<T> invoker : invokers) {
String address = invoker.getUrl().getAddress();
for (int i = 0; i < replicaNumber / 4; i++) {
// 對 address + i 進行 md5 運算,得到一個長度爲16的字節數組
byte[] digest = md5(address + i);
// 對 digest 部分字節進行4次 hash 運算,得到四個不同的 long 型正整數
for (int h = 0; h < 4; h++) {
// h = 0 時,取 digest 中下標爲 0 ~ 3 的4個字節進行位運算
// h = 1 時,取 digest 中下標爲 4 ~ 7 的4個字節進行位運算
// h = 2, h = 3 時過程同上
long m = hash(digest, h);
// 將 hash 到 invoker 的映射關係存儲到 virtualInvokers 中,
// virtualInvokers 需要提供高效的查詢操作,因此選用 TreeMap 作爲存儲結構
virtualInvokers.put(m, invoker);
}
}
}
}
}
ConsistentHashSelector 的構造方法執行了一系列的初始化邏輯,比如從配置中獲取虛擬節點數以及參與 hash 計算的參數下標,默認情況下只使用第一個參數進行 hash。需要特別說明的是,ConsistentHashLoadBalance 的負載均衡邏輯只受參數值影響,具有相同參數值的請求將會被分配給同一個服務提供者。ConsistentHashLoadBalance 不關心權重,因此使用時需要注意一下。
在獲取虛擬節點數和參數下標配置後,接下來要做的事情是計算虛擬節點 hash 值,並將虛擬節點存儲到 TreeMap 中。到此,ConsistentHashSelector 初始化工作就完成了。最後處理select邏輯。
select邏輯如下,選擇的過程相對比較簡單了。首先是對參數進行 md5 以及 hash 運算,得到一個 hash 值。然後再拿這個值到 TreeMap 中查找目標 Invoker 即可。
public Invoker<T> select(Invocation invocation) {
// 將參數轉爲 key
String key = toKey(invocation.getArguments());
// 對參數 key 進行 md5 運算
byte[] digest = md5(key);
// 取 digest 數組的前四個字節進行 hash 運算,再將 hash 值傳給 selectForKey 方法,
// 尋找合適的 Invoker
return selectForKey(hash(digest, 0));
}
private Invoker<T> selectForKey(long hash) {
// 到 TreeMap 中查找第一個節點值大於或等於當前 hash 的 Invoker
Map.Entry<Long, Invoker<T>> entry = virtualInvokers.tailMap(hash, true).firstEntry();
// 如果 hash 大於 Invoker 在圓環上最大的位置,此時 entry = null,
// 需要將 TreeMap 的頭節點賦值給 entry
if (entry == null) {
entry = virtualInvokers.firstEntry();
}
// 返回 Invoker
return entry.getValue();
}