[享學Netflix] 四十七、Ribbon多區域選擇:ZoneAvoidanceRule.getAvailableZones()獲取可用區

質量、速度、廉價,只能選擇其中兩個。

–> 返回專欄總目錄 <–
代碼下載地址:https://github.com/f641385712/netflix-learning

前言

隨着微服務、雲源生的流行,多雲、多區域(zone)、跨機房部署的case越來越多。Ribbon作爲微服務領域的優秀組件,自然也提供了對多區域支持的負載均衡能力。

作爲基礎,本文將介紹多zone負載均衡中最爲重要的一個方法:ZoneAvoidanceRule.getAvailableZones(),它解決了根據LoadBalancerStats狀態信息仲裁出可用區出來。


正文

關於getAvailableZones方法,其實有兩處地方都叫這個名字,但是它們的功能是不一樣的,且存在依賴的關係,爲了避免讀者迷糊,現分別進行闡述。


LoadBalancerStats#getAvailableZones實例方法

它是LoadBalancerStats裏的一個實例方法:

LoadBalancerStats:

	volatile Map<String, List<? extends Server>> upServerListZoneMap = new ConcurrentHashMap<>();
    public Set<String> getAvailableZones() {
        return upServerListZoneMap.keySet();
    }

若有下面邏輯的存在,其實我覺得該方法的命令是頗具歧義的,或許叫getAllAvailableZones()會更合適一些。因爲它僅是一個普通的獲取方法,並不考慮對應zone內Server的負載情況、可用情況,這些都交給下面這個工具方法進行完成。


ZoneAvoidanceRule靜態工具方法

首先我吐槽一下:作爲static工具方法,爲毛放在ZoneAvoidanceRule裏呢?統一放在LoadBalancerStats裏內聚起來不香嗎?


randomChooseZone()

在開始之前,我們先了解下這個選擇支持方法,但是它是非public。調用方僅有兩處:

  • 下面的getAvailableZones()方法
  • ZoneAwareLoadBalancer#chooseServer()方法(後文重點闡述,非常重要)
ZoneAvoidanceRule:

    static String randomChooseZone(
    		Map<String, ZoneSnapshot> snapshot,
            Set<String> chooseFrom) {
        if (chooseFrom == null || chooseFrom.size() == 0) {
            return null;
        }
		// 注意:默認選擇的是第一個zone區域
		// 若總共就1個區域,那就是它了。若有多個,那就需要隨機去選
		String selectedZone = chooseFrom.iterator().next();
        if (chooseFrom.size() == 1) {
        	return chooseFrom.iterator().next();
        }

		// 所有的區域中總的Server實例數
        int totalServerCount = 0;
        for (String zone : chooseFrom) {
            totalServerCount += snapshot.get(zone).getInstanceCount();
        }
        // 從所有的實例總數中隨機選個數字。比如總數是10臺機器
        // 那就是從[1-10]之間隨機選個數字,比如選中爲6
        int index = random.nextInt(totalServerCount) + 1;
		
		// sum代表當前實例統計的總數
		// 它的邏輯是:當sum超過這個index時,就以這個區域爲準
        int sum = 0;
        for (String zone : chooseFrom) {
            sum += snapshot.get(zone).getInstanceCount();
            if (index <= sum) {
                selectedZone = zone;
                break;
            }
        }
	
	}

這個隨機算法最核心的就是最後面的indexsum算法,看完後你應該有如下疑問:爲何不來個從chooseFrom這個集合裏隨機彈出一個zone就成,而非弄的這麼麻煩呢?

其實這麼做的是很有意義的,這麼做能保證:zone裏面機器數越多的話,被選中的概率是越大的,這樣隨機纔是最合理的


getAvailableZones()

該方法是一個靜態工具方法,顧名思義它用於獲取真實的可用區,它在LoadBalancerStats#getAvailableZones方法的基礎上,結合每個zone對應的ZoneSnapshot的情況再結合閾值設置,篩選真正可用的zone區域。

ZoneAvoidanceRule:

	// snapshot:zone對應的ZoneSnapshot的一個map
	// triggeringLoad:
	// triggeringBlackoutPercentage:
	public static Set<String> getAvailableZones(
			Map<String, ZoneSnapshot> snapshot,
			double triggeringLoad,
			double triggeringBlackoutPercentage) {
		// 爲毛一個都木有不返回空集合???有點亂啊。。。。不過沒關係
        if (snapshot.isEmpty()) {
            return null;
        }
		
		//最終需要return的可用區,中途會進行排除的邏輯
		Set<String> availableZones = new HashSet<>(snapshot.keySet());
		// 如果有且僅有一個zone可用,再糟糕也得用,不用進行其他邏輯了
        if (availableZones.size() == 1) {
            return availableZones;
        }
		
		// 記錄很糟糕
		Set<String> worstZones = new HashSet<>();
		// 所有zone中,平均負載最高值
		double maxLoadPerServer = 0;
		// true:zone有限可用
		// false:zone全部可用
		boolean limitedZoneAvailability = false;

		// 對每個zone的情況逐一分析
		for (Map.Entry<String, ZoneSnapshot> zoneEntry : snapshot.entrySet()) {
            String zone = zoneEntry.getKey();
            ZoneSnapshot zoneSnapshot = zoneEntry.getValue();
            int instanceCount = zoneSnapshot.getInstanceCount();
            
            // 若該zone內一個實例都木有了,那就是完全不可用,那就移除該zone
            // 然後標記zone是有限可用的(並非全部可用嘍)
            if (instanceCount == 0) {
                availableZones.remove(zone);
                limitedZoneAvailability = true;
            } else {
            	// 該zone的平均負載
				double loadPerServer = zoneSnapshot.getLoadPerServer();
				
				// 機器的熔斷總數 / 總實例數已經超過了閾值(默認爲1,也就是全部熔斷纔會認爲該zone完全不可用)
				// 或者 loadPerServer < 0 (啥時候小於0???下面說)
                if (((double) zoneSnapshot.getCircuitTrippedCount()) / instanceCount >= triggeringBlackoutPercentage
                        || loadPerServer < 0) {
					// 證明這個zone完全不可用,就移除掉
                    availableZones.remove(zone);
                    limitedZoneAvailability = true;
				} else { // 並不是完全不可用,就看看狀態是不是很糟糕

					// 若當前負載和最大負載相當,那認爲已經很糟糕了
					if (Math.abs(loadPerServer - maxLoadPerServer) < 0.000001d) {
						worstZones.add(zone);
					
					// 或者若當前負載大於最大負載了
					} else if (loadPerServer > maxLoadPerServer) {
						maxLoadPerServer = loadPerServer;
						worstZones.clear();
						worstZones.add(zone);
					}
				}
            }
		}

		// 若最大負載小於設定的負載閾值 並且limitedZoneAvailability=false
		// 就是說全部zone都可用,並且最大負載都還沒有達到閾值,那就把全部zone返回
        if (maxLoadPerServer < triggeringLoad && !limitedZoneAvailability) {
            // zone override is not needed here
            return availableZones;
        }
        String zoneToAvoid = randomChooseZone(snapshot, worstZones);
        if (zoneToAvoid != null) {
            availableZones.remove(zoneToAvoid);
        }
        return availableZones;
	}

這個選擇可用區的步驟還是比較重要的,畢竟現在多區域部署、多雲部署都比價常見,現在對它的處理過程做如下文字總結:

  1. 若zone爲null,返回null。若只有一個zone,就返回當前zone,不用再繼續判斷。否則默認返回所有zone:availableZones。接下來會一步步做remove()移除動作
  2. 使用變量Set<String> worstZones記錄所有zone中比較糟糕的zone們;用maxLoadPerServer表示所有zone中負載最高的區域;用limitedZoneAvailability表示是否是部分zone可用(true:部分可用,false:全部可用)
  3. 遍歷所有的zone,根據其對應的快照ZoneSnapshot來判斷負載情況
  4. 若當前zone的instanceCount也就是實例總數是0,那就remove(當前zone),並且標記limitedZoneAvailability=true(因爲移除了一個,就不是全部了嘛)。若當前zone的實例數>0,那就繼續
  5. 拿到當前總的平均負載loadPerServer,如果zone內的熔斷實例數 / 總實例數 >= triggeringBlackoutPercentage閾值 或者 loadPerServer < 0的話,那就執行remove(當前zone),並且limitedZoneAvailability=true
    1. 熔斷實例數 / 總實例數 >= 閾值標記爲當前zone就不可用了(移除掉),這個很好理解。這個閾值爲0.99999d也就說所有的Server實例被熔斷了,該zone纔算不可用了
    2. loadPerServer < 0是什麼鬼?那麼什麼時候loadPerServer會是負數呢?它在LoadBalancerStats#getZoneSnapshot()方法裏:if (circuitBreakerTrippedCount == instanceCount)的時候,loadPerServer = -1,也就說當所有實例都熔斷了,那麼loadPerServer也無意義了嘛,所以賦值爲-1。
    3. 總的來說1和2觸達條件差不多,只是1的閾值是可以配置的,比如你配置爲0.9那就是隻有當90%機器都熔斷了就認爲該zone不可用了,而不用100%(請原諒我把0.99999d當1來看待)
  6. 經過以上步驟,說明所有的zone是基本可用的,但可能有些負載高有些負載低,因此接下來需要判斷區域負載情況,就是如下這段代碼。這段代碼的總體意思是:從所有zone中找出負載最高的區域們(若負載差在0.000001d只能被認爲是相同負載,都認爲是負載最高的們)。
    1. 說明:worstZones裏面裝載着負載最高的zone們,也就是top1(當然可能多個並列第一的情況)
	if (Math.abs(loadPerServer - maxLoadPerServer) < 0.000001d) {
	    // they are the same considering double calculation
	    // round error
	    worstZones.add(zone);
	} else if (loadPerServer > maxLoadPerServer) {
	    maxLoadPerServer = loadPerServer;
	    worstZones.clear();
	    worstZones.add(zone);
	}
  1. 分析好數據後,最後準備返回結果。若統計完所有的區域後,最高負載maxLoadPerServer仍舊小於提供的triggeringLoad閾值,並且並且limitedZoneAvailability=false(就是說所有zone都可用的情況下),那就返回所有的zone吧:availableZones
    1. 這個很好理解:所有的兄弟們負載都很低,並且一個哥們都沒“死”,那就都返回出去唄
    2. triggeringLoad閾值的默認值是0.2,負載的計算方式是:loadPerServer = 整個zone的活躍請求總數 / 整個zone內可用實例總數
      1. 注意:一定是活躍連接數。也就是說正在處理中的鏈接數纔算做服務壓力嘛
  2. 若最大負載超過閾值(或者死了一個/N個兄弟),那麼就不能返回全部拉。那就從負載最高的兄弟們中(因爲可能多個,可能1個,大概率是隻有1個值的)隨機選擇一個出來:randomChooseZone(snapshot, worstZones),然後執行移除remove(zoneToAvoid)掉,這麼處理的目的是把負載最高的那個哥們T除掉,再返回結果。
    1. 說明:這裏使用的隨機算法就是上面所講述的(誰的zone裏面實例數最多,就越可能被選中)

總而言之:選擇可用區的原則是T除掉不可用的、T掉負載最高的區域,其它區域返回結果,這樣處理後返回的結果纔是健康程度綜合最好的。

另外,該方法還有個重載的,便捷使用方法:

ZoneAvoidanceRule:

	// 實際調用仍舊爲getAvailableZones方法~
	// 它友好的只需要傳參LoadBalancerStats即可,內部幫你構建snapshot這個Map
    public static Set<String> getAvailableZones(LoadBalancerStats lbStats,
            double triggeringLoad, double triggeringBlackoutPercentage) {
        if (lbStats == null) {
            return null;
        }
        Map<String, ZoneSnapshot> snapshot = createSnapshot(lbStats);
        return getAvailableZones(snapshot, triggeringLoad,
                triggeringBlackoutPercentage);
    }

該方法使用處

getAvailableZones()方法的調用處主要有兩個地方:

  • ZoneAvoidancePredicate#apply():用於過濾掉哪些超過閾值的、不可用的zone區域們
  • ZoneAwareLoadBalancer#chooseServer():通過此方法拿到可用區域們availableZones,然後再通過randomChooseZone()方法從中隨機選取一個出來,再從zone裏選擇一臺Server就是最佳的Server

不合理的默認值

可以先看下面代碼示例。計算可用區的兩個閾值是:

  • triggeringLoad:平均負載閾值。該閾值可配置
    • ZoneAvoidancePredicate:默認值均爲0.2d
      • 默認key:ZoneAwareNIWSDiscoveryLoadBalancer.triggeringLoadPerServerThreshold
      • 個性化key:"ZoneAwareNIWSDiscoveryLoadBalancer." + clientConfig.getClientName() + ".triggeringLoadPerServerThreshold"
    • ZoneAwareLoadBalancer:默認值亦爲0.2
      • 配置同上
  • triggeringBlackoutPercentage:觸發“熄滅”的百分比閾值(簡單的說當你的實例掛了%多少時,就移除掉此區域)。

triggeringBlackoutPercentage這個閾值尚且合理(默認所有實例掛了纔會移除這個zone),但是triggeringLoad這個閾值僅設置爲0.2,what a fuck???也就說一個zone裏面有10臺機器的話,超過2個請求打進來就算負載過重,從而最終結果會移除掉一個負載最高的可用區,這麼設定腦子不是怕陪驢砸了吧?

這麼配置有何後果?
0.2的閾值等於所有zone都處於過載狀態,因此選擇可用區的時候永遠會T除掉一個(當然你只有一個可用區除外),假如你總共只有2個可用區,這將使得負載均衡策略完全失效~~~~

說明:我強烈懷疑老外是想表達負載超過20%了就算負載過重了,只是它沒考慮到ZoneSnapshot.loadPerServer它並不是一個百分比值~~~

在實際生產中:我個人強烈建議你增加默認配置ZoneAwareNIWSDiscoveryLoadBalancer.triggeringLoadPerServerThreshold = 100。表示單臺機器超過100個併發後認爲負載過高了(當然100這個數值你可以根據機器配置具體設定,此處僅供參考),這樣能極大的提高zone之間的負載均衡能力

說明:這一切都建立在你的應用部署在多zone的情況下,若你僅有一個zone,那麼請忽略本文內容~


代碼示例

// 單獨線程模擬刷頁面,獲取監控到的數據
private void monitor(LoadBalancerStats lbs) {
    List<String> zones = Arrays.asList("華南", "華東", "華北");
    new Thread(() -> {
        ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
        executorService.scheduleWithFixedDelay(() -> {
            // 打印當前可用區
            // 獲取可用區
            Set<String> availableZones = ZoneAvoidanceRule.getAvailableZones(lbs, 0.2d, 0.99999d);
            System.out.println("=====當前可用區爲:" + availableZones);

            zones.forEach(zone -> {
                System.out.printf("區域[" + zone + "]概要:");
                int instanceCount = lbs.getInstanceCount(zone);
                int activeRequestsCount = lbs.getActiveRequestsCount(zone);
                double activeRequestsPerServer = lbs.getActiveRequestsPerServer(zone);
                // ZoneSnapshot zoneSnapshot = lbs.getZoneSnapshot(zone);

                System.out.printf("實例總數:%s,活躍請求總數:%s,平均負載:%s\n", instanceCount, activeRequestsCount, activeRequestsPerServer);
                // System.out.println(zoneSnapshot);
            });
            System.out.println("======================================================");
        }, 5, 5, TimeUnit.SECONDS);
    }).start();
}


// 請注意:請必須保證Server的id不一樣,否則放不進去List的(因爲Server的equals hashCode方法僅和id有關)
// 所以此處使用index作爲port,以示區分
private Server createServer(String zone, int index) {
    Server server = new Server("www.baidu" + zone + ".com", index);
    server.setZone(zone);
    return server;
}


// 多線程,模擬請求
private void request(ServerStats serverStats) {
    new Thread(() -> {
        // 每10ms發送一個請求(每個請求處理10-200ms的時間),持續不斷
        ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
        executorService.scheduleWithFixedDelay(() -> {
            new Thread(() -> {
                // 請求之前 記錄活躍請求數
                serverStats.incrementActiveRequestsCount();
                serverStats.incrementNumRequests();
                long rt = doSomething();
                // 請求結束, 記錄響應耗時
                serverStats.noteResponseTime(rt);
                serverStats.decrementActiveRequestsCount();
            }).start();
        }, 10, 10, TimeUnit.MILLISECONDS);
    }).start();
}

// 模擬請求耗時,返回耗時時間
private long doSomething() {
    try {
        int rt = randomValue(10, 200);
        TimeUnit.MILLISECONDS.sleep(rt);
        return rt;
    } catch (InterruptedException e) {
        e.printStackTrace();
        return 0L;
    }
}

// 本地使用隨機數模擬數據收集
private int randomValue(int min, int max) {
    return min + (int) (Math.random() * ((max - min) + 1));
}

// ============單元測試
@Test
public void fun6() throws InterruptedException {
    LoadBalancerStats lbs = new LoadBalancerStats("YoutBatman");

    // 添加Server
    List<Server> serverList = new ArrayList<>();
    serverList.add(createServer("華南", 1));
    serverList.add(createServer("華東", 1));
    serverList.add(createServer("華東", 2));

    serverList.add(createServer("華北", 1));
    serverList.add(createServer("華北", 2));
    serverList.add(createServer("華北", 3));
    serverList.add(createServer("華北", 4));
    lbs.updateServerList(serverList);

    Map<String, List<Server>> zoneServerMap = new HashMap<>();
    // 模擬向每個Server發送請求  記錄ServerStatus數據
    serverList.forEach(server -> {
        ServerStats serverStat = lbs.getSingleServerStat(server);
        request(serverStat);

        // 順便按照zone分組
        String zone = server.getZone();
        if (zoneServerMap.containsKey(zone)) {
            zoneServerMap.get(zone).add(server);
        } else {
            List<Server> servers = new ArrayList<>();
            servers.add(server);
            zoneServerMap.put(zone, servers);
        }
    });
    lbs.updateZoneServerMapping(zoneServerMap);

    // 從lbs裏拿到一些監控數據
    monitor(lbs);

    TimeUnit.SECONDS.sleep(500);
}

運行程序,打印:

=====當前可用區爲:[華南, 華東]
區域[華南]概要:實例總數:1,活躍請求總數:10,平均負載:10.0
區域[華東]概要:實例總數:2,活躍請求總數:18,平均負載:9.0
區域[華北]概要:實例總數:4,活躍請求總數:41,平均負載:10.25
======================================================
=====當前可用區爲:[華南, 華北]
區域[華南]概要:實例總數:1,活躍請求總數:9,平均負載:9.0
區域[華東]概要:實例總數:2,活躍請求總數:22,平均負載:11.0
區域[華北]概要:實例總數:4,活躍請求總數:34,平均負載:8.5
======================================================
=====當前可用區爲:[華南, 華東]
區域[華南]概要:實例總數:1,活躍請求總數:9,平均負載:9.0
區域[華東]概要:實例總數:2,活躍請求總數:18,平均負載:9.0
區域[華北]概要:實例總數:4,活躍請求總數:37,平均負載:9.25
======================================================
=====當前可用區爲:[華北, 華東]
區域[華南]概要:實例總數:1,活躍請求總數:10,平均負載:10.0
區域[華東]概要:實例總數:2,活躍請求總數:17,平均負載:8.5
區域[華北]概要:實例總數:4,活躍請求總數:39,平均負載:9.75
======================================================
...

從中可以明顯的看出:每次會把負載最高的Zone給T除掉(請認真觀察輸出的數據來發現規律),這是完全符合預期的。

說明:因爲平均負載均超過閾值0.2,所以會從所有zone中排除掉一個負載最高的zone~


總結

關於Ribbon可用區選擇邏輯就先介紹這,這裏有必要再次強調:雖然它爲static靜態方法,但是它是可用區過濾邏輯、可用區選擇的核心邏輯,這對後面的具有區域意識的LoadBalancer的理解具有核心要意。

這部分邏輯理解起來稍顯費力,建議多讀幾遍,並且結合自己腦補的場景便可完成,當然嘍,若有不知道的概念,請參閱前面相關文章,畢竟學習就像砌磚,跳不過去的。
分隔線

聲明

原創不易,碼字不易,多謝你的點贊、收藏、關注。把本文分享到你的朋友圈是被允許的,但拒絕抄襲。你也可【左邊掃碼/或加wx:fsx641385712】邀請你加入我的 Java高工、架構師 系列羣大家庭學習和交流。
往期精選

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章