Dubbo剖析-叢集容錯

weixin_34146805發表於2018-05-24

本篇主要對dubbo叢集容錯進行剖析,主要下面幾個模組

  1. cluster容錯方案
  2. Directory目錄服務
  3. route 路由解析
  4. loadBalance 軟負載均衡

一、呼叫鏈路


3359971-8506d79a3a46fd73.png

二、容錯方案


叢集模式的配置

<dubbo:service cluster="failsafe" /> 服務提供方
<dubbo:reference cluster="failsafe" /> 服務消費方

叢集容錯實現

介面類 com.alibaba.dubbo.rpc.cluster.Cluster


3359971-bff5432ca9761726.png
Cluster實現類

1.AvailableCluster
獲取可用的呼叫。遍歷所有Invokers判斷Invoker.isAvalible,只要一個有為true直接呼叫返回,不管成不成功

2.BroadcastCluster
廣播呼叫。遍歷所有Invokers, 逐個呼叫每個呼叫catch住異常不影響其他invoker呼叫

3.FailbackCluster
失敗自動恢復, 對於invoker呼叫失敗, 後臺記錄失敗請求,任務定時重發, 通常用於通知

//FailbackClusterInvoker
//記錄失敗的呼叫
private final ConcurrentMap<Invocation, AbstractClusterInvoker<?>> failed = new ConcurrentHashMap<Invocation, AbstractClusterInvoker<?>>();

protected Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
        try {
            checkInvokers(invokers, invocation);
            Invoker<T> invoker = select(loadbalance, invocation, invokers, null);
            return invoker.invoke(invocation);
        } catch (Throwable e) {
            //失敗後呼叫 addFailed
            addFailed(invocation, this);
            return new RpcResult(); // ignore
        }
    }

private void addFailed(Invocation invocation, AbstractClusterInvoker<?> router) {
    if (retryFuture == null) {
        synchronized (this) {
            if (retryFuture == null) {
                retryFuture = scheduledExecutorService.scheduleWithFixedDelay(new Runnable() {

                    public void run() {
                        // 收集統計資訊
                        try {
                            retryFailed();
                        } catch (Throwable t) { // 防禦性容錯
                            logger.error("Unexpected error occur at collect statistic", t);
                        }
                    }
                }, RETRY_FAILED_PERIOD, RETRY_FAILED_PERIOD, TimeUnit.MILLISECONDS);
            }
        }
    }
    failed.put(invocation, router);
}

//失敗的進行重試,重試成功後移除當前map
void retryFailed() {
        if (failed.size() == 0) {
            return;
        }
        for (Map.Entry<Invocation, AbstractClusterInvoker<?>> entry : new HashMap<Invocation, AbstractClusterInvoker<?>>(
                failed).entrySet()) {
            Invocation invocation = entry.getKey();
            Invoker<?> invoker = entry.getValue();
            try {
                invoker.invoke(invocation);
                failed.remove(invocation);
            } catch (Throwable e) {
                logger.error("Failed retry to invoke method " + invocation.getMethodName() + ", waiting again.", e);
            }
        }
    }

4.FailfastCluster
快速失敗,只發起一次呼叫,失敗立即保錯,通常用於非冪等性操作

5.FailoverCluster default
失敗轉移,當出現失敗,重試其它伺服器,通常用於讀操作,但重試會帶來更長延遲
(1) 目錄服務directory.list(invocation) 列出方法的所有可呼叫服務
獲取重試次數,預設重試兩次

int len = getUrl().getMethodParameter(invocation.getMethodName(), Constants.RETRIES_KEY, Constants.DEFAULT_RETRIES) + 1;

(2) 根據LoadBalance負載策略選擇一個Invoker
(3) 執行invoker.invoke(invocation)呼叫
(4) 呼叫成功返回
呼叫失敗小於重試次數,重新執行從3)步驟開始執行,呼叫次數大於等於重試次數丟擲呼叫失敗異常

6.FailsafeCluster
失敗安全,出現異常時,直接忽略,通常用於寫入審計日誌等操作。

7.ForkingCluster
並行呼叫,只要一個成功即返回,通常用於實時性要求較高的操作,但需要浪費更多服務資源。

注:
還有 MergeableCluster 和 MockClusterWrapper策略,但是個人沒有用過所以就不說了

三、Directory目錄服務


3359971-71143365e99737cb.png

1. StaticDirectory

靜態目錄服務, 它的所有Invoker通過建構函式傳入, 服務消費方引用服務的時候, 服務對多註冊中心的引用,將Invokers集合直接傳入 StaticDirectory構造器

public StaticDirectory(URL url, List<Invoker<T>> invokers, List<Router> routers) {
    super(url == null && invokers != null && invokers.size() > 0 ? invokers.get(0).getUrl() : url, routers);
    if (invokers == null || invokers.size() == 0)
        throw new IllegalArgumentException("invokers == null");
    this.invokers = invokers;
}

StaticDirectory的list方法直接返回所有invoker集合

@Override
protected List<Invoker<T>> doList(Invocation invocation) throws RpcException {
    return invokers;
}

2. RegistryDirectory

註冊目錄服務, 它的Invoker集合是從註冊中心獲取的, 它實現了NotifyListener介面實現了回撥介面notify(List<Url>)。

比如消費方要呼叫某遠端服務,會向註冊中心訂閱這個服務的所有服務提供方,訂閱時和服務提供方資料有變動時回撥消費方的NotifyListener服務的notify方法NotifyListener.notify(List<Url>) 回撥介面傳入所有服務的提供方的url地址然後將urls轉化為invokers, 也就是refer應用遠端服務到此時引用某個遠端服務的RegistryDirectory中有對這個遠端服務呼叫的所有invokers。

RegistryDirectory.list(invocation)就是根據服務呼叫方法獲取所有的遠端服務引用的invoker執行物件

四、服務路由


dubbo路由功能貌似用的不多,目的主要是對已註冊的服務進行過濾,比如只能呼叫某些配置的服務,或者禁用某些服務。

1. ConditionRouter條件路由

dubbo-admin 後臺進行配置。


3359971-e76d91da6cdd3fb7.png

路由程式碼入口

public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url, Invocation invocation)
            throws RpcException {
    if (invokers == null || invokers.size() == 0) {
        return invokers;
    }
    try {
        if (!matchWhen(url, invocation)) {
            return invokers;
        }
        List<Invoker<T>> result = new ArrayList<Invoker<T>>();
        if (thenCondition == null) {
            logger.warn("The current consumer in the service blacklist. consumer: " + NetUtils.getLocalHost() + ", service: " + url.getServiceKey());
            return result;
        }
    .............................

2. ScriptRouter指令碼路由

按照dubbo指令碼規則進行編寫,程式識別

五、軟負載均衡


3359971-84d21c107272d094.png

1. RandomLoadBalance default

隨機,按權重設定隨機概率。權重default=100
在一個截面上碰撞的概率高,但呼叫量越大分佈越均勻,而且按概率使用權重後也比較均勻,有利於動態調整提供者權重。

    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        int length = invokers.size(); // 總個數
        int totalWeight = 0; // 總權重
        boolean sameWeight = true; // 權重是否都一樣
        for (int i = 0; i < length; i++) {
            int weight = getWeight(invokers.get(i), invocation);
            totalWeight += weight; // 累計總權重
            if (sameWeight && i > 0
                    && weight != getWeight(invokers.get(i - 1), invocation)) {
                sameWeight = false; // 計算所有權重是否一樣
            }
        }
        if (totalWeight > 0 && !sameWeight) {
            // 如果權重不相同且權重大於0則按總權重數隨機
            int offset = random.nextInt(totalWeight);
            // 並確定隨機值落在哪個片斷上
            for (int i = 0; i < length; i++) {
                offset -= getWeight(invokers.get(i), invocation);
                if (offset < 0) {
                    return invokers.get(i);
                }
            }
        }
        // 如果權重相同或權重為0則均等隨機
        return invokers.get(random.nextInt(length));
    }

演算法含義
如果所有的服務權重都一樣,就採用總服務數進行隨機。如果權重不一樣,則按照權重出隨機數,然後用隨機數減去服務權重,結果為負數則使用當前迴圈的服務。其實也就是一個概率性問題 每個服務的概率就是 當前服務的權重/ 總服務權重

2. RoundRobinLoadBalance

輪循,按公約後的權重設定輪循比率。
存在慢的提供者累積請求的問題,比如:第二臺機器很慢,但沒掛,當請求調到第二臺時就卡在那,久而久之,所有請求都卡在調到第二臺上。

該負載演算法維護著一個方法呼叫順序計數

private final ConcurrentMap<String, AtomicPositiveInteger> sequences = new ConcurrentHashMap<String, AtomicPositiveInteger>();

以方法名作為key

輪循分為 普通輪詢和加權輪詢。權重一樣時,採用取模運算普通輪詢,反之加權輪詢。

下面看下具體的實現
RoundRobinLoadBalance#doSelect

i.普通輪詢

AtomicPositiveInteger sequence = sequences.get(key);
if (sequence == null) {
    sequences.putIfAbsent(key, new AtomicPositiveInteger());
    sequence = sequences.get(key);
}
//獲取本次呼叫的伺服器序號,並+1
int currentSequence = sequence.getAndIncrement();

//當前序號和服務總數取模
return invokers.get(currentSequence % length);

ii.加權輪詢
下面貼下核心實現程式碼。注意幾個變數

weightSum = 服務權重之和
invokerToWeightMap = 權重>0的 invoker map

int currentSequence = sequence.getAndIncrement();
if (maxWeight > 0 && minWeight < maxWeight) { // 權重不一樣

    // mod < weightSum,下面for迴圈進行weight遞減,weight大的服務被呼叫的概率大
    int mod = currentSequence % weightSum;
    for (int i = 0; i < maxWeight; i++) {
        for (Map.Entry<Invoker<T>, IntegerWrapper> each : invokerToWeightMap.entrySet()) {
            final Invoker<T> k = each.getKey();
            final IntegerWrapper v = each.getValue();
            if (mod == 0 && v.getValue() > 0) {
                return k;
            }
            if (v.getValue() > 0) {
                v.decrement();
                mod--;
            }
        }
    }
}

可以舉個例子
兩個服務 A 和 B,權重分別是1和2
那麼 mod=[0,1,2],經過上面的邏輯,呼叫概率是 A B B A B B A B B ..... 顯然B的概率更大一些

3. LeastActiveLoadBalance

最少活躍呼叫數優先,活躍數指呼叫前後計數差。使慢的提供者收到更少請求,因為越慢的提供者的呼叫前後計數差會越大。

每個服務有一個活躍計數器,我們假如有A,B兩個提供者.計數均為0.當A提供者開始處理請求,該計數+1,此時A還沒處理完,當處理完後則計數-1.而B請求接收到請求處理得很快.B處理完後A還沒處理完,所以此時A,B的計數為1,0.那麼當有新的請求來的時候,就會選擇B提供者(B的活躍計數比A小).這就是文件說的,使慢的提供者收到更少請求。

int leastCount = 0; // 相同最小活躍數的個數
int[] leastIndexs = new int[length]; // 相同最小活躍數的下標

i.最小活躍服務個數=1, 該服務優先

if (leastCount == 1) {
    // 如果只有一個最小則直接返回
    return invokers.get(leastIndexs[0]);
}

ii.最小活躍服務個數>1, 最小活躍的服務按照權重隨機

if (!sameWeight && totalWeight > 0) {
    // 如果權重不相同且權重大於0則按總權重數隨機
    int offsetWeight = random.nextInt(totalWeight);
    // 並確定隨機值落在哪個片斷上
    for (int i = 0; i < leastCount; i++) {
        int leastIndex = leastIndexs[i];
        //權重越大,offsetWeight越快減成負數
        offsetWeight -= getWeight(invokers.get(leastIndex), invocation);
        if (offsetWeight <= 0)
            return invokers.get(leastIndex);
    }
}

iii. 最小活躍服務個數>1, 權重相同,服務個數隨機

// 如果權重相同或權重為0則均等隨機
return invokers.get(leastIndexs[random.nextInt(leastCount)]);

4. ConsistentHashLoadBalance

  • 一致性 Hash,相同引數的請求總是發到同一提供者。
  • 當某一臺提供者掛時,原本發往該提供者的請求,基於虛擬節點,平攤到其它提供者,不會引起劇烈變動。
  • 演算法參見:http://en.wikipedia.org/wiki/Consistent_hashing
  • 預設只對第一個引數 Hash,如果要修改,請配置 <dubbo:parameter key="hash.arguments" value="0,1" />
  • 預設用 160 份虛擬節點,如果要修改,請配置 <dubbo:parameter key="hash.nodes" value="320" />
配置樣例
<dubbo:reference id="demoService" interface="com.youzan.dubbo.api.DemoService" loadbalance="consistenthash">
    <!--預設只對第一個引數 Hash-->
    <dubbo:parameter key="hash.arguments" value="0,1" />
    <!--預設用 160 份虛擬節點,-->
    <dubbo:parameter key="hash.nodes" value="160" />
</dubbo:reference>
演算法解析

ConsistentHashLoadBalance為使用該演算法的服務維護了一個selectors,

key=invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName()
eg: com.youzan.dubbo.api.DemoService.sayHello

#com.alibaba.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance

private final ConcurrentMap<String, ConsistentHashSelector<?>> selectors = new ConcurrentHashMap<String, ConsistentHashSelector<?>>();

@SuppressWarnings("unchecked")
@Override
protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
    String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
    int identityHashCode = System.identityHashCode(invokers);

    //獲取該服務的ConsistentHashSelector,並跟進本次呼叫獲取對應invoker
    ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);
    if (selector == null || selector.getIdentityHashCode() != identityHashCode) {
        selectors.put(key, new ConsistentHashSelector<T>(invokers, invocation.getMethodName(), identityHashCode));
        selector = (ConsistentHashSelector<T>) selectors.get(key);
    }
    return selector.select(invocation);
}

ConsistentHashSelector作為ConsistentHashLoadBalance的內部類, 就是具體的一致性hash實現。

  • ConsistentHashSelector內部元素
#com.alibaba.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance.ConsistentHashSelector

//該服務的所有hash節點
private final TreeMap<Long, Invoker<T>> virtualInvokers;
//虛擬節點數量
private final int replicaNumber;
//該服務的唯一hashcode,通過System.identityHashCode(invokers)獲取
private final int identityHashCode;
  • 如何構建該服務的虛擬節點?
public ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {
    // 建立TreeMap 來儲存結點
    this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
    // 生成呼叫結點HashCode
    this.identityHashCode = System.identityHashCode(invokers);
    // 獲取Url 
    //dubbo://192.168.0.4:20880/com.youzan.dubbo.api.DemoService?anyhost=true&application=consumer-of-helloworld-app&check=false&class=com.youzan.dubbo.provider.DemoServiceImpl&dubbo=2.5.4&generic=false&hash.arguments=0,1&hash.nodes=160&interface=com.youzan.dubbo.api.DemoService&loadbalance=consistenthash&methods=sayHello&pid=32710&side=consumer&timestamp=1527383363936
    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]);
    }
    // 建立虛擬結點
    // 對每個invoker生成replicaNumber個虛擬結點,並存放於TreeMap中
    for (Invoker<T> invoker : invokers) {
        for (int i = 0; i < replicaNumber / 4; i++) {
            // 根據md5演算法為每4個結點生成一個訊息摘要,摘要長為16位元組128位。
            byte[] digest = md5(invoker.getUrl().toFullString() + i);
            // 隨後將128位分為4部分,0-31,32-63,64-95,95-128,並生成4個32位數,存於long中,long的高32位都為0
            // 並作為虛擬結點的key。
            for (int h = 0; h < 4; h++) {
                long m = hash(digest, h);
                virtualInvokers.put(m, invoker);
            }
        }
    }
}

程式碼如果看的不是很懂,也不用去深究了(我就沒看懂,瞻仰了網上大神的文章貼了帖註釋),大家可以就粗略的認為,這段程式碼就是儘可能的構建出雜湊均勻的服務hash表。

  • 如何從virtualInvokers選取本次呼叫的invoker?
// 選擇invoker
public Invoker<T> select(Invocation invocation) {
    // 根據呼叫引數來生成Key
    String key = toKey(invocation.getArguments());
    // 根據這個引數生成訊息摘要
    byte[] digest = md5(key);
    //呼叫hash(digest, 0),將訊息摘要轉換為hashCode,這裡僅取0-31位來生成HashCode
    //呼叫sekectForKey方法選擇結點。
    Invoker<T> invoker = sekectForKey(hash(digest, 0));
    return invoker;
}

private String toKey(Object[] args) {
    StringBuilder buf = new StringBuilder();
    // 由於hash.arguments沒有進行配置,因為只取方法的第1個引數作為key
    for (int i : argumentIndex) {
        if (i >= 0 && i < args.length) {
            buf.append(args[i]);
        }
    }
    return buf.toString();
}

//根據hashCode選擇結點
private Invoker<T> sekectForKey(long hash) {
    Invoker<T> invoker;
    Long key = hash;
    // 若HashCode直接與某個虛擬結點的key一樣,則直接返回該結點
    if (!virtualInvokers.containsKey(key)) {
        // 若不一致,找到一個比傳入的key大的第一個結點。
        SortedMap<Long, Invoker<T>> tailMap = virtualInvokers.tailMap(key);
        // 若不存在,那麼選擇treeMap中第一個結點
        // 使用TreeMap的firstKey方法,來選擇最小上界。
        if (tailMap.isEmpty()) {
            key = virtualInvokers.firstKey();
        } else {
           // 若存在則返回
            key = tailMap.firstKey();
        }
    }
    invoker = virtualInvokers.get(key);
    return invoker;
}
  • 一致性hash環是什麼東東?和上面的演算法什麼關係?
    ConsistentHashSelector.virtualInvokers這個東西就是我們的服務hash節點,單純的從資料結構上的確看不到什麼環狀的存在,可以先示意下,當前的資料結構
    3359971-d8ec14cb72b4bcd5.png
    selector結構
3359971-b01256763f066765.png
virtualInvokers

我們的服務節點只是一個普通的 map資料儲存而已,如何形成環呢?其實所謂的環只是邏輯上的展現,ConsistentHashSelector.sekectForKey()方法裡通過 TreeMap.tailMap()、TreeMap.tailMap().firstKey、TreeMap.tailMap().firstKey() 結合case實現了環狀邏輯。下面我們畫圖說話。

第一步原始資料結構,我們按照hash從小到大排列

3359971-48cd50058e53c4bf.png

A,B,C表示我們提供的服務,改示意圖假設服務節點雜湊均勻

第二步選擇服務節點

i. 假設本地呼叫得到的key=2120, 程式碼邏輯(指ConsistentHashSelector.sekectForKey)走到tailMap.firstKey()

3359971-44fa051917d6d34e.png

那麼讀取到 3986 A服務

ii.假設本地呼叫得到的key=9991, tailMap為空,邏輯走到 virtualInvokers.firstKey() 回到起點

3359971-977888a5f8096544.png

讀取到 1579 A服務

上述兩部情況基本已經能夠描述清楚節點的選擇邏輯,至於hash直接命中,那麼讀取對應的服務即可,無需多講。

最後環狀形成
上面兩部的介紹已經描述hash演算法,那麼我們所謂的環狀是怎麼一回事呢?其實也就是為了方便更好的理解這個邏輯,我們將線性的hash排列作為環狀,然後hash的選擇按照順時針方向選擇節點(等價於上面hash比較大小)

3359971-9e0476479ec37644.png
環狀示意

節點選擇演算法與上面等價,本圖主要用來示意,理想的hash環hash差距應該是等差,均勻的排列。

參考:
https://blog.csdn.net/column/details/learningdubbo.html?&page=1
https://blog.csdn.net/revivedsun/article/details/71022871
https://www.jianshu.com/p/53feb7f5f5d9

相關文章