微信紅包業務,爲什麼採用輪詢算法?

目錄

前言

負載均衡這個概念,幾乎在所有支持高可用的技術棧中都存在,例如微服務、分庫分表、各大中間件(MQ、Redis、MyCat、Nginx、ES)等,也包括雲計算、雲調度、大數據中也是炙手可熱的詞彙。

負載均衡策略主要分爲靜態與動態兩大類:

但負載均衡算法數量並不少,本篇主要對於一些常用且高效的負載策略進行剖析。

基本的負載算法

如果聊到最基本的負載均衡算法,那麼相信大家多少都有了解,例如:輪詢、隨機、權重等這類算法。特點就在於實現簡單,先來快速過一遍基本的算法實現。

| 輪詢算法

輪詢算法是最爲簡單、也最爲常見的算法,也是大多數集羣情況下的默認調度算法,這種算法會按照配置的服務器列表,按照順序依次分發請求,所有服務器都分發一遍後,又會回到第一臺服務器循環該步驟。

Java 代碼實現如下:

// 服務類:主要用於保存配置的所有節點
public class Servers {

    // 模擬配置的集羣節點
    public static List<String> SERVERS = Arrays.asList(
            "44.120.110.001:8080",
            "44.120.110.002:8081",
            "44.120.110.003:8082",
            "44.120.110.004:8083",
            "44.120.110.005:8084"
    );
}

// 輪詢策略類:實現基本的輪詢算法
public class RoundRobin{
    // 用於記錄當前請求的序列號
    private static AtomicInteger requestIndex = new AtomicInteger(0);

    // 從集羣節點中選取一個節點處理請求
    public static String getServer(){
        // 用請求序列號取餘集羣節點數量,求得本次處理請求的節點下標
        int index = requestIndex.get() % Servers.SERVERS.size();
        // 從服務器列表中獲取具體的節點IP地址信息
        String server = Servers.SERVERS.get(index);
        // 自增一次請求序列號,方便下個請求計算
        requestIndex.incrementAndGet();
        // 返回獲取到的服務器IP地址
        return server;
    }
}

// 測試類:測試輪詢算法
public class Test{
    public static void main(String[] args){
        // 使用for循環簡單模擬10個客戶端請求
        for (int i = 1; i <= 10; i++){
            System.out.println("第"+ i + "個請求:" + RoundRobin.getServer());
        }
    }
}

/******輸出結果*******/
第1個請求44.120.110.001:8080
第2個請求44.120.110.002:8081
第3個請求44.120.110.003:8082
第4個請求44.120.110.004:8083
第5個請求44.120.110.005:8084
第6個請求44.120.110.001:8080
第7個請求44.120.110.002:8081
第8個請求44.120.110.003:8082
第9個請求44.120.110.004:8083
第10個請求44.120.110.005:8084

上述案例中,整個算法的實現尤爲簡單,就是通過一個原子計數器記錄當前請求的序列號,然後直接通過 % 集羣中的服務器節點總數,最終得到一個具體的下標值,再通過這個下標值,從服務器 IP 列表中獲取一個具體的 IP 地址。

輪詢算法的優勢:

輪詢算法的劣勢:

輪詢算法的應用場景:

| 隨機算法

隨機算法的實現也非常簡單,也就是當客戶端請求到來時,每次都會從已配置的服務器列表中隨機抽取一個節點處理。

實現如下:

// 隨機策略類:隨機抽取集羣中的一個節點處理請求
public class Random {
    // 隨機數產生器,用於產生隨機因子
    static java.util.Random random = new java.util.Random();

    public static String getServer(){
        // 從已配置的服務器列表中,隨機抽取一個節點處理請求
        return Servers.SERVERS.get(random.nextInt(Servers.SERVERS.size()));
    }
}

上述該算法的實現,非常明瞭,通過 java.util 包中自帶的 Random 隨機數產生器,從服務器列表中隨機抽取一個節點處理請求,該算法的結果也不測試了,大家估計一眼就能看明白。

隨機算法的優勢:個人看來該算法單獨使用的意義並不大,一般會配合下面要講的權重策略協同使用。

隨機算法的劣勢:

| 權重算法

權重算法是建立在其他基礎算法之上推出的一種概念,權重算法並不能單獨配置,因爲權重算法無法做到請求分發的調度,所以一般權重會配合其他基礎算法結合使用。

如:輪詢權重算法、隨機權重算法等,這樣可以讓之前的兩種基礎調度算法更爲 “人性化” 一些。

權重算法是指對於集羣中的每個節點分配一個權重值,權重值越高,該節點被分發的請求數也會越多,反之同理。

這樣做的好處十分明顯,也就是能夠充分考慮機器的硬件配置,從而分配不同權重值,做到 “能者多勞”。

那如何實現呢,先來看看隨機權重的實現:

public class Servers{
    // 在之前是Servers類中再加入一個權重服務列表
    public static Map<String, Integer> WEIGHT_SERVERS = new LinkedHashMap<>();
    static {
        // 配置集羣的所有節點信息及權重值
        WEIGHT_SERVERS.put("44.120.110.001:8080",17);
        WEIGHT_SERVERS.put("44.120.110.002:8081",11);
        WEIGHT_SERVERS.put("44.120.110.003:8082",30);
    }
}

// 隨機權重算法
public class Randomweight {
    // 初始化隨機數生產器
    static java.util.Random random = new java.util.Random();

    public static String getServer(){
        // 計算總權重值
        int weightTotal = 0;
        for (Integer weight : Servers.WEIGHT_SERVERS.values()) {
            weightTotal += weight;
        }

        // 從總權重的範圍內隨機生成一個索引
        int index = random.nextInt(weightTotal);
        System.out.println(index);

        // 遍歷整個權重集羣的節點列表,選擇節點處理請求
        String targetServer = "";
        for (String server : Servers.WEIGHT_SERVERS.keySet()) {
            // 獲取每個節點的權重值
            Integer weight = Servers.WEIGHT_SERVERS.get(server);
            // 如果權重值大於產生的隨機數,則代表此次隨機分配應該落入該節點
            if (weight > index){
                // 直接返回對應的節點去處理本次請求並終止循環
                targetServer = server;
                break;
            }
            // 如果當前節點的權重值小於隨機索引,則用隨機索引減去當前節點的權重值,
            // 繼續循環權重列表,與其他的權重值進行對比,
            // 最終該請求總會落入到某個IP的權重值範圍內
            index = index - weight;
        }
        // 返回選中的目標節點
        return targetServer;
    }

    public static void main(String[] args){
        // 利用for循環模擬10個客戶端請求測試
        for (int i = 1; i <= 10; i++){
            System.out.println("第"+ i + "個請求:" + getServer());
        }
    }
}

/********運行結果********/
第1個請求44.120.110.003:8082
第2個請求44.120.110.001:8080
第3個請求44.120.110.003:8082
第4個請求44.120.110.003:8082
第5個請求44.120.110.003:8082
第6個請求44.120.110.003:8082
第7個請求44.120.110.003:8082
第8個請求44.120.110.001:8080
第9個請求44.120.110.001:8080
第10個請求44.120.110.002:8081

上面這個算法對比之前的基本實現,可能略微有些複雜難懂,我們先上個圖:

仔細觀看上圖後,邏輯應該會清晰很多,大體捋一下思路:

這樣一分析下來,估摸着各位小夥伴應該都理解了,接着再來看看輪詢權重算法的實現:

// 輪詢權重算法
public class RoundRobinweight {
    private static AtomicInteger requestCount = new AtomicInteger(0);

    public static String getServer(){
        int weightTotal = 0;
        for (Integer weight : Servers.WEIGHT_SERVERS.values()) {
            weightTotal += weight;
        }

        String targetServer = "";
        int index = requestCount.get() % weightTotal;
        requestCount.incrementAndGet();

        for (String server : Servers.WEIGHT_SERVERS.keySet()) {
            Integer weight = Servers.WEIGHT_SERVERS.get(server);
            if (weight > index){
                targetServer = server;
                break;
            }
            index = index - weight;
        }
        return targetServer;
    }

    public static void main(String[] args){
        for (int i = 1; i <= 10; i++){
            System.out.println("第"+ i + "個請求:" + getServer());
        }
    }
}

/********運行結果*********/
第1個請求44.120.110.001:8080
第2個請求44.120.110.001:8080
第3個請求44.120.110.001:8080
第4個請求44.120.110.001:8080
第5個請求44.120.110.001:8080
第6個請求44.120.110.001:8080
第7個請求44.120.110.001:8080
第8個請求44.120.110.001:8080
第9個請求44.120.110.001:8080
第10個請求44.120.110.001:8080

觀察上述中的案例,此刻會發現出端倪,代碼實現過程相同,但此刻的輸出結果,竟然全部請求都被分發到了 44.120.110.001:8080 這個節點,這是爲什麼呢?

因爲此時是通過請求序列號去進行判斷的,所以最終效果會成爲:

此時似乎離我們預期的負載效果發生了偏離,如果採用這種方案去實現輪詢權重算法,最終會將一個集羣變爲單點服務,這顯然並不是期待中的效果,因此需要一種新的方式去實現,那麼又該如何去做呢?

此時需要牽扯到一種請求調度的高級算法:平滑加權輪詢算法。

平滑加權輪詢算法

平滑輪詢加權算法的本質就是爲了解決之前實現方式中所存在的問題,能夠將請求均勻的按照權重值分發到每臺機器。

這種算法設計的非常巧妙,實現過程也尤爲有趣,我們一起來看看:

// 權重服務器的配置類
public class Servers {
    public static Map<String, Integer> WEIGHT_SERVERS = new LinkedHashMap<>();
    static {
        // 權重值設置的略微小一點,方便後續理解算法
        WEIGHT_SERVERS.put("44.120.110.001:8080",3);
        WEIGHT_SERVERS.put("44.120.110.002:8081",2);
        WEIGHT_SERVERS.put("44.120.110.003:8082",1);
    }
}

// 權重類
public class Weight {
    // 節點信息
    private String server;
    // 節點權重值
    private Integer weight;
    // 動態權重值
    private Integer currentWeight;

    // 構造方法
    public Weight() {}
    public Weight(String server, Integer weight, Integer currentWeight) {
        this.server = server;
        this.weight = weight;
        this.currentWeight = currentWeight;
    }

    // 封裝方法
    public String getServer() {
        return server;
    }
    public void setServer(String server) {
        this.server = server;
    }
    public Integer getWeight() {
        return weight;
    }
    public void setWeight(Integer weight) {
        this.weight = weight;
    }
    public Integer getCurrentWeight() {
        return this.currentWeight;
    }
    public void setCurrentWeight(Integer currentWeight) {
        this.currentWeight = currentWeight;
    }
}

public class RoundRobinWeight {
    // 初始化存儲每個節點的權重容器
    private static Map<String,Weight> weightMap = new HashMap<>();

    // 計算總權重值,只需要計算一次,因此放在靜態代碼塊中執行
    private static int weightTotal = 0;
    static {
        sumWeightTotal();
    }

    // 求和總權重值,後續動態伸縮節點時,再次調用該方法即可。
    public static void sumWeightTotal(){
        for (Integer weight : Servers.WEIGHT_SERVERS.values()) {
            weightTotal += weight;
        }
    }

    // 獲取處理本次請求的具體服務器IP
    public static String getServer(){
        // 判斷權重容器中是否有節點信息
        if (weightMap.isEmpty()){
            // 如果沒有則將配置的權重服務器列表挨個載入容器
            Servers.WEIGHT_SERVERS.forEach((servers, weight) -> {
                // 初始化時,每個節點的動態權重值都爲0
                weightMap.put(servers, new Weight(servers, weight, 0));
            });
        }

        // 每次請求時,更改動態權重值
        for (Weight weight : weightMap.values()) {
            weight.setCurrentWeight(weight.getCurrentWeight()
                    + weight.getWeight());
        }

        // 判斷權重容器中最大的動態權重值
        Weight maxCurrentWeight = null;
        for (Weight weight : weightMap.values()) {
            if (maxCurrentWeight == null || weight.getCurrentWeight()
                    > maxCurrentWeight.getCurrentWeight()){
                maxCurrentWeight = weight;
            }
        }

        // 最後用最大的動態權重值減去所有節點的總權重值
        maxCurrentWeight.setCurrentWeight(maxCurrentWeight.getCurrentWeight()
                - weightTotal);

        // 返回最大的動態權重值對應的節點IP
        return maxCurrentWeight.getServer();
    }

    public static void main(String[] args){
        // 使用for循環模擬6次請求
        for (int i = 1; i <= 6; i++){
            System.out.println("第"+ i + "個請求:" + getServer());
        }
    }
}

/********輸出結果********/
第1個請求44.120.110.001:8080
第2個請求44.120.110.002:8081
第3個請求44.120.110.001:8080
第4個請求44.120.110.003:8082
第5個請求44.120.110.002:8081
第6個請求44.120.110.001:8080

先看結果,對比之前的實現方式而言,該算法在分發請求時,確實均勻了很多很多。

而且請求分發的數量與我們配置的權重值也恰巧相符合:

這是不是很神奇?如何做到的呢,接下來簡單聊一下該算法的核心思想。

在之前的權重算法中,服務器列表中只有兩個值:服務器 IP、對應的權重值,而在當前這種算法中,需要再引入一個動態權重值的概念。

所以我們再上述案例中,將服務器的列表抽象成了一個 Weight 類,在該類中除開原本的 servers、weight 之外,多添加了一個字段 currentWeight,用於記錄每個節點的動態權重(該值是變化的)。

在該算法中,會先計算已配置的權重值總和,然後第一次請求,會初始化權重容器 weightMap,將每個配置的節點都封裝成一個 Weight 對象,並將其動態權重值初始化爲 0。

如下:

OK,至此準備工作就緒,接下來是算法的核心過程,主要分爲三步:

結合上述的算法過程和前面給出的案例,把整個過程攤開剖析一次:

上表中列出了六次請求的處理過程,整個過程到最後,動態權重值又會迴歸初始值:0,0,0,然後開啓新的一輪計算,週而復始之,格外的神奇 ^_^。

平滑加權輪詢算法也是應用最爲廣泛的輪詢算法,在 Dubbo、Robbin、Nginx、Zookeeper 等一些集羣環境中,當你配置了權重時,默認採用的就是該算法作爲請求分發的策略。

一致性哈希算法

其實平滑加權輪詢算法對於請求分發而言,是一種比較優秀的策略了,不過前面分析的所有策略,都存在一個致命問題:不能確保同一客戶端的所有請求都分發在同一臺服務器處理,因此無法實現有狀態的請求。

好比最簡單的登錄功能,客戶端發送請求登錄成功,然後將其登錄的狀態保存在 session 中,結果客戶端的第二次請求被分發到了另外一臺機器。

由於第二臺服務器 session 中沒有相關的登錄信息,因此會要求客戶端重新登錄,這顯然造成的用戶體驗感是極差的,那麼對於這種問題又該如何解決呢?

主要有兩種方案:

一致性哈希算法就是一種能夠能夠確保同一客戶端的所有請求都會被分發到同一臺機器的策略,不過一致性哈希算法依舊會存在問題,就是當集羣中某個節點下線,或者集羣出現拓展時,那麼也會影響最終分發的目標機器。

所以一般一致性哈希算法並不能 100% 解決 session 一致性的問題,因此該算法一般很少用於網關層的請求分發,更多的場景是應用在分佈式緩存等情況,接下來一起來看看。

| 通過其他分發算法實現緩存

在講解一致性哈希算法之前,大家先來簡單理解一下一致性哈希算法的產生背景。

先思考一個問題:假設目前單臺緩存服務器無法承擔外部的訪問壓力,此刻會如何去做呢?

答案是增加新的緩存服務器節點,拓展出一個集羣對外提供服務。

好的,那問題又來了,現在緩存服務器是一個集羣環境,此刻來了一個請求後該落入哪個節點呢?

假設採用輪詢策略,那麼寫入 xxx 緩存信息的請求被分發到了第一個節點,客戶端讀取 xxx 時,請求又被分發到了第三個節點上,那麼顯然是讀不到之前的緩存。

而且最關鍵的是,一般的輪詢策略都是需要基於集羣的節點數量進行請求分發的,因此集羣中的節點一旦出現伸縮,最終會導致所有緩存內容全部失效。

就拿最基本的取模輪詢來說,原本集羣是 3 個節點,所以是基於取模 3 去分發請求,結果有臺節點宕機了,成爲了取模 2,那最後整個緩存系統分發請求完全亂套.....

如果採用隨機策略.....,更不靠譜.....

因此在這種需求背景下,大名鼎鼎的一致性哈希算法問世了,一致性哈希算法其實也使用的取模方式,只是,剛纔描述的取模輪詢法是對服務器的數量進行取模,而一致性哈希算法是對 2^32 取模,什麼意思呢?我們一點點來講。

| 一致性哈希核心 - 哈希環

實現一致性哈希算法的核心結構在於哈希環,前面講到過一致性哈希是基於 2^32 做取模。

那麼首先可以將二的三十二次方想象成一個圓,這個圓總共由 2^32 個點組成,如下:

圓環的正上方第一個點代表 0,0 右側的點按照 1、2、3、4.... 的順序依此類推,直到 2^32-1,也就是說 0 左側的第一個點代表着 2^32-1。

最終這個在邏輯上由 2^32 個點組成的圓,被稱爲哈希環。

結合之前的緩存案例,假設有四臺緩存服務器 A、B、C、D,然後再通過每臺服務器的 IP 哈希值取模 2^32,最終必然會得到一個 2^32 範圍之內的整數,這個數在哈希環上定然也對應着一個點。

那麼每臺服務器的 IP 就可以映射到哈希環上,如下:

到此時,服務器已經和哈希環建立起了聯繫,那麼此時當客戶端發送請求時,又可以通過相同的計算方式,將客戶端需要操作的緩存 Key 進行相同的哈希取模,然後同樣將其映射到哈希環上。

例如寫入一條緩存 name = 竹子,如下:

那麼此時該緩存糾結要落入到哪臺服務器呢?答案是 B,爲什麼?因爲在哈希環結構中,沿着順時針方向走,遇到的第一臺服務器是 B,所以最終會落到 B 服務器上。

當然,如果一致性哈希算法被用於請求分發,那麼就以用戶的 IP 作爲哈希取模的條件,這樣就能確保同一個客戶端的所有請求都會被分發到同一臺服務器。

一致性哈希算法中,就利用哈希環結構 + 哈希取模判斷每個請求該落入的服務器,由於服務器 IP、客戶端 IP 或緩存的 Key 都是相同的,所以在服務器數量不變的情況,相同的哈希條件進行哈希取模,最終計算出來的值永遠都是相同的。

然後再通過計算出的值,在哈希環結構上進行順時針查找,能夠定位到的服務器也是相同的,所以相同屬性的請求永遠會落入到同一服務器。

| 哈希環的映射偏移問題

經過上述分析後,好像發現一致性哈希算法沒啥大毛病,但上述中屬於 “理想狀態”:

可偏偏理想很豐滿,現實卻很骨感,實際映射服務器 IP 的過程中,可能會出現如下情況:

由於服務器 IP 哈希取模後,無法確保哈希得到的數字能夠均勻分佈,因此就有可能造成如上情況,所有的服務器 IP 都被映射在 “一塊兒”,最終導致 A 服務器承載了 90% 以上的訪問壓力。

| 映射偏移造成的宕機連鎖反應

接上述,如果服務器 IP 映射在哈希環上出現偏移,在大流量的衝擊下,這種情況很容易導致整個集羣崩塌,首先是 A 扛不住併發衝擊,宕機下線,緊接着流量交給 B,B 也扛不住,接着宕機,然後 C.....

因此哈希環映射偏移問題可能會造成的一系列連鎖反應,所以在一致性哈希算法中,爲了確保整個集羣的健壯性,提出了一種虛擬節點的概念來解決此問題。

虛擬節點其實本質上就是真實服務器節點的複製品,虛擬節點映射的 IP 都是指向於真實服務器的。

就類似平時 .EXE 軟件的快捷方式,現在爲 QQ 創建了一個快捷方式,然後拷貝到了十個不同的目錄下,但本質上這十個快捷方式指向的啓動文件都是相同 exe 程序。

哈希環中的虛擬節點也同理,如下:

從上圖中可以看出,A、B、C、D 四臺服務器分別都映射出了一個虛擬節點,引入虛擬節點後會明顯感覺出來,原本 A 服務器需要承載 90% 以上的流量,但此刻映射出的虛擬節點大大減輕了 A 的壓力,將流量均攤到了集羣中的每個節點。

在一致性哈希算法的實際應用場景中,絕非只映射一個虛擬節點,往往會爲一個真實節點映射數十個虛擬節點,以便於減小哈希環偏移所帶來的影響。

同時,虛擬節點的數量越多,請求在分發時也能更均勻的分佈,哈希環最終結構如下:

| Java 實現一致性哈希算法

講了這麼多,那麼一致性哈希算法究竟如何實現呢?接下來一起看看:

public class Servers {
    public static List<String> SERVERS = Arrays.asList(
            "44.120.110.001:8080",
            "44.120.110.002:8081",
            "44.120.110.003:8082",
            "44.120.110.004:8083",
            "44.120.110.005:8084"
    );
}

public class ConsistentHash {
    // 使用有序的紅黑樹結構,用於實現哈希環結構
    private static TreeMap<Integer,String> virtualNodes = new TreeMap<>();
    // 每個真實節點的虛擬節點數量
    private static final int VIRTUAL_NODES = 160;

    static {
        // 對每個真實節點添加虛擬節點,虛擬節點會根據哈希算法進行散列
        for (String serverIP : Servers.SERVERS) {
            // 將真實節點的IP映射到哈希環上
            virtualNodes.put(getHashCode(serverIP), serverIP);
            // 根據設定的虛擬節點數量進行虛擬節點映射
            for (int i = 0; i < VIRTUAL_NODES; i++){
                // 計算出一個虛擬節點的哈希值(只要不同即可)
                int hash = getHashCode(serverIP + i);
                // 將虛擬節點添加到哈希環結構上
                virtualNodes.put(hash, serverIP);
            }
        }
    }

    public static String getServer(String IP){
        int hashCode = getHashCode(IP);
        // 得到大於該Hash值的子紅黑樹
        SortedMap<Integer, String> sortedMap = virtualNodes.tailMap(hashCode);
        // 得到該樹的第一個元素,也就是最小的元素
        Integer treeNodeKey = sortedMap.firstKey();
        // 如果沒有大於該元素的子樹了,則取整棵樹的第一個元素,相當於取哈希環中的最小元素
        if (sortedMap == null)
            treeNodeKey = virtualNodes.firstKey();
        // 返回對應的虛擬節點名稱
        return virtualNodes.get(treeNodeKey);
    }

    // 哈希方法:用於計算一個IP的哈希值
    public static int getHashCode(String IP){
        final int p = 1904390101;
        int hash = (int)1901102097L;
        for (int i = 0; i < IP.length(); i++)
            hash = (hash ^ IP.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;

        // 如果算出來的值爲負數則取其絕對值
        if (hash < 0)
            hash = Math.abs(hash);
        return hash;
    }

    public static void main(String[] args){
        // 用for循環模擬五個不同的IP訪問
        for (int i = 1; i <= 5; i++){
            System.out.println("第"+ i + "個請求:" + getServer("192.168.12.13"+i));
        }
        System.out.println("-----------------------------");
        // 用for循環模擬三個相同的IP訪問
        for (int i = 1; i <= 3; i++){
            System.out.println("第"+ i + "個請求:" + getServer("192.168.12.131"));
        }
    }
}

/********輸出結果*******/
第1個請求44.120.110.002:8081
第2個請求44.120.110.003:8082
第3個請求44.120.110.004:8083
第4個請求44.120.110.003:8082
第5個請求44.120.110.004:8083
-----------------------------
第1個請求44.120.110.002:8081
第2個請求44.120.110.002:8081
第3個請求44.120.110.002:8081

上述便是 Java 實現一致性哈希算法的全過程,其實並不難理解,裏面用到了 TreeMap 實現了哈希環結構,並且指定了每個服務器節點的虛擬節點數量,同時實現了一個簡單的哈希方法,用於計算入參的哈希值。

算法過程如下:

一致性哈希算法由於其特性,因此一般多被用於分佈式緩存中的集羣分片,尤其是 MemCache 的緩存分片,就是採用一致性哈希算法實現的。

而 Redis 自身推出的 RedisCluster 分片集羣中,也借用了一致性哈希算法的思想,不過進行了改版實現,內部採用 CRC16+HashSolt 實現了緩存分片,但核心思想也是相同的。

當然,文中給出的算法過程都是較爲簡單的實現,如若想要參考完整的實現,可以參考 :

最小活躍數算法

上述分析的基本算法、平滑輪詢加權、一致性哈希等算法都屬於靜態算法,也就是說這些算法配置後,並不會根據線上的實際運行情況進行調整,只會根據已配置的規則進行請求分發。

最小活躍數算法則會根據線上的實際情況進行分發,能夠靈活的檢測出集羣中各個節點的狀態,能夠自動尋找並調用活躍度最低的節點處理請求。

Java 實現如下:

// 節點類:用於封裝集羣中的每個節點
public class Server {
    private String IP;
    private AtomicInteger active;
//    private Integer weight;

    public Server(){}
    public Server(String IP,int active) {
        this.IP = IP;
        // 將外部傳遞的活躍數作爲默認活躍數
        this.active = new AtomicInteger(active);
    }

    public String getIP() {
        // 每分發一個請求時自增一次活躍數
        active.incrementAndGet();
        return IP;
    }

    public AtomicInteger getActive() {
        return active;
    }
}

// 集羣類:用於模擬集羣節點列表
public class Servers {
    // 活躍度衰減器
    public static void attenuator(){
        new Thread(()->{
            // 遍歷集羣中的所有節點
            for (Server server : Servers.SERVERS) {
                // 如果活躍度不爲0
                if (server.getActive().get() != 0){
                    // 則自減一個活躍度
                    server.getActive().getAndDecrement();
                }
            }
            try {
                // 每隔 2 秒中衰減一次活躍度
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }

    // 模擬的集羣節點信息,活躍數最開始默認爲0
    public static List<Server> SERVERS = Arrays.asList(
            new Server("44.120.110.001:8080",0),
            new Server("44.120.110.002:8081",0),
            new Server("44.120.110.003:8082",0)
    );
}

// 最小活躍數算法實現類
public class LeastActive {

    public static String getServer(){
        // 初始化最小活躍數和最小活躍數的節點
        int leastActive = Integer.MAX_VALUE;
        Server leastServer = new Server();
        // 遍歷集羣中的所有節點
        for (Server server : Servers.SERVERS) {
            // 找出活躍數最小的節點
            if (leastActive > server.getActive().get()){
                leastActive = server.getActive().get();
                leastServer = server;
            }
        }

        // 返回活躍數最小的節點IP
        return leastServer.getIP();
    }

    public static void main(String[] args){
        Servers.attenuator();
        for (int i = 1; i <= 10; i++){
            System.out.println("第"+ i + "個請求:" + getServer());
        }
    }
}

/********運行結果*********/
第1個請求44.120.110.001:8080
第2個請求44.120.110.002:8081
第3個請求44.120.110.003:8082
第4個請求44.120.110.001:8080
第5個請求44.120.110.002:8081
第6個請求44.120.110.003:8082
第7個請求44.120.110.001:8080
第8個請求44.120.110.002:8081
第9個請求44.120.110.003:8082
第10個請求44.120.110.001:8080

觀察如上案例的運行結果,似乎結果好像是輪詢的效果呀?確實是的,這是因爲在最開始,所有節點的活躍數都爲 0,三個節點的活躍數都相同。

所以默認會先取集羣中的第一個活躍數爲 0 的節點處理請求,第一個節點的活躍數會變成 1,第二次請求時最小活躍數也爲 0,然後取第二個節點處理請求,依此類推......

在線上環境下,不會出現輪詢的效果,因爲每臺服務器隨着運行時間的增長,活躍數必然會不同,因此該算法總會取活躍數最小的節點提供服務。

當然,上述案例中實現的最小活躍數,是比較簡易的版本,對於完善的實現可以參考 Dubbo 框架中的 com.alibaba.dubbo.rpc.cluster.loadbalance.LeastActiveLoadBalance 類,其中也實現了權重機制。

簡單闡述一下其中的原理實現:

當然,由於需要對每個節點去實現活躍數監聽,所以在 Dubbo 框架中,想要配置最小活躍數策略,那麼需要首先啓用 ActiveLimitFilter 記錄每個節點的活躍數。

或者也可以參考 Ribbon 框架 com.netflix.loadbalancer 包下面的 BestAvailableRule 最小活躍數算法實現類。

從最小活躍數算法特性不難得知,該算法帶來的優勢極爲明顯,永遠都能選取節點列表中最空閒的那臺服務器處理請求,從而避免某些負載過高的節點,還依舊承擔需要承擔新的流量訪問,造成更大的壓力。

最優響應算法

與前面分析的最小活躍數算法一樣,最優響應算法也是一種動態算法,但它比最小活躍數算法更加智能,因爲最小活躍數算法中,如果一臺節點存在故障,導致它自身處理的請求數比較少,那麼它會遭受最大的訪問壓力,這顯然是並不合理的。

最小活躍數算法就類似於平時的搬磚工作,誰事情做的最少誰留下來加班,在正常情況下,這種算法都能夠找到 “摸魚” 最厲害的員工留下來加班。

但如果有一天,某個員工由於身體出問題了,導致自己做的工作量比較少,但按照這種算法的邏輯,依舊會判定爲該員工今天最閒,所以留下來加班。

從上述這個案例中,大家略微能夠感受出來最小活躍數算法的不合理性。

而最優響應算法則更加智能,該算法在開始前,會對服務列表中的各節點發出一個探測請求(例如 Ping 或心跳包檢測),然後根據各節點的響應時間來決定由哪臺服務器處理客戶端請求,該算法能較好根據節點列表中每臺機器的當前運行狀態分發請求。

Java 實現如下:

public class Servers {
    // 模擬的集羣節點信息,活躍數最開始默認爲0
    public static List<Server> SERVERS = Arrays.asList(
            new Server("44.120.110.001:8080"),
            new Server("44.120.110.002:8081"),
            new Server("44.120.110.003:8082")
    );
}

public class Server {
    private String IP;

    public Server(){}
    public Server(String IP) {
        this.IP = IP;
    }
    public String getIP() {
        return IP;
    }
    public void setIP(String IP){
        this.IP = IP;
    }

    public String ping(){
        // 生成一個1000~3000之間的隨機數
        int random = ThreadLocalRandom.current().nextInt(1000, 2000);
        try {
            // 隨機休眠一段時間,模擬不同的響應速度
            Thread.sleep(random);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 最後返回自身的IP
        return this.IP;
    }
}

public class ResponseTime {
    // 創建一個定長的線程池,用於去執行ping任務
    static ExecutorService pingServerPool = 
        Executors.newFixedThreadPool(Servers.SERVERS.size());

    public static String getServer() throws InterruptedException {
        // 創建一個CompletableFuture用於拼接任務
        CompletableFuture cfAnyOf;
        // 創建一個接收結果返回的server節點對象
        final Server resultServer = new Server();
        // 根據集羣節點數量初始化一個異步任務數組
        CompletableFuture[] cfs = new CompletableFuture[Servers.SERVERS.size()];

        // 遍歷整個服務器列表,爲每個節點創建一個ping任務
        for (Server server : Servers.SERVERS) {
            // 獲取當前節點在集羣列表中的下標
            int index = Servers.SERVERS.indexOf(server);
            // 爲每個節點創建一個ping任務,並交給pingServerPool線程池執行
            CompletableFuture<String> cf =
                    CompletableFuture.supplyAsync(server::ping,pingServerPool);
            // 將創建好的異步任務加入數組中
            cfs[index] = cf;
        }

        // 將創建好的多個Ping任務組合成一個聚合任務並執行
        cfAnyOf = CompletableFuture.anyOf(cfs);

        // 監聽執行完成後的回調,誰先執行完成則返回誰
        cfAnyOf.thenAccept(resultIP -> {
             System.out.println("最先響應檢測請求的節點爲:" + resultIP);
            resultServer.setIP((String) resultIP);
        });
        //  阻塞主線程一段時間,防止CompletableFuture退出
        Thread.sleep(3000);

        // 返回最先響應檢測請求(ping)的節點作爲本次處理客戶端請求的節點
        return resultServer.getIP();
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 1; i <= 5; i++){
            System.out.println("第"+ i + "個請求:" + getServer());
        }
    }
}

/******運行結果:******/
最先響應檢測請求的節點爲44.120.110.002:8081
第1個請求44.120.110.002:8081
最先響應檢測請求的節點爲44.120.110.002:8081
第2個請求44.120.110.002:8081
最先響應檢測請求的節點爲44.120.110.003:8082
第3個請求44.120.110.003:8082
最先響應檢測請求的節點爲44.120.110.003:8080
第4個請求44.120.110.001:8080
最先響應檢測請求的節點爲44.120.110.002:8081
第5個請求44.120.110.002:8081

在該案例中,其實現過程對比之前的算法略微複雜一些,首先在 Server 實例類中定義了一個 Ping() 方法,該方法中使用隨機數 + 線程休眠的方式簡單模擬了一下節點的不同的響應速度。

然後在算法實現類中,利用 CompletableFuture 分別對每一個節點都創建了對應的 Ping 任務,然後同時執行,又通過 thenAccept() 回調方法監聽了執行結果,誰最先響應,則取其作爲處理本次請求的節點。

這個算法的實現過程中,唯一難理解的就是 CompletableFuture,它是 JDK8 中推出的一種異步任務。

這裏只是舉例實現,所以通過 CompletableFuture 實現了檢測請求,但實際過程中如果要選擇這種算法,那麼基於 Netty 會更爲合適。

從上述案例的運行結果中也可以得知:最優響應算法無論在何種情況下,都能從集羣中選取性能最好的節點對外服務,Nginx 中也支持配置這種算法,但需要先安裝對應的 nginx-upstream-fair 模塊。

總結

在本文中,對於比較常用的請求分發算法進行了剖析及手寫實踐,其中提到了較爲傳統的靜態調度算法:輪詢、隨機、加權、一致性哈希等,也談到了一些較爲智能的動態算法:最小活躍數、最優響應等。

但需要牢記的一點是:並非越智能的算法越好,越是併發高、流量大的場景下,反而選用最基本的算法更合適,例如微信的紅包業務,就是採用最基本的輪詢算法進行集羣調度。

那這又是爲何呢?因爲越智能的調度算法,進行節點選擇時的開銷會更大,如果你對於文中給出的調度算法實現都一一運行過,那麼大家會明顯感知出:越到後面的算法,分發請求的速度越慢。

因此在面臨巨大訪問壓力的情景中,選擇最簡單的算法反而帶來的收益更高,但前提是需要集羣中所有的節點硬件配置都一致,所有節點分配的資源都相同,輪詢算法則是最佳的調度算法。

作者:竹子愛熊貓 來源:juejin.cn/post/7116421978776404004

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CFIDA5pCXIY3eCj7kdHNkw