(四)網絡編程之請求分發篇:負載均衡靜態調度算法、平滑輪詢加權、一致性哈希、最小活躍數算法實踐!

語言: CN / TW / HK

引言

   先如今所有的技術棧中,只要一談關於高可用、高併發處理相關的實現,必然會牽扯到集羣這個話題,也就是部署多台服務器共同對外提供服務,從而做到提升系統吞吐量,優化系統的整體性能以及穩定性等目的。

當多台機器上部署相同服務節點時,客户端的發送請求訪問就會出現一個必須要解決的問題:客户端的請求到底該交由哪台服務器處理?這點則由負載均衡策略來決定,也就是説:請求具體會被分發到哪台服務器,是調度算法來決定的

   在上篇《Nginx》的文章中,我們首次引出了負載均衡的概念,在通過Nginx對後端做集羣時,裏面可以配置負載均衡策略,從而能夠做到讓訪問系統的大規模流量均勻分散到集羣中的每台服務器上,但在上篇Nginx的文章中並未對此展開敍述,但負載相關的內容對於後續的分佈式、中間件專題尤為重要,因此也來詳細剖析一下。

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

負載均衡策略主要分為靜態與動態兩大類: - 靜態調度算法:指配置後只會依據配置好的策略進行請求分發的算法。 - 動態調度算法:指配置後會根據線上情況(網絡/CPU負載/磁盤IO等)來分發請求。

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

一、基本的負載算法

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

1.1、輪詢算法

   輪詢算法是最為簡單、也最為常見的算法,也是大多數集羣情況下的默認調度算法,這種算法會按照配置的服務器列表,按照順序依次分發請求,所有服務器都分發一遍後,又會回到第一台服務器循環該步驟,Java代碼實現如下: ```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`地址。

輪詢算法的優勢: - ①算法實現簡單,請求分發效率夠高。 - ②能夠將所有請求均攤到集羣中的每個節點上。 - ③易於後期彈性伸縮,業務增長時可以拓展節點,業務萎靡時可以縮減節點。

輪詢算法的劣勢: - ①對於不同配置的服務器無法合理照顧,無法將高配置的服務器性能發揮出來。 - ②由於請求分發時,是基於請求序列號來實現的,所以無法保證同一客户端的請求都是由同一節點處理的,因此需要通過session記錄狀態時,無法確保其一致性。

輪詢算法的應用場景: - ①集羣中所有節點硬件配置都相同的情況。 - ②只讀不寫,無需保持狀態的情景。

1.2、隨機算法

   隨機算法的實現也非常簡單,也就是當客户端請求到來時,每次都會從已配置的服務器列表中隨機抽取一個節點處理,實現如下:
```java // 隨機策略類:隨機抽取集羣中的一個節點處理請求 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`隨機數產生器,從服務器列表中隨機抽取一個節點處理請求,該算法的結果也不測試了,大家估計一眼就能看明白。

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

隨機算法的劣勢: - ①無法合理的將請求均攤到每台服務器節點。 - ②由於處理請求的目標服務器不明確,因此也無法滿足需要記錄狀態的請求。 - ③能夠在一定程度上發揮出高配置的機器性能,但充滿不確定因素。

1.3、權重算法

   權重算法是建立在其他基礎算法之上推出的一種概念,權重算法並不能單獨配置,因為權重算法無法做到請求分發的調度,所以一般權重會配合其他基礎算法結合使用,如:輪詢權重算法、隨機權重算法等,這樣可以讓之前的兩種基礎調度算法更為“人性化”一些。
   權重算法是指對於集羣中的每個節點分配一個權重值,權重值越高,該節點被分發的請求數也會越多,反之同理。這樣做的好處十分明顯,也就是能夠充分考慮機器的硬件配置,從而分配不同權重值,做到“能者多勞”。那如何實現呢,先來看看隨機權重的實現: ```java public class Servers{ // 在之前是Servers類中再加入一個權重服務列表 public static Map 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 `` 上面這個算法對比之前的基本實現,可能略微有些複雜難懂,我們先上個圖: ![隨機權重算法](http://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/93d08e8ab91049e888e0e461de41fe03~tplv-k3u1fbpfcp-watermark.image?) 仔細觀看上圖後,邏輯應該會清晰很多,大體捋一下思路: - 先求和所有的權重值,再隨機生成一個總權重之內的索引。 - 遍歷之前配置的服務器列表,用隨機索引與每個節點的權重值進行判斷。 - 如果小於,則代表當前請求應該落入目前這個節點。 - 如果大於,則代表隨機索引超出了目前節點的權重範圍,則減去當前權重,繼續與其他節點判斷。 - 最終隨機出的索引總會落入到一個節點的權重範圍內,最後返回對應的節點IP`。

這樣一分析下來,估摸着各位小夥伴應該都理解了,接着再來看看輪詢權重算法的實現: ```java // 輪詢權重算法 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這個節點,這是為什麼呢?因為此時是通過請求序列號去進行判斷的,所以最終效果會成為: - 前17個請求會交給44.120.110.001:8080節點。 - 後續11個請求會交給44.120.110.002:8081節點。 - 最後30個請求會交給44.120.110.003:8082`節點。 - 然後持續重複該過程.....

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

二、平滑加權輪詢算法

   平滑輪詢加權算法的本質就是為了解決之前實現方式中所存在的問題,能夠將請求均勻的按照權重值分發到每台機器。這種算法設計的非常巧妙,實現過程也尤為有趣,我們一起來看看:
```java // 權重服務器的配置類 public class Servers { public static Map 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 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 `` 先看結果,對比之前的實現方式而言,該算法在分發請求時,確實均勻了很多很多,而且請求分發的數量與我們配置的權重值也恰巧相符合: -44.120.110.001:80803次 -44.120.110.002:80812次 -44.120.110.003:80821`次

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

在之前的權重算法中,服務器列表中只有兩個值:服務器IP、對應的權重值,而在當前這種算法中,需要再引入一個動態權重值的概念,所以我們再上述案例中,將服務器的列表抽象成了一個Weight類,在該類中除開原本的servers、weight之外,多添加了一個字段currentWeight,用於記錄每個節點的動態權重(該值是變化的)。

在該算法中,會先計算已配置的權重值總和,然後第一次請求,會初始化權重容器weightMap,將每個配置的節點都封裝成一個Weight對象,並將其動態權重值初始化為0,如下: - Weight("server":"44.120.110.001:8080","weight":3,"currentWeight":0) - Weight("server":"44.120.110.002:8081","weight":2,"currentWeight":0) - Weight("server":"44.120.110.003:8082","weight":1,"currentWeight":0)

OK,至此準備工作就緒,接下來是算法的核心過程,主要分為三步: - ①用原本的動態權重值加一次每個節點的靜態權重值,計算出新的動態權重值。 - ②遍歷權重容器,找出動態權重值最大的節點,將其作為處理本次請求的節點。 - ③用最大的動態權重值減去已配置的靜態權重值總和,為一下輪分發做準備。

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

序列號 | 計算動態權重 | 尋找最大權重 | 最大權重減總權重 | 本次目標IP :-:|:-:|:-:|:-:|:-: ① | 3,2,1 | 3 | -3,2,1 | 44.120.110.001:8080 ② | 0,4,2 | 4 | 0,-2,2 | 44.120.110.002:8081 ③ | 3,0,3 | 3 | -3,0,3 | 44.120.110.001:8080 ④ | 0,2,4 | 4 | 0,0,-2 | 44.120.110.003:8082 ⑤ | 3,4,-1 | 4 | 3,-2,-1 | 44.120.110.002:8081 ⑥ | 6,0,0 | 6 | 0,0,0 | 44.120.110.001:8080

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

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

三、一致性哈希算法

   其實平滑加權輪詢算法對於請求分發而言,是一種比較優秀的策略了,不過前面分析的所有策略,都存在一個致命問題:不能確保同一客户端的所有請求都分發在同一台服務器處理,因此無法實現有狀態的請求,好比最簡單的登錄功能,客户端發送請求登錄成功,然後將其登錄的狀態保存在session中,結果客户端的第二次請求被分發到了另外一台機器,由於第二台服務器session中沒有相關的登錄信息,因此會要求客户端重新登錄,這顯然造成的用户體驗感是極差的,那麼對於這種問題又該如何解決呢?主要有兩種方案: - ①採用外部中間件存儲session,例如Redis,然後從Redis中獲取登錄狀態。 - ②採用特殊的請求分發策略,確保同一客户端的所有請求都會去到同一台機器上處理。

一致性哈希算法 就是一種能夠能夠確保同一客户端的所有請求都會被分發到同一台機器的策略,不過一致性哈希算法依舊會存在問題,就是當集羣中某個節點下線,或者集羣出現拓展時,那麼也會影響最終分發的目標機器,所以一般一致性哈希算法並不能100%解決session一致性的問題,因此該算法一般很少用於網關層的請求分發,更多的場景是應用在分佈式緩存等情況,接下來一起來看看。

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

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

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

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

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

   假設採用輪詢策略,那麼寫入xxx緩存信息的請求被分發到了第一個節點,客户端讀取xxx時,請求又被分發到了第三個節點上,那麼顯然是讀不到之前的緩存。而且最關鍵的是,一般的輪詢策略都是需要基於集羣的節點數量進行請求分發的,因此集羣中的節點一旦出現伸縮,最終會導致所有緩存內容全部失效。
   就拿最基本的取模輪詢來説,原本集羣是3個節點,所以是基於取模3去分發請求,結果有台節點宕機了,成為了取模2,那最後整個緩存系統分發請求完全亂套.....

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

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

3.2、一致性哈希核心-哈希環

   實現一致性哈希算法的核心結構在於哈希環,前面講到過一致性哈希是基於2^32做取模,那麼首先可以將二的三十二次方想象成一個圓,這個圓總共由2^32個點組成,如下:
哈希環-V1
圓環的正上方第一個點代表00右側的點按照1、2、3、4....的順序依此類推,直到2^32-1,也就是説0左側的第一個點代表着2^32-1

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

結合之前的緩存案例,假設有四台緩存服務器A、B、C、D,然後再通過每台服務器的IP哈希值取模2^32,最終必然會得到一個2^32範圍之內的整數,這個數在哈希環上定然也對應着一個點,那麼每台服務器的IP就可以映射到哈希環上,如下:
哈希環-V2
到此時,服務器已經和哈希環建立起了聯繫,那麼此時當客户端發送請求時,又可以通過相同的計算方式,將客户端需要操作的緩存Key進行相同的哈希取模,然後同樣將其映射到哈希環上,例如寫入一條緩存name=竹子,如下:
哈希環-V3
那麼此時該緩存糾結要落入到哪台服務器呢?答案是B,為什麼?因為在哈希環結構中,沿着順時針方向走,遇到的第一台服務器是B,所以最終會落到B服務器上。

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

一致性哈希算法中,就利用哈希環結構+哈希取模判斷每個請求該落入的服務器,由於服務器IP、客户端IP或緩存的Key都是相同的,所以在服務器數量不變的情況,相同的哈希條件進行哈希取模,最終計算出來的值永遠都是相同的,然後再通過計算出的值,在哈希環結構上進行順時針查找,能夠定位到的服務器也是相同的,所以相同屬性的請求永遠會落入到同一服務器。

3.3、哈希環的映射偏移問題

   經過上述分析後,好像發現一致性哈希算法沒啥大毛病,但上述中屬於“理想狀態”:
理想vs現實
可偏偏理想很豐滿,現實卻很骨感,實際映射服務器IP的過程中,可能會出現如下情況:
映射偏移
   由於服務器IP哈希取模後,無法確保哈希得到的數字能夠均勻分佈,因此就有可能造成如上情況,所有的服務器IP都被映射在“一塊兒”,最終導致A服務器承載了90%以上的訪問壓力。

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

   接上述,如果服務器IP映射在哈希環上出現偏移,在大流量的衝擊下,這種情況很容易導致整個集羣崩塌,首先是A扛不住併發衝擊,宕機下線,緊接着流量交給BB也扛不住,接着宕機,然後C.....,因此哈希環映射偏移問題可能會造成的一系列連鎖反應,所以在一致性哈希算法中,為了確保整個集羣的健壯性,提出了一種虛擬節點的概念來解決此問題。

虛擬節點其實本質上就是真實服務器節點的複製品,虛擬節點映射的IP都是指向於真實服務器的,就類似平時.EXE軟件的快捷方式,現在為QQ創建了一個快捷方式,然後拷貝到了十個不同的目錄下,但本質上這十個快捷方式指向的啟動文件都是相同exe程序,哈希環中的虛擬節點也同理,如下:
虛擬節點
從上圖中可以看出,A、B、C、D四台服務器分別都映射出了一個虛擬節點,引入虛擬節點後會明顯感覺出來,原本A服務器需要承載90%以上的流量,但此刻映射出的虛擬節點大大減輕了A的壓力,將流量均攤到了集羣中的每個節點。

在一致性哈希算法的實際應用場景中,絕非只映射一個虛擬節點,往往會為一個真實節點映射數十個虛擬節點,以便於減小哈希環偏移所帶來的影響。同時,虛擬節點的數量越多,請求在分發時也能更均勻的分佈,哈希環最終結構如下:
哈希環最終態

3.5、Java實現一致性哈希算法

   講了這麼多,那麼一致性哈希算法究竟如何實現呢?接下來一起看看:
```java public class Servers { public static List 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 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實現了哈希環結構,並且指定了每個服務器節點的虛擬節點數量,同時實現了一個簡單的哈希方法,用於計算入參的哈希值,算法過程如下: - ①啟動時先根據指定的數量,映射對應的虛擬節點數量在哈希環上。 - ②通過計算客户端哈希值,然後在哈希環上取得大於該值的節點,然後返回對應的IP。 - 由於哈希環是取順時針方向的第一個節點作為處理請求的目標服務器,所以獲取大於該哈希值的節點中的第一個節點即可。 - ③如果哈希環中沒有大於客户端哈希值的節點,那麼則將這些客户端的請求分發到整個Map`上的第一台服務器,從此實現哈希閉環。

一致性哈希算法由於其特性,因此一般多被用於分佈式緩存中的集羣分片,尤其是MemCache的緩存分片,就是採用一致性哈希算法實現的,而Redis自身推出的RedisCluster分片集羣中,也借用了一致性哈希算法的思想,不過進行了改版實現,內部採用CRC16+HashSolt實現了緩存分片,但核心思想也是相同的。

當然,文中給出的算法過程都是較為簡單的實現,如若想要參考完整的實現,可以參考Dubbocom.alibaba.dubbo.rpc.cluster.loadbalance包,或參考SpringCloudRibboncom.netflix.loadbalancer包下的實現。

四、最小活躍數算法

   上述分析的基本算法、平滑輪詢加權、一致性哈希等算法都屬於靜態算法,也就是説這些算法配置後,並不會根據線上的實際運行情況進行調整,只會根據已配置的規則進行請求分發。
   最小活躍數算法則會根據線上的實際情況進行分發,能夠靈活的檢測出集羣中各個節點的狀態,能夠自動尋找並調用活躍度最低的節點處理請求,Java實現如下:
```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實現如下: ```java public class Servers { // 模擬的集羣節點信息,活躍數最開始默認為0 public static List 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詳解》

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

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

六、請求分發篇總結

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

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

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

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

「其他文章」