【負載均衡】常見的負載均衡算法的實現與應用

所謂負載均衡就是將外部發送過來的請求均勻或者根據某種算法分配到對稱結構中的某一臺服務器中。負載均衡可以分爲硬件負載均衡和軟件負載均衡,常見的硬件負載均衡有F5、Array等,但是這些設備都比較昂貴。相比之下,利用軟件來實現負載均衡就比較簡單了,常見的像是 Nginx 的反向代理負載均衡。

這篇文章並不去細說 Nginx 這類軟件的具體配置,只是着重來了解幾種常見的負載均衡算法的實現(本文使用Java描述)與應用。對於我們來講,所瞭解的最基本的負載均衡算法包括了:隨機、輪詢、一致性Hash等幾種方式,接下來就來詳細介紹這幾種算法:

一、隨機

1. 完全隨機

所謂完全隨機就是完全沒有規律可言,每次隨機種IP列表中選取一個,將請求打到該服務器上:

public class ServerIps {
    
    public static final List<String> LIST = Arrays.asList(
    	"192.168.0.1",
        "192.168.0.2",
        "192.168.0.3",
        "192.168.0.4",
        "192.168.0.5"
    );
}
public class Random {
    
    public static String getServer() {
        java.util.Random = new java.util.Random();
        int rand = random.nextInt(ServerIps.LIST.size());
        
        return ServerIps.LIST.get(rand);
    }
    
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++) {
            System.out.println(getServer());
        }
    }
}

完全隨機是最簡單的負載均衡算法了,實現起來也很簡單。但是我們在日常生產過程中,機器的處理效率肯定是不同的,有些機器處理得快,有些機器處理得慢,完全隨機的缺點在此刻就很明顯了,無法將更多的請求分配到更好的服務器上,由此就有了加權隨機的思想了。

2. 加權隨機

加權隨機常見的有兩種實現方式,現在先來了解第一種方式,先構建一個ServerIps類先,如下:

public class ServerIps {
    
    public static final Map<String, Integer> WEIGHT_LIST = new LinkedHashMap<String, Integer>();
    
    static {
        WEIGHT_LIST.put("192.168.0.1", 1);
        WEIGHT_LIST.put("192.168.0.2", 8);
        WEIGHT_LIST.put("192.168.0.3", 3);
        WEIGHT_LIST.put("192.168.0.4", 6);
        WEIGHT_LIST.put("192.168.0.5", 5);
    }
}

首先這種實現方式的想法很簡單,就是通過上面的服務器IP的List,再去構建一次List:如果一個IP服務器的權重爲8,那麼就往List裏面添加8次該對應的IP地址,如果權重爲3,那麼就添加3次,以此類推。這樣的話,結合前面的完全隨機實現,那麼這樣權重越大的,在List中出現的比例也越高,被選中的機率當然也會更大:

public class WeightRandom {
    
    public static String getServer() {
        // 構建一個新的List
        List<String> ips = new ArrayList<>();
        
        for(String ip : ServerIps.WEIGHT_LIST.keySet()) {
            Integer weight = ServerIps.WEIGHT_LIST.get(ip);
            
            for(int i = 0; i < weight; i++) {
                ips.add(ip);
            }
        }
        
        java.util.Random random = new java.util.Random();
        int randomPos = random.nextInt(ips.size());
        
        return ips.get(randomPos);
    }
    
    public static void main(String[] args) {
        // 連續調用10次
        for(int i = 0; i < 10; i++) {
            System.out.println(getServer());
        }
    }
}

上面代碼可以自行測試一下,最後結構應該就是權重高的服務器被選中的機率更高。但是這也有一種很明顯的缺點,如果現在的權重是像下面這樣配置呢?

WEIGHT_LIST.put("192.168.0.1", 998);
WEIGHT_LIST.put("192.168.0.2", 13);
// ...

這樣很明顯就會帶來一個新的問題,服務器中的新建的List動不動就會有上萬條記錄,那麼此時服務器豈不是要白白浪費掉一部分內存去維護一個數組,所以此時我們就有了下面更優的實現。

3. 加權隨機優化

假設我們現在有3臺服務器,A服務器的權重爲3,B服務器的權重爲5,C服務器的權重爲1。然後我們隨機生成一個數:

  • 如果生成的隨機數爲1,1 ≤ 3,那麼此時請求在落在A的區間段,請求應該交由A來處理;
  • 如果生成的隨機數爲5,那麼此時 3 < 5,所以不在A區間,而 5 - 3 = 2 < 5,那麼此時應該落在B區間;
  • 如果生成的隨機數爲9,那麼 9 > 3 + 5,所以不可能落在A區間或者B區間,而剛好後面C的權重爲,落在C區間。

用圖展示會更直觀些,假設下面數組下標從1開始:

當然我們不可能真的像圖中那樣再去維護一個數組了,這樣不也和剛剛那樣白佔內存麼。所以每次但一個隨機數過來時,我們會先去判斷它是否小於A的權重,如果小於,那麼就是請求發到A,如果不小於,注意,這裏就得將隨機數減去A的權重,然後下一次循環再和B的權重比,如果小於B的權重,那麼就是請求到B,具體我們可以看下代碼實現:

public class WeightRandomV2 {
    
    public static String getServer() {
        int totalWeight = 0;
        for(Integer weight : ServerIps.WEIGHT_LIST.values()) {
            totalWeight += weight;
        }
        
        java.util.Random random = new java.util.Random();
        int pos = random.nextInt(totalWeight);
        
        for(String ip : ServerIps.WEIGHT_LIST.keySet()) {
            Integer weight = ServerIps.WEIGHT_LIST.get(ip);
            
            if(pos < weight) {
                return ip;
            }
            
            pos = pos - weight;
        }
        
        return "";
    }
}

二、輪詢

1. 完全輪詢

完全輪詢的思想也十分簡單,大家輪着來嘛,這次請求打到A上,下次就到B,再下次就是C了,這種實現的方式也很簡單,代碼實現如下:

public clsss RoundRobin {
    
    private static Integer pos = 0;
    
    public String String getServer() {
        if (pos >= ServerIps.LIST.size()) {
            pos = 0;
        }
        String ip = ServerIps.LIST.get(pos);
        pos++;
        
        return ip;
    }
}

2. 加權輪詢

完全輪詢缺點和完全隨機基本無異,當遇見性能較好的機器時,它的性能無法展示出來,當遇見性能較差的機器時,它的弊端倒是很容易顯現,由此我們也需要來實現加權輪詢。同樣的,加權輪詢也像加權隨機那樣有兩種實現方式,一種靠着擴展列表,以外一種靠着偏移量,這裏就簡單演示下後者,畢竟後者的實現更優。

這裏我們加上些簡單的優化,請求是從前面發過來的,帶有一個RequestID,那麼我們就利用這個ID來幫助我們的輪詢:

我們先創建一個RequestId模仿請求ID,代碼如下:

public class RequestId {
    
    public static Integer num = 0;
    
    public static Integer getAndIncrement() {
        return num++;
    }
}

接下來我們就利用這個ID,然後進行輪詢,代碼如下:

public class WeightRoundRobin {
 
    public static String getServer() {
        int totalWeight = 0;
        for (Integet weight : ServerIps.WEIGHT_LIST.values()) {
            totalWeight += weight;
        }
        
        Integer requestId = Request.getAndIncrement();
        
        Integer pos = requestId % totalWeight;
        
        for(String ip : ServerIps.WEIGHT_LIST.keySet()) {
            Integer weight = ServerIps.WEIGHT_LIST.get(ip);
            
            if(pos < weight) {
                return ip;
            }
            
            pos = pos - weight;
        }
        
        return "";
    }
}

通過上面方式我們確實能實現加權輪詢,但是這種輪詢是連着來到,意思也就說,如果有請求過來,那麼如果A服務器的權重爲3,就會有連續3個請求打到A上,那麼B此時卻沒有請求來訪問,等到A服務器經歷3個請求後,接下來又有連續的5個請求打到B上,依次類推。

A A A B B B B B C

這種實現也會來帶很大的弊端,所以我們實際上是既要有輪詢,輪詢中間最好還是要有交叉,由此有了平滑加權輪詢的思想。

3. 平滑加權輪詢

首先我們需要先來了解靜態權重與動態權重的概念:

  • 靜態權重:權重由用戶自己設定,在輪詢過程中一直不變化,像上面幾種加權方式就是靜態權重;
  • 動態權重:動態權重的思想與靜態權重剛好相反,在輪詢的過程中動態地變換各機器的權重,但是最終的加權效果不能與靜態權重不同,只是用來實現交叉輪詢的效果。

接下來就講解一種動態權重實現的算法,假設用戶給A、B、C三臺服務器配置的權重爲3、5、1,我們將3、5、1稱爲固定權重,在算法過程中變化的權重稱爲動態權重:

  • 請求第一次過來,此時A、B、C的權重分別是3、5、1,在這裏面 5 是大的,所對應的就是B服務器,將請求打到B服務器,接下來將我們剛剛所選中的服務器的權重減去所有機器的權重加起來的總值,作爲接下來的動態權重,沒被選中的服務器權重不變。也就是B服務器:5 - (3 + 5 + 1) = -4;那麼第一次請求結束後三臺服務器的動態權重爲:3、-4、1;
  • 請求第二次過來,此時A、B、C的動態權重爲3、-4、1,固定權重3、5、1,我們需要將這兩個權重相加,得出此時三臺服務器的動態權重爲6、1、2。那麼此時A的權重最大,那麼就將請求打到A上。接下來再將剛剛所選中的服務器的動態權重減去所有服務器的權重加起來的總值,也就是A服務器:6 - (3 + 5 + 1 ) = -3;那麼第二次請求結束後三臺服務器的動態權重爲:-3、1、2;
  • 請求第三次過來,此時A、B、C的動態權重爲-3、1、2,固定權重3、5、1,將這兩個權重相加,得出此時三臺服務器的動態權重爲0、6、3。那麼此時B的權重最大,那麼就將請求打到B服務器上。接下來再將剛剛所選中的服務器的動態權重減去所有的服務器的權重加起來的總值,也就是B服務器:6 - (3 + 5 + 1) = -3;那麼第三次請求結束後三臺服務器的動態權重爲:0、-3、3;
  • ......

到這裏應該能有一個基本的瞭解,那麼我們現在就用代碼實現一遍:

首先實現一個權重類:

public class Weight {
    
    private String ip;
    private Integer weight; // 固定權重
    private Integer currentWeight; // 動態權重
    
    public Weight(String ip, Integer weight, Integer currentWeight) {
        this.ip = up;
        this.weight = weight;
        this.currentWeight = currentWeight;
    }
    
    // get & set methods...
}

接下來我們就來實現這個平滑加權輪詢算法:

public class WeightRoundRobinV3 {

    private static Map<String, Weight> weights = new HashMap<String, Weight>();

    public static String getServer() {
        if (weights.isEmpty()) {
            ServerIps.WEIGHT_LIST.forEach((ip, weight) -> {
                weights.put(ip, new Weight(ip, weight, 0));
            });
        }
        
        for (Weight weight : weight.values()) {
            weight.setCurrentWeight(weight.getCurrentWeight() + weight.getWeight());
        }
        
        // 尋找本次請求中權重最高的服務器IP
        Weight maxCurrentWeight = null;
        for (Weight weight : weights.values()) {
            if (maxCurrentWeight == null || weight.getCurrentWeight() > maxCurrentWeight.getCurrentWeight()) {
                maxCurrentWeight = weight;
            }
        }
        
        return maxCurrentWeight.getIp();
    }

    public static void main(String[] args) {
        // 連續調用20次
        for (int i = 0; i < 20; i++) {
            System.out.print(getServer() + " ");
        }
    }
}

三、一致性Hash

其實我們可以發現,隨機和輪詢的思想其實差不多,都是儘可能將請求均衡地派發到服務器中,最多加權實現。如果我們現在想要讓某個用於發過來的請求一直打到相同的服務器上去處理,那該怎麼辦呢?常見的實現就是一致性Hash算法了,這一算法在Nginx中配置負載均衡時也有體現:

如上圖,當有相同用戶的連接過來的時候,可以通過Hash映射的方式打到同一臺服務器上,這也是解決分佈式Session的一種思路。

現在這裏就涉及到一些問題了,我們一步一步來解決。首先用戶的請求過來,根據Hash算法計算出對應的值後,如何找到對應的服務器IP呢?

這很簡單,將所有可能的HashCode值列出來,造一個Map,將HashCode與IP一一對應,大概多個HashCode會對應到同一臺機器。

那如果HashCode的可能值有很多個呢?那豈不是需要去造一個佔用內存很大的Map?

嗯,客戶端發起的請求情況是無窮無盡的(客戶端地址不同,請求參數不同等等),所以對於哈希值也是無窮大的,所以我們不可能把所有的哈希值都進行映射到服務端IP上,這裏就需要用到哈希環了,如下圖:

  • 哈希值如果在ip1和ip2之間,則應該選擇ip2作爲響應服務器;
  • 哈希值如果在ip2和ip3之間,則應該選擇ip3作爲響應服務器;
  • 哈希值如果在ip3和ip4之間,則應該選擇ip4作爲響應服務器;
  • 哈希值如果在ip4和ip1之間,則應該選擇ip1作爲響應服務器;

如果多臺服務器所“控制”的區域很小,或者有時候其中一臺服務器宕機了,像下面這種出現哈希傾斜的情況,怎麼辦?

嗯,像這種情況可以加入虛擬節點,如下圖所示:

其中ip1-2、ip2-1等節點其實是虛擬的,等同於ip1和ip2服務器本身。實際上,這只是處理這種不平衡性的一種思路,實際上就算哈希環本身是平衡的,你也可以加入如更多的虛擬節點來使這個環更加平滑。

那麼我們如何來實現這個算法呢?接下來我們就來看代碼實現:

public class ConsitentHash {
    
    // 用紅黑樹,有序
    private static TreeMap<Integer, String> virtualNodes = new TreeMap<>();
    private static final int VIRTUAL_NODES = 160;
    
    static {
        // 對每個真實的節點添加虛擬節點,虛擬節點會根據哈希算法進行散列
        for (String ip : ServerIps.LIST) {
            for (int i = 0; i < VIRTUAL_NODES; i++) {
                int hash = getHash(ip + "VN" + i);
                virtualNodes.put(hash, ip);
            }
        }
    }
    
    public staitc String getServer(String clientInfo) {
        int hash = getHash(client);
        
        // 得到大於該Hash值的子紅黑樹
        SortedMap<Integer, String> subMap = virtualNode.tailMap(hash);
        
        // 獲取該樹的第一個元素,也就是最小的元素
        Integer nodeIndex = subMap.firstKey();
        
        // 如果沒有大於該元素的子樹了,則取整棵樹的第一個元素,相當於取哈希環中的最小的那個元素
        if (nodeIndex == null) {
            nodeIndex = virtualNodes.firstKey();
        }
        
        // 返回對應的虛擬節點名稱
        return virtualNodes.get(nodeIndex);
    }
    
    // 計算hash值的算法
    private static int getHash(String str) {
        final int p = 16777619;
        int hash = (int) 216613621L;
        for (int i = 0; i < str.length(); i++) 
            hash = (hash ^ str.charAt(i)) * p;
        hash += hash << 13;
        hsah ^= hash >> 7;
        return hashl
    }
    
}

四、最小連接數法

前面幾種方法費盡心思來實現服務消費者請求次數分配的均衡,當然這麼做是沒錯的,可以爲後端的多臺服務器平均分配工作量,最大程度地提高服務器的利用率,但是實際情況是否真的如此?實際情況中,請求次數的均衡真的能代表負載的均衡嗎?這是一個值得思考的問題。

上面的問題,再換一個角度來說就是:以後端服務器的視角來觀察系統的負載,而非請求發起方來觀察。最小連接數法便屬於此類。

最小連接數算法比較靈活和智能,由於後端服務器的配置不盡相同,對於請求的處理有快有慢,它正是根據後端服務器當前的連接情況,動態地選取其中當前積壓連接數最少的一臺服務器來處理當前請求,儘可能地提高後端服務器的利用效率,將負載合理地分流到每一臺機器。由於最小連接數設計服務器連接數的彙總和感知,設計與實現較爲繁瑣,此處就不說它的實現了。

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