負載均衡的那些算法們

sushi1025 8年前發布 | 77K 次閱讀 算法 負載均衡 集群/負載均衡

上周發了問卷,想了解一下大家對老王有沒有什么建議,然后好多朋友都投了票,想了解編程技術和服務器架構的干貨,所以接下來會先聊聊編程和架構相關的算法,然后大概在6月下旬會跟大家聊聊面試那些事兒(老王到目前大約參加了幾百次的面試,可以從面試官的角度來聊聊不一樣的面試)。老王聊技術有個特點,就是絕不假大空,只求貼地飛行。所以,聊的東西一定會跟實際有關聯,大家在平時也有可能用得著。

今天跟大伙兒聊的是負載均衡相關的一些算法。老王在百度的時候(估計是 5-6 年前),寫過一個通用的基礎庫(不知道現在還有沒有部門在用),用來做不同系統間負載均衡。太細節的東東估計想不起來了,不過基本的算法可以跟大家做做分享。

那第一個問題: what's load-balance?

假設我有兩個模塊(或者兩個系統): module-A 和 module-B , A 依賴 B 提供服務。當用戶請求過來的時候, A 就會去請求 B ,讓 B 根據請求進行某些處理(比如:根據單詞 id 查對應的單詞),完成后把結果返回給 A , A 再對這個結果進行處理。然而,為了保證服務穩定,有可能 B 服務有很多臺機器, A 遇到這個時候就犯難了:我該去找 B 的哪臺機器取數據呢?

最常見的一個 case 就是 nginx :比如我們的 web 邏輯服務器是 jetty 或者 tomcat ,一般會有多臺, nginx 就需要配置這多臺機器:

upstream simplemain.com {

server  192.168.1.100:8080;

server  192.168.1.101:8080;

}

那這些機器是怎么樣選擇的呢?實際就是負載均衡算法。

老王對負載均衡的理解,他應該包含兩個層面:

1 、負載:就是后端系統的承載能力。比如同等條件下,一個 1 核 cpu-1G 內存的機器的承載能力一般會比 8 核 cpu-8G 內存的機器要差;相同配置下,一個 cpu 利用率為 80% 的機器比 30% 的承載能力一般要差等等。

2 、均衡:保證后端請求的平衡。比如:在同等情況下,分配到多臺機器的請求要相當;有些情況下,同一用戶盡可能分配到同一臺機器等等。

所以,負載均衡的算法實際上就是解決跨系統調用的時候,在考慮后端機器承載情況的前提下,保證請求分配的平衡和合理。

那第二個問題隨之而來: why ?

為什么要有負載均衡呢?

1 、很明顯,如果我們不去考慮后端的承載情況,有可能直接就把某臺機器壓垮了(比如 cpu 利用率已經 80% 了,再給大量的請求直接就干死了),更嚴重的會直接造成雪崩(一臺壓死了,對應的請求又壓倒其他某臺機器上,又干死一臺……),從而致使服務癱瘓。

2 、如果我們均衡算法選的不好,就會導致后端資源浪費。比如:如果選擇一致 Hash 算法,可以很好利用 cache 的容量。而如果用隨機,有可能就會讓 cache 效果大打折扣(每臺機器上都要緩存幾乎相同的內容)。

所以,用負載均衡應該是一個比較好的選擇。

那就解決第三個問題吧: how

按照之前的思路,我們還是分成兩個部分來講:負載 & 均衡。

1 、先來看 負載算法

既然要解決后端系統的承載能力,那我們就有很多方式,常見的有以下幾種:

A 、簡單粗暴有效的:手工配置!

大家是不是覺得這個聽起來很山寨呢?其實不是。這種方式對于中小系統來講是最有效最穩定的。因為后端機器的性能配置、上面部署了哪些服務、還能有多大的承載能力等等,我們是最清楚的。那我們在配置的時候,就可以明確的告訴調用者,你只能分配多大的壓力到某臺服務器上,多了不行!

比如,我們經常看到 nginx 的配置:

upstream simplemain.com {

server  192.168.1.100:8080 weight=30 ;

server  192.168.1.101:8080 weight=70 ;

}

就是說,雖然有兩臺后端的服務器,但是他們承載能力是不一樣的,有一個能力更強,我們就給他 70% 的壓力;有一個更弱,我們就給他 30% 的壓力。這樣, nginx 就會把更多的壓力分配給第二臺。

這種方式配置簡單,而且很穩定,基本不會產生分配的抖動。不過,帶來的問題就是分配很固定,不能動態調整。如果你的后端服務器有一段時間出現性能抖動(比如有其他服務擾動了機器的穩定運行,造成 cpu 利用率一段時間升高),前端調用者就很難根據實際的情況重新分配請求壓力。所以,引入了第二種方法。

B 、動態調整。

這種方案會根據機器當前運行的狀態和歷史平均值進行對比,發現如果當前狀態比歷史的要糟糕,那么就動態減少請求的數量。如果比歷史的要好,那么就可以繼續增加請求的壓力,直到達到一個平衡。

具體怎么做呢?

首先,剛開始接入的時候,我們可以計算所有機器對于請求的響應時間,算一個平均值。對于響應較快的機器,我們可以多分配一些請求。如果請求多了導致響應減慢,這個時候就會逐步和其他機器持平,說明這臺機器達到了相應的平衡。

接著,當接入達到平衡以后,就可以統計這臺機器平均的響應時間。如果某一段響應請求變慢了(同時比其他機器都要慢),就可以減少對他請求的分配,將壓力轉移一部分到其他機器,直到所有機器達到一個整體的平衡。

這種方案是不是看起來很高級呢?他的好處在于可以動態的來平衡后面服務器的處理能力。不過,任何事物都有兩面性。這種方案如果遇到極端情況,可能會造成系統雪崩!當某臺機器出現短暫網絡抖動的時候,他的響應就可能變慢,這個時候,前端服務就會將他的請求分配給其他的機器。如果分配的很多,就有可能造成某些機器響應也變慢。然后又將這些機器的請求分配給另外的……如此這般,那些勤勤懇懇的機器終將被這些請求壓死。

所以,更好的方案,將兩者結合。一方面靜態配置好承載負荷的一個范圍,超過最大的就扔掉;另一方面動態的監控后端機器的響應情況,做小范圍的請求調整。

2 、 均衡算法

均衡算法主要解決將請求如何發送給后端服務。經常會用到以下四種算法:隨機( random )、輪訓( round-robin )、一致哈希( consistent-hash )和主備( master-slave )。

比如:我們配置 nginx 的時候,經常會用到這樣的配置:

upstream simplemain.com {

ip_hash ;

server  192.168.1.100:8080;

server  192.168.1.101:8080;

}

這個配置就是按 ip 做 hash 算法,然后分配給對應的機器。

接下來我們詳細的看看這幾個算法是如何來工作的。

A 、隨機算法

顧名思義,就是在選取后端服務器的時候,采用隨機的一個方法。在具體講這個算法之前,我們先來看看一個例子,我們寫如下 C 語言的代碼 :

#include <stdlib.h>

#include <stdio.h>

int main()

{

srand( 1234 );

printf( " %d\n " , rand());

return 0 ;

}

我們用 srand 函數給隨機算法播了一個 1234 的種子,然后再去隨機數,接著我們編譯和鏈接 gcc rand.c -o rand

按理想中說,我們每次運行 rand 這個程序,都應該得到不一樣的結果,對吧。可是……

可以看到,我們每次運行的結果都是一樣的!!出了什么問題呢?

我們說的隨機,在計算機算法中通常采用的是一種偽隨機的算法。我們會先給算法放一個種子,然后根據一定的算法將種子拿來運算,最后得到一個所謂的隨機值。我們將上面的算法做一個小小的改動,將 1234 改為 time(NULL) ,效果就不一樣了:

#include <stdlib.h>

#include <stdio.h>

#include <time.h>

int main()

{

srand(( int )time( NULL ));

printf( " %d\n " , rand());

return 0 ;

}

time 這個函數會獲取當前秒數,然后將這個值作為種子放入到偽隨機函數,從而計算出的偽隨機值會因為秒數不一樣而不同。

具體來看一下 java 源代碼里如何來實現的。我們常用的 java 隨機類是 java.util.Random 這個類。他提供了兩個構造函數:

public Random() {

this ( seedUniquifier () ^ System. nanoTime ());

}

public Random( long seed) {

if (getClass() == Random. class )

this . seed = new AtomicLong( initialScramble (seed));

else {

//subclass might have overriden setSeed

this . seed = new AtomicLong();

setSeed(seed);

}

}

我們可以看到,這個類也是需要一個種子。然后我們獲取隨機值的時候,會調用 next 函數:

protected int next ( int bits) {

long oldseed, nextseed;

AtomicLong seed = this . seed ;

do {

oldseed = seed.get();

nextseed = (oldseed * multiplier + addend ) & mask ;

} while (!seed.compareAndSet(oldseed, nextseed));

return ( int )(nextseed>>> (48 - bits));

}

這個函數會利用種子進行一個運算,然后得到隨機值。所以,我們看起來隨機的一個算法,實際上跟時間是相關的,跟算法的運算是相關的。并不是真正的隨機。

好了,話歸正題,我們用隨機算法怎么樣做請求均衡呢?比如,還是我們之前那個 nginx 配置:

upstream simplemain.com {

server  192.168.1.100:8080 weight=30 ;

server  192.168.1.101:8080 weight=70 ;

}

我們有兩臺機器,分別需要承載 30% 和 70% 的壓力,那么我們算法就可以這樣來寫(偽代碼):

bool res = abs(rand()) % 100 < 30

這句話是什么意思呢?

1 、我們先產生一個偽隨機數: rand()

2 、將這個偽隨機數的轉化為非負數 : abs(rand())

3 、將這個數取模 100 ,將值轉化到 [0,100) 的半開半閉區間: abs(rand()) % 100

4 、看這個數是否落入了前 30 個數的區間 [0,30) : abs(rand()) % 100 < 30

如果隨機是均勻的話,他們落到 [0,100) 這個區間里一定是均勻的,所以只要在 [0,30) 這個區間里,我們就分給第一臺機器,否則就分給第二臺機器。

其實這里講述的只是一種方法,還有很多其他的方法,大家都可以去想想。

隨機算法是我們最最最最最最常用的算法,絕大多數情況都使用他。首先,從概率上講,它能保證我們的請求基本是分散的,從而達到我們想要的均衡效果;其次,他又是無狀態的,不需要維持上一次的選擇狀態,也不需要均衡因子等等。總體上,方便實惠又好用,我們一直用他!

B 、輪訓算法

輪訓算法就像是挨個數數一樣( 123-123-123 ……),一個個的輪著來。

upstream simplemain.com {

server  192.168.1.100:8080 weight=30 ;

server  192.168.1.101:8080 weight=70 ;

}

還是這個配置,我們就可以這樣來做(為了方便,我們把第一臺機器叫做 A ,第二臺叫做 B ):

1 、我們先給兩臺機器做個排序的數組: array = [ABBABBABBB]

2 、我們用一個計數指針來標明現在數組的位置: idx = 3

3 、當一個請求來的時候,我們就把指針對應的機器選取出來,并且指針加一,挪到下一個位置。

這樣,十個請求,我們就可以保證有 3 個一定是 A , 7 個一定是 B 。

輪訓算法在實際中也有使用,但是因為要維護 idx 指針,所以是有狀態的。我們經常會用隨機算法取代。

C 、一致哈希算法

這個算法是大家討論最對,研究最多,神秘感最強的一個算法。老王當年剛了解這個算法的時候,也是花了很多心思去研究他。在百度上搜:“一致 hash ”,大概有 321 萬篇相關文章。

大家到網上搜這個算法,一般都會講將 [0,2 32 ) 所有的整數投射到一個圓上,然后再將你的機器的唯一編碼(比如: IP )通過 hash 運算得到的整數也投射到這個圓上( Node-A 、 Node-B )。如果一個請求來了,就將這個請求的唯一編碼(比如:用戶 id )通過 hash 算法運算得到的整數也投射到這個圓上( request-1 、 request-2 ),通過順時針方向,找到第一個對應的機器。如下圖:

當時老王看了這些文章也覺得很有道理,但是過了一段時間就忘了……自己琢磨了一段時間,不斷的問自己,為什么要這樣做呢?

過了很久,老王有了一些體會。實際上,一致 Hash 要解決的是兩個問題:

1 、散列的不變性:就是同一個請求(比如:同一個用戶 id )盡量的落入到一臺機器,不要因為時間等其他原因,落入到不同的機器上了;

2 、異常以后的分散性:當某些機器壞掉(或者增加機器),原來落到同一臺機器的請求(比如:用戶 id 為 1 , 101 , 201 ),盡量分散到其他機器,不要都落入其他某一臺機器。這樣對于系統的沖擊和影響最小。

有了以上兩個原則,這個代碼寫起來就很好寫了。比如我們可以這樣做 ( 假定請求的用戶 id=100 ):

1 、我們將這個 id 和所有的服務的 IP 和端口拼接成一個字符串:

str1 = "192.168.1.100:8080-100"

str2 = "192.168.1.101:8080-100"

2 、對這些字符串做 hash ,然后得到對應的一些整數:

iv1 = hash(str1)

iv2 = hash(str2)

3 、對這些整數做從大到小的排序,選出第一個。

好,現在來看看我們的這個算法是否符合之前說的兩個原則。

1 、散列的不變性:很明顯,這個算法是可重入的,只要輸入一樣,結果肯定一樣;

2 、異常以后的分散性:當某臺機器壞掉以后,原本排到第一的這些機器就被第二位的取代掉了。只要我們的 hash 算法是分散的,那么得到排到第二位的機器就是分散的。

所以,這種算法其實也能達到同樣的目的。當然,可以寫出同樣效果的算法很多很多,大家也可以自己琢磨琢磨。最根本的,就是要滿足以上說的原則。

一致 Hash 算法用的最多的場景,就是分配 cache 服務。將某一個用戶的數據緩存在固定的某臺服務器上,那么我們基本上就不用多臺機器都緩存同樣的數據,這樣對我們提高緩存利用率有極大的幫助。

不過硬幣都是有兩面的,一致 Hash 也不例外。當某臺機器出問題以后,這臺機器上的 cache 失效,原先壓倒這臺機器上的請求,就會壓到其他機器上。由于其他機器原先沒有這些請求的緩存,就有可能直接將請求壓到數據庫上,造成數據庫瞬間壓力增大。如果壓力很大的話,有可能直接把數據庫壓垮。

所以,在考慮用一致 Hash 算法的時候,一定要估計一下如果有機器宕掉后,后端系統是否能承受對應的壓力。如果不能,則建議浪費一點內存利用率,使用隨機算法。

D 、主備算法

這個算法核心的思想是將請求盡量的放到某個固定機器的服務上(注意這里是盡量),而其他機器的服務則用來做備份,如果出現問題就切換到另外的某臺機器的服務上。

這個算法用的相對不是很多,只是在一些特殊情況下會使用這個算法。比如,我有多臺 Message Queue 的服務,為了保證提交數據的時序性,我就想把所有的請求都盡量放到某臺固定的服務上,當這臺服務出現問題,再用其他的服務。

那怎么做呢?最簡單的做法,我們就對每臺機器的 IP : Port 做一個 hash ,然后按從大到小的順序排序,第一個就是我們想要的結果。如果第一個出現問題,那我們再取第二個: head(sort(hash("IP:Port1"), hash("IP:Port2"), …… ))

當然,還有其他做法。比如:老王做的 Naming Service 就用一個集中式的鎖服務來判定當前的主服務器,并對他進行鎖定。

好了,關于負載均衡相關的算法就大體上說這么多。其實還有一個相關話題沒有說,就是健康檢查。他的作用就是對所有的服務進行存活和健康檢測,看是否需要提供給負載均衡做選擇。如果一臺機器的服務出現了問題,健康檢查就會將這臺機器從服務列表中去掉,讓負載均衡算法看不到這臺機器的存在。這個是給負載均衡做保障的,但是可以不劃在他的體系內。不過也有看法是可以將這個也算在負載均衡算法中。因為這個算法的實現其實也比較復雜,老王這次就不講這個算法了,可以放到接下來的文章中來分析。

==== 感謝的分割線 ====

1 、上周做了一個問卷,想了解一下大家都關注哪些技術方向。好多盆友都投了票,給老王提了建議和想法,讓老王對接下來的扯淡有了更多的思考;

2 、有幾位盆友問老王怎么沒開通評論和打賞,準備要給老王打個賞。更有一位姓姜的盆友加了老王的微信,硬給老王發了紅包(是老王收到的第一個賞哦,老王會永遠記住的)。讓老王感動的不行。本來老王寫這些東東沒太想其他的,就想把多年的積累記錄下來,分享給更多的人,所以收到大家的贊賞真的很感動。也巧,這周收到微信的原創邀請和評論了 ~

3 、好幾位盆友還將自己未來的發展方向找老王來聊。這是把自己的未來跟老王做分享和交流,這是對老王最大的信任!

所以,老王要對所有支持我的朋友深深的鞠一躬,謝謝各位的支持與信任!

來自: http://mp.weixin.qq.com/s?__biz=MzA3MDExNzcyNA==&mid=2650392075&idx=1&sn=fca2ebeca258e15f78a43c44bbb6153d&scene=0#wechat_redirect

 

 本文由用戶 sushi1025 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!