Davids原理探究:Dubbo負載均衡實現原理

Dubbo負載均衡實現原理

Dubbo負載均衡概述

Dubbo在使用負載均衡的時候並沒有直接使用LoadBalance,而是使用的抽象父類AbstractClusterInvoker中定義的Invoker<T> select方法。因爲抽象父類在LoadBalance的基礎上有風裝了一些新的特性。

Dubbo負載均衡特性

  1. 粘滯連接。Dubbo中有一種特性叫粘滯連接。

粘滯連接用於有狀態服務,儘可能讓客戶端總是向同一提供者發起調用,除非該提供者“掛了”,再連接另一臺。
粘滯連接將自動開啓延遲連接,以減少長連接數<dubbo:protocol name=“dubbo” sticky=“true”>

  1. 可用檢測。Dubbo調用的URL中,如果含有cluster.availablecheck=false,則不會檢測遠程服務是否可用,直接調用。如果不設置,則會默認開啓檢查,對所有的服務都做是否可用檢查,如果不可用,則再次做負載均衡。
  2. 避免重複調用。對於已經調用過的遠程服務,避免重複選擇,每次都是用同一個節點。這種特性主要是爲了避免併發場景下,某個節點瞬間接收大量請求。

Dubbo負載均衡邏輯過程

  1. 檢查URL中是否有配置粘滯連接,如果有則使用粘滯連接的Invoker。如果沒有配置粘滯連接,或者重複調用檢測不通過、可用檢測不通過,則進入第2步。
  2. 通過ExtensionLoader獲取負載均衡的具體實現,並通過負載均衡做節點的選擇。對選擇出來的節點做重複調用、可用性檢測,通過則直接返回,否則進入第3步。
  3. 進行節點重新選擇。如果需要做可用性檢測會遍歷Directory中得到的所有節點,過濾不可用的和已經調用過的節點,在剩餘的節點中做負載均衡;如果不需要做可用性檢測,那麼也會遍歷Directory中得到的所有節點,但只過濾已經調用過的,在剩餘的節點中重新做負載均衡。這裏存在一種情況,就是在過濾不可用或已經調用過的節點時,節點全部被過濾,沒有剩餘節點,則進入第4步。
  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)

  1. 計算權重並判斷每個Invoker的權重是否一樣。遍歷整個Invoker列表,求和總權重。在遍歷過程中,會對比每個Invoker的權重,判斷所有Invoker的權重是否相同。
  2. 如果權重相同,則說明每個Invoker的概率都一樣,因此直接使用nextInt隨機選一個Invoker返回即可。
  3. 如果權重不同,則首先得到偏移值,然後根據偏移值找到對應的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,如果一個服務提供者計數差很大,則說明該服務處理的比較慢,則減少請求量,防止服務請求阻塞,以及服務端崩潰。)。使慢的提供者收到更少的請求,因爲越慢的提供者的調用前後計數差會越大

  1. 遍歷 invokers 列表,尋找活躍數最小的 Invoker
  2. 如果有多個 Invoker 具有相同的最小活躍數,此時記錄下這些 Invoker 在 invokers 集合中的下標,並累加它們的權重,比較它們的權重值是否相等
  3. 如果只有一個 Invoker 具有最小的活躍數,此時直接返回該 Invoker 即可
  4. 如果有多個 Invoker 具有最小活躍數,且它們的權重不相等,此時處理方式和 RandomLoadBalance 一致
  5. 如果有多個 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();
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章