一個線上問題的思考:Eureka註冊中心叢集如何實現客戶端請求負載及故障轉移?

一枝花算不算浪漫發表於2020-06-23

前言

先拋一個問題給我聰明的讀者,如果你們使用微服務SpringCloud-Netflix進行業務開發,那麼線上註冊中心肯定也是用了叢集部署,問題來了:

你瞭解Eureka註冊中心叢集如何實現客戶端請求負載及故障轉移嗎?

可以先思考一分鐘,我希望你能夠帶著問題來閱讀此篇文章,也希望你看完文章後會有所收穫!

背景

前段時間線上Sentry平臺報警,多個業務服務在和註冊中心互動時,例如續約登錄檔增量拉取等都報了Request execution failed with message : Connection refused 的警告:

連線拒絕.jpg

緊接著又看到 Request execution succeeded on retry #2 的日誌。

連線重試.jpg

看到這裡,表明我們的服務在嘗試兩次重連後和註冊中心互動正常了。

一切都顯得那麼有驚無險,這裡報Connection refused 是註冊中心網路抖動導致的,接著觸發了我們服務的重連,重連成功後一切又恢復正常。

這次的報警雖然沒有對我們線上業務造成影響,並且也在第一時間恢復了正常,但作為一個愛思考的小火雞,我很好奇這背後的一系列邏輯:Eureka註冊中心叢集如何實現客戶端請求負載及故障轉移?

問題思考梳理.png

註冊中心叢集負載測試

線上註冊中心是由三臺機器組成的叢集,都是4c8g的配置,業務端配置註冊中心地址如下(這裡的peer來代替具體的ip地址):

eureka.client.serviceUrl.defaultZone=http://peer1:8080/eureka/,http://peer2:8080/eureka/,http://peer3:8080/eureka/

我們可以寫了一個Demo進行測試:

註冊中心叢集負載測試

1、本地通過修改EurekaServer服務的埠號來模擬註冊中心叢集部署,分別以87618762兩個埠進行啟動
2、啟動客戶端SeviceA,配置註冊中心地址為:http://localhost:8761/eureka,http://localhost:8762/eureka

EurekaClient端配置.png

3、啟動SeviceA時在傳送註冊請求的地方打斷點:AbstractJerseyEurekaHttpClient.register(),如下圖所示:
8761在前.png

這裡看到請求註冊中心時,連線的是8761這個埠的服務。

4、更改ServiceA中註冊中心的配置:http://localhost:8762/eureka,http://localhost:8761/eureka
5、重新啟動SeviceA然後檢視埠,如下圖所示:
8762在前.png
此時看到請求註冊中心是,連線的是8762這個埠的服務。

註冊中心故障轉移測試

以兩個埠分別啟動EurekaServer服務,再啟動一個客戶端ServiceA。啟動成功後,關閉一個8761埠對應的服務,檢視此時客戶端是否會自動遷移請求到8762埠對應的服務:

1、以87618762兩個埠號啟動EurekaServer
2、啟動ServiceA,配置註冊中心地址為:http://localhost:8761/eureka,http://localhost:8762/eureka
3、啟動成功後,關閉8761埠的EurekaServer
4、在EurekaClient傳送心跳請求的地方打上斷點:AbstractJerseyEurekaHttpClient.sendHeartBeat()
5、檢視斷點處資料,第一次請求的EurekaServer8761埠的服務,因為該服務已經關閉,所以返回的responsenull
8761故障.png
6、第二次會重新請求8762埠的服務,返回的response為狀態為200,故障轉移成功,如下圖:
8762故障轉移.png

思考

通過這兩個測試Demo,我以為EurekaClient每次都會取defaultZone配置的第一個host作為請求EurekaServer的請求的地址,如果該節點故障時,會自動切換配置中的下一個EurekaServer進行重新請求。

那麼疑問來了,EurekaClient每次請求真的是以配置的defaultZone配置的第一個服務節點作為請求的嗎?這似乎也太弱了!!?

EurekaServer叢集不就成了偽叢集!!?除了客戶端配置的第一個節點,其它註冊中心的節點都只能作為備份和故障轉移來使用!!?

真相是這樣嗎?NO!我們眼見也不一定為實,原始碼面前毫無祕密!

翠花,上乾貨!

客戶端請求負載原理

原理圖解

還是先上結論,負載原理如圖所示:

負載原理.png

這裡會以EurekaClient端的IP作為隨機的種子,然後隨機打亂serverList,例如我們在商品服務(192.168.10.56)中配置的註冊中心叢集地址為:peer1,peer2,peer3,打亂後的地址可能變成peer3,peer2,peer1

使用者服務(192.168.22.31)中配置的註冊中心叢集地址為:peer1,peer2,peer3,打亂後的地址可能變成peer2,peer1,peer3

EurekaClient每次請求serverList中的第一個服務,從而達到負載的目的。

程式碼實現

我們直接看最底層負載程式碼的實現,具體程式碼在
com.netflix.discovery.shared.resolver.ResolverUtils.randomize() 中:

程式碼實現.png

這裡面random 是通過我們EurekaClient端的ipv4做為隨機的種子,生成一個重新排序的serverList,也就是對應程式碼中的randomList,所以每個EurekaClient獲取到的serverList順序可能不同,在使用過程中,取列表的第一個元素作為serverhost,從而達到負載的目的。

負載均衡程式碼實現.png

思考

原來程式碼是通過EurekaClientIP進行負載的,所以剛才通過DEMO程式結果就能解釋的通了,因為我們做實驗都是用的同一個IP,所以每次都是會訪問同一個Server節點。

既然說到了負載,這裡肯定會有另一個疑問:

通過IP進行的負載均衡,每次請求都會均勻分散到每一個Server節點嗎?

比如第一次訪問Peer1,第二次訪問Peer2,第三次訪問Peer3,第四次繼續訪問Peer1等,迴圈往復......

我們可以繼續做個試驗,假如我們有10000個EurekaClient節點,3個EurekaServer節點。

Client節點的IP區間為:192.168.0.0 ~ 192.168.255.255,這裡面共覆蓋6w多個ip段,測試程式碼如下:

/**
 * 模擬註冊中心叢集負載,驗證負載雜湊演算法
 *
 *  @author 一枝花算不算浪漫
 *  @date 2020/6/21 23:36
 */
public class EurekaClusterLoadBalanceTest {

    public static void main(String[] args) {
        testEurekaClusterBalance();
    }

    /**
     * 模擬ip段測試註冊中心負載叢集
     */
    private static void testEurekaClusterBalance() {
        int ipLoopSize = 65000;
        String ipFormat = "192.168.%s.%s";
        TreeMap<String, Integer> ipMap = Maps.newTreeMap();
        int netIndex = 0;
        int lastIndex = 0;
        for (int i = 0; i < ipLoopSize; i++) {
            if (lastIndex == 256) {
                netIndex += 1;
                lastIndex = 0;
            }

            String ip = String.format(ipFormat, netIndex, lastIndex);
            randomize(ip, ipMap);
            System.out.println("IP: " + ip);
            lastIndex += 1;
        }

        printIpResult(ipMap, ipLoopSize);
    }

    /**
     * 模擬指定ip地址獲取對應註冊中心負載
     */
    private static void randomize(String eurekaClientIp, TreeMap<String, Integer> ipMap) {
        List<String> eurekaServerUrlList = Lists.newArrayList();
        eurekaServerUrlList.add("http://peer1:8080/eureka/");
        eurekaServerUrlList.add("http://peer2:8080/eureka/");
        eurekaServerUrlList.add("http://peer3:8080/eureka/");

        List<String> randomList = new ArrayList<>(eurekaServerUrlList);
        Random random = new Random(eurekaClientIp.hashCode());
        int last = randomList.size() - 1;
        for (int i = 0; i < last; i++) {
            int pos = random.nextInt(randomList.size() - i);
            if (pos != i) {
                Collections.swap(randomList, i, pos);
            }
        }

        for (String eurekaHost : randomList) {
            int ipCount = ipMap.get(eurekaHost) == null ? 0 : ipMap.get(eurekaHost);
            ipMap.put(eurekaHost, ipCount + 1);
            break;
        }
    }

    private static void printIpResult(TreeMap<String, Integer> ipMap, int totalCount) {
        for (Map.Entry<String, Integer> entry : ipMap.entrySet()) {
            Integer count = entry.getValue();
            BigDecimal rate = new BigDecimal(count).divide(new BigDecimal(totalCount), 2, BigDecimal.ROUND_HALF_UP);
            System.out.println(entry.getKey() + ":" + count + ":" + rate.multiply(new BigDecimal(100)).setScale(0, BigDecimal.ROUND_HALF_UP) + "%");
        }
    }
}

負載測試結果如下:
負載測試結果.png

可以看到第二個機器會有50%的請求,最後一臺機器只有17%的請求,負載的情況並不是很均勻,我認為通過IP負載並不是一個好的方案。

還記得我們之前講過Ribbon預設的輪詢演算法RoundRobinRule【一起學原始碼-微服務】Ribbon 原始碼四:進一步探究Ribbon的IRule和IPing

這種演算法就是一個很好的雜湊演算法,可以保證每次請求都很均勻,原理如下圖:

Ribbon輪詢演算法.png

故障轉移原理

原理圖解

還是先上結論,如下圖:

故障轉移原理.png

我們的serverList按照client端的ip進行重排序後,每次都會請求第一個元素作為和Server端互動的host,如果請求失敗,會嘗試請求serverList列表中的第二個元素繼續請求,這次請求成功後,會將此次請求的host放到全域性的一個變數中儲存起來,下次client端再次請求 就會直接使用這個host

這裡最多會重試請求兩次。

程式碼實現

直接看底層互動的程式碼,位置在
com.netflix.discovery.shared.transport.decorator.RetryableEurekaHttpClient.execute() 中:

重試程式碼.png

我們來分析下這個程式碼:

  1. 第101行,獲取client上次成功server端的host,如果有值則直接使用這個host
  2. 第105行,getHostCandidates()是獲取client端配置的serverList資料,且通過ip進行重排序的列表
  3. 第114行,candidateHosts.get(endpointIdx++),初始endpointIdx=0,獲取列表中第1個元素作為host請求
  4. 第120行,獲取返回的response結果,如果返回的狀態碼是200,則將此次請求的host設定到全域性的delegate變數中
  5. 第133行,執行到這裡說明第120行執行的response返回的狀態碼不是200,也就是執行失敗,將全域性變數delegate中的資料清空
  6. 再次迴圈第一步,此時endpointIdx=1,獲取列表中的第二個元素作為host請求
  7. 依次執行,第100行的迴圈條件numberOfRetries=3,最多重試2次就會跳出迴圈

我們還可以第123和129行,這也正是我們業務丟擲來的日誌資訊,所有的一切都對應上了。

總結

感謝你看到這裡,相信你已經清楚了開頭提問的問題。

上面已經分析完了Eureka叢集下Client端請求時負載均衡的選擇以及叢集故障時自動重試請求的實現原理。

如果還有不懂的問題,可以新增我的微信或者給我公眾號留言,我會單獨和你討論交流。

本文首發自:一枝花算不算浪漫 公眾號,如若轉載請在文章開頭標明出處,如需開白可直接公眾號回覆即可。

相關文章