分佈式系統常見負載均衡算法及其nginx實現

一、概要

隨着系統日益龐大、邏輯業務越來越複雜,系統架構由原來的單一系統到垂直系統,發展到現在的分佈式系統。分佈式系統中,可以做到公共業務模塊的高可用,高容錯性,高擴展性,然而,當系統越來越複雜時,需要考慮的東西自然也越來越多,要求也越來越高,比如服務路由、負載均衡等。此文將針對負載均衡算法進行講解,不涉及具體的實現。

二、負載均衡算法

在分佈式系統中,多臺服務器同時提供一個服務,並統一到服務配置中心進行管理,消費者通過查詢服務配置中心,獲取到服務到地址列表,需要選取其中一臺來發起RPC遠程調用。如何選擇,則取決於具體的負載均衡算法,對應於不同的場景,選擇的負載均衡算法也不盡相同。負載均衡算法的種類有很多種,常見的負載均衡算法包括輪詢法、隨機法、源地址哈希法、加權輪詢法、加權隨機法、最小連接法、Latency-Aware等,應根據具體的使用場景選取對應的算法。

1、輪詢(Round Robin)法

輪詢很容易實現,將請求按順序輪流分配到後臺服務器上,均衡的對待每一臺服務器,而不關心服務器實際的連接數和當前的系統負載。使用輪詢策略的目的是,希望做到請求轉移的絕對均衡,但付出的代價性能也是相當大的。爲了保證pos變量的併發互斥,引入了重量級悲觀鎖synchronized,將會導致該輪詢代碼的併發吞吐量明顯下降。
輪詢法適用於機器性能相同的服務,一旦某臺機器性能不好,極有可能產生木桶效應,性能差的機器扛不住更多的流量。
nginx關鍵配置:

upstream servers{
server 192.168.1.160:8081;
server 192.168.1.160:8082;
}

2、隨機法

通過系統隨機函數,根據後臺服務器列表的大小值來隨機選取其中一臺進行訪問。由概率概率統計理論可以得知,隨着調用量的增大,其實際效果越來越接近於平均分配流量到後臺的每一臺服務器,也就是輪詢法的效果。
同樣地,它也不適用於機器性能有差異的分佈式系統。

3、隨機輪詢法

所謂隨機輪詢,就是將隨機法和輪詢法結合起來,在輪詢節點時,隨機選擇一個節點作爲開始位置index,此後每次選擇下一個節點來處理請求,即(index+1)%size。
這種方式只是在選擇第一個節點用了隨機方法,其他與輪詢法無異,缺點跟輪詢一樣。

4、源地址哈希法

源地址哈希法的思想是根據服務消費者請求客戶端的IP地址,通過哈希函數計算得到一個哈希值,將此哈希值和服務器列表的大小進行取模運算,得到的結果便是要訪問的服務器地址的序號。採用源地址哈希法進行負載均衡,相同的IP客戶端,如果服務器列表不變,將映射到同一個後臺服務器進行訪問。該方法適合訪問緩存系統,如果爲了增強緩存的命中率和單調性,可以用一致性哈希算法,相關博文在https://blog.csdn.net/okiwilldoit/article/details/51352743
nginx關鍵配置:

upstream servers{
# 相同的IP客戶端,如果服務器列表不變,將映射到同一個後臺服務器進行訪問
ip_hash;
server 192.168.1.160:8081;
server 192.168.1.160:8082;
}

5、加權輪詢(Weight Round Robin)法

不同的後臺服務器可能機器的配置和當前系統的負載並不相同,因此它們的抗壓能力也不一樣。跟配置高、負載低的機器分配更高的權重,使其能處理更多的請求,而配置低、負載高的機器,則給其分配較低的權重,降低其系統負載,加權輪詢很好的處理了這一問題,並將請求按照順序且根據權重分配給後端。Nginx的負載均衡默認算法是加權輪詢算法。

Nginx默認負載均衡算法簡介:

有三個節點{a, b, c},他們的權重分別是{a=5, b=1, c=1}。發送7次請求,a會被分配5次,b會被分配1次,c會被分配1次。

一般的算法可能是:
1、輪訓所有節點,找到一個最大權重節點;
2、選中的節點權重-1;
3、直到減到0,恢復該節點原始權重,繼續輪詢;
這樣的算法看起來簡單,最終效果是:{a, a, a, a, a, b, c},即前5次可能選中的都是a,這可能造成權重大的服務器造成過大壓力的同時,小權重服務器還很閒。
Nginx的加權輪詢算法將保持選擇的平滑性,希望達到的效果可能是{a, b, a, a, c, a, a},即儘可能均勻的分攤節點,節點分配不再是連續的。

Nginx加權輪詢算法實現:

1、概念解釋,每個節點有三個權重變量,分別是:
(1) weight: 約定權重,即在配置文件或初始化時約定好的每個節點的權重。
(2) effectiveWeight: 有效權重,初始化爲weight。
在通訊過程中發現節點異常,則-1;
之後再次選取本節點,調用成功一次則+1,直達恢復到weight;
此變量的作用主要是節點異常,降低其權重。
(3) currentWeight: 節點當前權重,初始化爲0。

2、算法邏輯
(1) 輪詢所有節點,計算當前狀態下所有節點的effectiveWeight之和totalWeight;
(2) currentWeight = currentWeight + effectiveWeight; 選出所有節點中currentWeight中最大的一個節點作爲選中節點;
(3) 選中節點的currentWeight = currentWeight - totalWeight;

基於以上算法,我們看一個例子:
這時有三個節點{a, b, c},權重分別是{a=4, b=2, c=1},共7次請求,初始currentWeight值爲{0, 0, 0},每次分配後的結果如下:

觀察到七次調用選中的節點順序爲{a, b, a, c, a, b, a},a節點選中4次,b節點選中2次,c節點選中1次,算法保持了currentWeight值從初始值{c=0,b=0,a=0}到7次調用後又回到{c=0,b=0,a=0}。
參考文檔爲:https://www.cnblogs.com/markcd/p/8456870.html

nginx關鍵配置:

upstream servers{
server 192.168.1.160:8081 weight=3;
server 192.168.1.160:8082 weight=2;
}

6、加權隨機(Weight Random)法

加權隨機法跟加權輪詢法類似,根據後臺服務器不同的配置和負載情況,配置不同的權重。不同的是,它是按照權重來隨機選取服務器的,而非順序。

7、最小連接數法

前面我們費盡心思來實現服務消費者請求次數分配的均衡,我們知道這樣做是沒錯的,可以爲後端的多臺服務器平均分配工作量,最大程度地提高服務器的利用率,但是,實際上,請求次數的均衡並不代表負載的均衡。因此我們需要介紹最小連接數法,最小連接數法比較靈活和智能,由於後臺服務器的配置不盡相同,對請求的處理有快有慢,它正是根據後端服務器當前的連接情況,動態的選取其中當前積壓連接數最少的一臺服務器來處理當前請求,儘可能的提高後臺服務器利用率,將負載合理的分流到每一臺服務器。

8、Latency-Aware

與方法7類似,該方法也是爲了讓性能強的機器處理更多的請求,只不過方法7使用的指標是連接數,而該方法用的請求服務器的往返延遲(RTT),動態地選擇延遲最低的節點處理當前請求。該方法的計算延遲的具體實現可以用EWMA算法來實現,它使用滑動窗口來計算移動平均耗時。具體代碼如下:

public class EWMA {
    private static final long serialVersionUID = 2979391326784043002L;

    //時間類型枚舉
    public static enum Time {
        MICROSECONDS(1),
        MILLISECONDS(1000),
        SECONDS(MILLISECONDS.getTime() * 1000),
        MINUTES(SECONDS.getTime() * 60),
        HOURS(MINUTES.getTime() * 60),
        DAYS(HOURS.getTime() * 24),
        WEEKS(DAYS.getTime() * 7);

        private long micros;

        private Time(long micros) {
            this.micros = micros;
        }

        public long getTime() {
            return this.micros;
        }
    }

    //三個alpha常量,這些值和Unix系統計算負載時使用的標準alpha值相同
    public static final double ONE_MINUTE_ALPHA = 1 - Math.exp(-5d / 60d / 1d);
    public static final double FIVE_MINUTE_ALPHA = 1 - Math.exp(-5d / 60d / 5d);
    public static final double FIFTEEN_MINUTE_ALPHA = 1 - Math.exp(-5d / 60d / 15d);
    private long window;
    private long alphaWindow;
    private long last;
    private double average;
    private double alpha = -1D;
    private boolean sliding = false;

    private long requests;//請求量
    private double weight;//權重

    public EWMA() {
    }

    public EWMA sliding(double count, Time time) {
        return this.sliding((long) (time.getTime() * count));
    }

    public EWMA sliding(long window) {
        this.sliding = true;
        this.window = window;
        return this;
    }

    public EWMA withAlpha(double alpha) {
        if (!(alpha > 0.0D && alpha <= 1.0D)) {
            throw new IllegalArgumentException("Alpha must be between 0.0 and 1.0");
        }
        this.alpha = alpha;
        return this;
    }

    public EWMA withAlphaWindow(long alphaWindow) {
        this.alpha = -1;
        this.alphaWindow = alphaWindow;
        return this;
    }

    public EWMA withAlphaWindow(double count, Time time) {
        return this.withAlphaWindow((long) (time.getTime() * count));
    }

    /**
     * 默認使用當前時間更新移動平均值
     */
    public void mark(){
        mark(System.currentTimeMillis());
    }

    /**
     * 更新移動平均值
     * @param time
     */
    public void mark(long time){
        if(this.sliding){
            //如果發生時間間隔大於窗口,則重置滑動窗口
            if(time-this.last > this.window){
                this.last = 0;
            }
        }
        if(this.last == 0){
            this.average = 0;
            this.requests = 0;
            this.last = time;
        }
        // 計算上一次和本次的時間差
        long diff = time - this.last;
        // 計算alpha
        double alpha = this.alpha != -1.0 ? this.alpha : Math.exp(-1.0*((double)diff/this.alphaWindow));
        // 計算當前平均值
        this.average = (1.0-alpha)*diff + alpha*this.average;
        this.last = time;
        // 請求量加1
        this.requests++;
        // 計算權重值
        // this.weight = this.average != 0 ? this.requests/this.average : -1;
    }


    //返回mark()方法多次調用的平均值
    public double getAverage() {
        return this.average;
    }

    //按照特定的時間單位來返回平均值,單位詳見Time枚舉
    public double getAverageIn(Time time) {
        return this.average == 0.0 ? this.average : this.average / time.getTime();
    }

    //返回特定時間度量內調用mark()的頻率
    public double getAverageRatePer(Time time) {
        return this.average == 0.0 ? this.average : time.getTime() / this.average;
    }

    //返回mark()方法多次調用的權重值
    public double getWeight() {return this.weight;}
    public long getRequests() {return this.requests;}

    public static   void main(String[] args) {
        //建立1分鐘滑動窗口EWMA實例
        EWMA ewma = new EWMA().
                    sliding(1.0, Time.MINUTES).
                    withAlpha(EWMA.ONE_MINUTE_ALPHA).
                    withAlphaWindow(1.0, EWMA.Time.MINUTES);

        int randomSleep = 0;
        long markVal = System.currentTimeMillis() * 1000;//單位爲微秒

        try {
            ewma.mark(markVal);
            for (int i = 1; i <= 10000000; i++) {
                randomSleep = util.get_rand_32() % 1500;
                markVal += randomSleep;
                ewma.mark(markVal);
                if (i % 1000 == 0) {
                    System.out.println("Round: " + i + ", Time: " + randomSleep
                                        + ", Requests: " + ewma.getRequests()
                                        + ", Average: " + ewma.getAverage()
                                        + ", Weight:" + ewma.getWeight());
                }
            }
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

Twitter的負載均衡算法基於這種思想,不過實現起來更加簡單,即P2C算法。首先隨機選取兩個節點,在這兩個節點中選擇延遲低,或者連接數小的節點處理請求,這樣兼顧了隨機性,又兼顧了機器的性能,實現很簡單。
具體參見:https://linkerd.io/1/features/load-balancing/。

9、fair

fair方法比起之前的幾個算法要比較靈活一點是按照後端服務器的響應時間來進行分配,響應時間短的優先分配。

nginx關鍵配置:

upstream servers{
fair;
server 192.168.1.160:8081;
server 192.168.1.160:8082;
}

參考文章:https://blog.csdn.net/okiwilldoit/article/details/81738782
https://blog.csdn.net/youanyyou/article/details/78990133

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