(四)網路程式設計之請求分發篇:負載均衡靜態排程演算法、平滑輪詢加權、一致性雜湊、最小活躍數演算法實踐!
引言
先如今所有的技術棧中,只要一談關於高可用、高併發處理相關的實現,必然會牽扯到叢集這個話題,也就是部署多臺伺服器共同對外提供服務,從而做到提升系統吞吐量,優化系統的整體效能以及穩定性等目的。
當多臺機器上部署相同服務節點時,客戶端的傳送請求訪問就會出現一個必須要解決的問題:客戶端的請求到底該交由哪臺伺服器處理?這點則由負載均衡策略來決定,也就是說:請求具體會被分發到哪臺伺服器,是排程演算法來決定的。
在上篇《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
// 隨機權重演算法 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
// 權重類 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
// 計算總權重值,只需要計算一次,因此放在靜態程式碼塊中執行
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:8080:
3次
-
44.120.110.002:8081:
2次
-
44.120.110.003:8082:
1`次
這是不是很神奇?如何做到的呢,接下來簡單聊一下該演算法的核心思想。
在之前的權重演算法中,伺服器列表中只有兩個值:伺服器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
個點組成,如下:
圓環的正上方第一個點代表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
都是相同的,所以在伺服器數量不變的情況,相同的雜湊條件進行雜湊取模,最終計算出來的值永遠都是相同的,然後再通過計算出的值,在雜湊環結構上進行順時針查詢,能夠定位到的伺服器也是相同的,所以相同屬性的請求永遠會落入到同一伺服器。
3.3、雜湊環的對映偏移問題
經過上述分析後,好像發現一致性雜湊演算法沒啥大毛病,但上述中屬於“理想狀態”:
可偏偏理想很豐滿,現實卻很骨感,實際對映伺服器IP
的過程中,可能會出現如下情況:
由於伺服器IP
雜湊取模後,無法確保雜湊得到的數字能夠均勻分佈,因此就有可能造成如上情況,所有的伺服器IP
都被對映在“一塊兒”,最終導致A
伺服器承載了90%
以上的訪問壓力。
3.4、對映偏移造成的宕機連鎖反應
接上述,如果伺服器IP
對映在雜湊環上出現偏移,在大流量的衝擊下,這種情況很容易導致整個叢集崩塌,首先是A
扛不住併發衝擊,宕機下線,緊接著流量交給B
,B
也扛不住,接著宕機,然後C.....
,因此雜湊環對映偏移問題可能會造成的一系列連鎖反應,所以在一致性雜湊演算法中,為了確保整個叢集的健壯性,提出了一種虛擬節點的概念來解決此問題。
虛擬節點其實本質上就是真實伺服器節點的複製品,虛擬節點對映的IP
都是指向於真實伺服器的,就類似平時.EXE
軟體的快捷方式,現在為QQ
建立了一個快捷方式,然後拷貝到了十個不同的目錄下,但本質上這十個快捷方式指向的啟動檔案都是相同exe
程式,雜湊環中的虛擬節點也同理,如下:
從上圖中可以看出,A、B、C、D
四臺伺服器分別都映射出了一個虛擬節點,引入虛擬節點後會明顯感覺出來,原本A
伺服器需要承載90%
以上的流量,但此刻映射出的虛擬節點大大減輕了A
的壓力,將流量均攤到了叢集中的每個節點。
在一致性雜湊演算法的實際應用場景中,絕非只對映一個虛擬節點,往往會為一個真實節點對映數十個虛擬節點,以便於減小雜湊環偏移所帶來的影響。同時,虛擬節點的數量越多,請求在分發時也能更均勻的分佈,雜湊環最終結構如下:
3.5、Java實現一致性雜湊演算法
講了這麼多,那麼一致性雜湊演算法究竟如何實現呢?接下來一起看看:
```java
public class Servers {
public static List
public class ConsistentHash {
// 使用有序的紅黑樹結構,用於實現雜湊環結構
private static TreeMap
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
實現了快取分片,但核心思想也是相同的。
當然,文中給出的演算法過程都是較為簡單的實現,如若想要參考完整的實現,可以參考
Dubbo
的com.alibaba.dubbo.rpc.cluster.loadbalance
包,或參考SpringCloudRibbon
的com.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
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
模組。
六、請求分發篇總結
在本文中,對於比較常用的請求分發演算法進行了剖析及手寫實踐,其中提到了較為傳統的靜態排程演算法:輪詢、隨機、加權、一致性雜湊等,也談到了一些較為智慧的動態演算法:最小活躍數、最優響應等,但需要牢記的一點是:
並非越智慧的演算法越好,越是併發高、流量大的場景下,反而選用最基本的演算法更合適,例如微信的紅包業務,就是採用最基本的輪詢演算法進行叢集排程。
那這又是為何呢?因為越智慧的排程演算法,進行節點選擇時的開銷會更大,如果你對於文中給出的排程演算法實現都一一執行過,那麼大家會明顯感知出:越到後面的演算法,分發請求的速度越慢。
因此在面臨巨大訪問壓力的情景中,選擇最簡單的演算法反而帶來的收益更高,但前提是需要叢集中所有的節點硬體配置都一致,所有節點分配的資源都相同,輪詢演算法則是最佳的排程演算法。
- 全解MySQL終章:這份爆肝30W字的資料庫寶典贈與有緣的你!
- 追憶四年前:一段關於我被外企CTO用登入註冊吊打的不堪往事
- (十一)Netty實戰篇:基於Netty框架打造一款高效能的IM即時通訊程式
- (四)MySQL之索引初識篇:索引機制、索引分類、索引使用與管理綜述
- (九)MySQL之MVCC機制:為什麼你改了的資料我還看不見?
- (十)全解MySQL之死鎖問題分析、事務隔離與鎖機制的底層原理剖析
- (二十八)MySQL面試通關祕籍:這次你也可以在簡歷寫上精通MySQL!
- (一)全解MySQL之架構篇:自頂向下深入剖析MySQL整體架構!
- (九)Java網路程式設計無冕之王-這回把大名鼎鼎的Netty框架一網打盡!
- (八)MySQL鎖機制:高併發場景下該如何保證資料讀寫的安全性?
- (十五)MySQL命令大全:以後再也不用擔心忘記SQL該怎麼寫啦~
- (七)MySQL事務篇:ACID原則、事務隔離級別及事務機制原理剖析
- 深入理解SpringMVC工作原理,像大牛一樣手寫SpringMVC框架
- (三)MySQL之庫表設計篇:一、二、三、四、五正規化、BC正規化與反正規化詳解!
- (五)MySQL索引應用篇:建立索引的正確姿勢與使用索引的最佳指南!
- (六)MySQL索引原理篇:深入資料庫底層揭開索引機制的神祕面紗!
- (五)網路程式設計之流量接入層設計:基於效能怪獸從零構建日均億級吞吐量的閘道器架構!
- (四)網路程式設計之請求分發篇:負載均衡靜態排程演算法、平滑輪詢加權、一致性雜湊、最小活躍數演算法實踐!
- (三)Nginx一網打盡:動靜分離、壓縮、快取、黑白名單、跨域、高可用、效能優化...想要的這都有!
- Redis綜述篇:與面試官徹夜長談Redis快取、持久化、淘汰機制、哨兵、叢集底層原理!