常見的負(fù)載均衡算法的實(shí)現(xiàn)與應(yīng)用
走過路過不要錯(cuò)過
點(diǎn)擊 藍(lán)字 關(guān)注我們
所謂負(fù)載均衡就是將外部發(fā)送過來的請(qǐng)求均勻或者根據(jù)某種算法分配到對(duì)稱結(jié)構(gòu)中的某一臺(tái)服務(wù)器中。負(fù)載均衡可以分為硬件負(fù)載均衡和軟件負(fù)載均衡,常見的硬件負(fù)載均衡有F5、Array等,但是這些設(shè)備都比較昂貴。相比之下,利用軟件來實(shí)現(xiàn)負(fù)載均衡就比較簡(jiǎn)單了,常見的像是 Nginx 的反向代理負(fù)載均衡。
這篇文章并不去細(xì)說 Nginx 這類軟件的具體配置,只是著重來了解幾種常見的負(fù)載均衡算法的實(shí)現(xiàn)(本文使用Java描述)與應(yīng)用。對(duì)于我們來講,所了解的最基本的負(fù)載均衡算法包括了:隨機(jī)、輪詢、一致性Hash等幾種方式,接下來就來詳細(xì)介紹這幾種算法:
一、隨機(jī)#
1. 完全隨機(jī)
所謂完全隨機(jī)就是完全沒有規(guī)律可言,每次隨機(jī)種IP列表中選取一個(gè),將請(qǐng)求打到該服務(wù)器上:
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());
}
}
}
完全隨機(jī)是最簡(jiǎn)單的負(fù)載均衡算法了,實(shí)現(xiàn)起來也很簡(jiǎn)單。但是我們?cè)谌粘Ia(chǎn)過程中,機(jī)器的處理效率肯定是不同的,有些機(jī)器處理得快,有些機(jī)器處理得慢,完全隨機(jī)的缺點(diǎn)在此刻就很明顯了,無法將更多的請(qǐng)求分配到更好的服務(wù)器上,由此就有了加權(quán)隨機(jī)的思想了。
2. 加權(quán)隨機(jī)#
加權(quán)隨機(jī)常見的有兩種實(shí)現(xiàn)方式,現(xiàn)在先來了解第一種方式,先構(gòu)建一個(gè)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);
}
}
首先這種實(shí)現(xiàn)方式的想法很簡(jiǎn)單,就是通過上面的服務(wù)器IP的List,再去構(gòu)建一次List:如果一個(gè)IP服務(wù)器的權(quán)重為8,那么就往List里面添加8次該對(duì)應(yīng)的IP地址,如果權(quán)重為3,那么就添加3次,以此類推。這樣的話,結(jié)合前面的完全隨機(jī)實(shí)現(xiàn),那么這樣權(quán)重越大的,在List中出現(xiàn)的比例也越高,被選中的幾率當(dāng)然也會(huì)更大:
public class WeightRandom {
public static String getServer() {
// 構(gòu)建一個(gè)新的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) {
// 連續(xù)調(diào)用10次
for(int i = 0; i < 10; i++) {
System.out.println(getServer());
}
}
}
上面代碼可以自行測(cè)試一下,最后結(jié)構(gòu)應(yīng)該就是權(quán)重高的服務(wù)器被選中的幾率更高。但是這也有一種很明顯的缺點(diǎn),如果現(xiàn)在的權(quán)重是像下面這樣配置呢?
WEIGHT_LIST.put("192.168.0.1", 998);
WEIGHT_LIST.put("192.168.0.2", 13);
// ...
這樣很明顯就會(huì)帶來一個(gè)新的問題,服務(wù)器中的新建的List動(dòng)不動(dòng)就會(huì)有上萬條記錄,那么此時(shí)服務(wù)器豈不是要白白浪費(fèi)掉一部分內(nèi)存去維護(hù)一個(gè)數(shù)組,所以此時(shí)我們就有了下面更優(yōu)的實(shí)現(xiàn)。
3. 加權(quán)隨機(jī)優(yōu)化
假設(shè)我們現(xiàn)在有3臺(tái)服務(wù)器,A服務(wù)器的權(quán)重為3,B服務(wù)器的權(quán)重為5,C服務(wù)器的權(quán)重為1。然后我們隨機(jī)生成一個(gè)數(shù):
-
如果生成的隨機(jī)數(shù)為1,1 ≤ 3,那么此時(shí)請(qǐng)求在落在A的區(qū)間段,請(qǐng)求應(yīng)該交由A來處理;
-
如果生成的隨機(jī)數(shù)為5,那么此時(shí) 3 < 5,所以不在A區(qū)間,而 5 - 3 = 2 < 5,那么此時(shí)應(yīng)該落在B區(qū)間;
-
如果生成的隨機(jī)數(shù)為9,那么 9 > 3 + 5,所以不可能落在A區(qū)間或者B區(qū)間,而剛好后面C的權(quán)重為,落在C區(qū)間。
用圖展示會(huì)更直觀些,假設(shè)下面數(shù)組下標(biāo)從1開始:

當(dāng)然我們不可能真的像圖中那樣再去維護(hù)一個(gè)數(shù)組了,這樣不也和剛剛那樣白占內(nèi)存么。所以每次但一個(gè)隨機(jī)數(shù)過來時(shí),我們會(huì)先去判斷它是否小于A的權(quán)重,如果小于,那么就是請(qǐng)求發(fā)到A,如果不小于,注意,這里就得將隨機(jī)數(shù)減去A的權(quán)重,然后下一次循環(huán)再和B的權(quán)重比,如果小于B的權(quán)重,那么就是請(qǐng)求到B,具體我們可以看下代碼實(shí)現(xiàn):
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. 完全輪詢#
完全輪詢的思想也十分簡(jiǎn)單,大家輪著來嘛,這次請(qǐng)求打到A上,下次就到B,再下次就是C了,這種實(shí)現(xiàn)的方式也很簡(jiǎn)單,代碼實(shí)現(xiàn)如下:
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. 加權(quán)輪詢
完全輪詢?nèi)秉c(diǎn)和完全隨機(jī)基本無異,當(dāng)遇見性能較好的機(jī)器時(shí),它的性能無法展示出來,當(dāng)遇見性能較差的機(jī)器時(shí),它的弊端倒是很容易顯現(xiàn),由此我們也需要來實(shí)現(xiàn)加權(quán)輪詢。同樣的,加權(quán)輪詢也像加權(quán)隨機(jī)那樣有兩種實(shí)現(xiàn)方式,一種靠著擴(kuò)展列表,以外一種靠著偏移量,這里就簡(jiǎn)單演示下后者,畢竟后者的實(shí)現(xiàn)更優(yōu)。
這里我們加上些簡(jiǎn)單的優(yōu)化,請(qǐng)求是從前面發(fā)過來的,帶有一個(gè)RequestID,那么我們就利用這個(gè)ID來幫助我們的輪詢:
我們先創(chuàng)建一個(gè)RequestId模仿請(qǐng)求ID,代碼如下:
public class RequestId {
public static Integer num = 0;
public static Integer getAndIncrement() {
return num++;
}
}
接下來我們就利用這個(gè)ID,然后進(jìn)行輪詢,代碼如下:
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 "";
}
}
通過上面方式我們確實(shí)能實(shí)現(xiàn)加權(quán)輪詢,但是這種輪詢是連著來到,意思也就說,如果有請(qǐng)求過來,那么如果A服務(wù)器的權(quán)重為3,就會(huì)有連續(xù)3個(gè)請(qǐng)求打到A上,那么B此時(shí)卻沒有請(qǐng)求來訪問,等到A服務(wù)器經(jīng)歷3個(gè)請(qǐng)求后,接下來又有連續(xù)的5個(gè)請(qǐng)求打到B上,依次類推。
A A A B B B B B C
這種實(shí)現(xiàn)也會(huì)來帶很大的弊端,所以我們實(shí)際上是既要有輪詢,輪詢中間最好還是要有交叉,由此有了平滑加權(quán)輪詢的思想。
3. 平滑加權(quán)輪詢#
首先我們需要先來了解靜態(tài)權(quán)重與動(dòng)態(tài)權(quán)重的概念:
-
靜態(tài)權(quán)重:權(quán)重由用戶自己設(shè)定,在輪詢過程中一直不變化,像上面幾種加權(quán)方式就是靜態(tài)權(quán)重;
-
動(dòng)態(tài)權(quán)重:動(dòng)態(tài)權(quán)重的思想與靜態(tài)權(quán)重剛好相反,在輪詢的過程中動(dòng)態(tài)地變換各機(jī)器的權(quán)重,但是最終的加權(quán)效果不能與靜態(tài)權(quán)重不同,只是用來實(shí)現(xiàn)交叉輪詢的效果。
接下來就講解一種動(dòng)態(tài)權(quán)重實(shí)現(xiàn)的算法,假設(shè)用戶給A、B、C三臺(tái)服務(wù)器配置的權(quán)重為3、5、1,我們將3、5、1稱為固定權(quán)重,在算法過程中變化的權(quán)重稱為動(dòng)態(tài)權(quán)重:
-
請(qǐng)求第一次過來,此時(shí)A、B、C的權(quán)重分別是3、5、1,在這里面 5 是大的,所對(duì)應(yīng)的就是B服務(wù)器,將請(qǐng)求打到B服務(wù)器,接下來將我們剛剛所選中的服務(wù)器的權(quán)重減去所有機(jī)器的權(quán)重加起來的總值,作為接下來的動(dòng)態(tài)權(quán)重,沒被選中的服務(wù)器權(quán)重不變。也就是B服務(wù)器:5 - (3 + 5 + 1) = -4;那么第一次請(qǐng)求結(jié)束后三臺(tái)服務(wù)器的動(dòng)態(tài)權(quán)重為:3、-4、1;
-
請(qǐng)求第二次過來,此時(shí)A、B、C的動(dòng)態(tài)權(quán)重為3、-4、1,固定權(quán)重3、5、1,我們需要將這兩個(gè)權(quán)重相加,得出此時(shí)三臺(tái)服務(wù)器的動(dòng)態(tài)權(quán)重為6、1、2。那么此時(shí)A的權(quán)重最大,那么就將請(qǐng)求打到A上。接下來再將剛剛所選中的服務(wù)器的動(dòng)態(tài)權(quán)重減去所有服務(wù)器的權(quán)重加起來的總值,也就是A服務(wù)器:6 - (3 + 5 + 1 ) = -3;那么第二次請(qǐng)求結(jié)束后三臺(tái)服務(wù)器的動(dòng)態(tài)權(quán)重為:-3、1、2;
-
請(qǐng)求第三次過來,此時(shí)A、B、C的動(dòng)態(tài)權(quán)重為-3、1、2,固定權(quán)重3、5、1,將這兩個(gè)權(quán)重相加,得出此時(shí)三臺(tái)服務(wù)器的動(dòng)態(tài)權(quán)重為0、6、3。那么此時(shí)B的權(quán)重最大,那么就將請(qǐng)求打到B服務(wù)器上。接下來再將剛剛所選中的服務(wù)器的動(dòng)態(tài)權(quán)重減去所有的服務(wù)器的權(quán)重加起來的總值,也就是B服務(wù)器:6 - (3 + 5 + 1) = -3;那么第三次請(qǐng)求結(jié)束后三臺(tái)服務(wù)器的動(dòng)態(tài)權(quán)重為:0、-3、3;
-
......

到這里應(yīng)該能有一個(gè)基本的了解,那么我們現(xiàn)在就用代碼實(shí)現(xiàn)一遍:
首先實(shí)現(xiàn)一個(gè)權(quán)重類:
public class Weight {
private String ip;
private Integer weight; // 固定權(quán)重
private Integer currentWeight; // 動(dòng)態(tài)權(quán)重
public Weight(String ip, Integer weight, Integer currentWeight) {
this.ip = up;
this.weight = weight;
this.currentWeight = currentWeight;
}
// get & set methods...
}
接下來我們就來實(shí)現(xiàn)這個(gè)平滑加權(quán)輪詢算法:
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());
}
// 尋找本次請(qǐng)求中權(quán)重最高的服務(wù)器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) {
// 連續(xù)調(diào)用20次
for (int i = 0; i < 20; i++) {
System.out.print(getServer() + " ");
}
}
}
三、一致性Hash
其實(shí)我們可以發(fā)現(xiàn),隨機(jī)和輪詢的思想其實(shí)差不多,都是盡可能將請(qǐng)求均衡地派發(fā)到服務(wù)器中,最多加權(quán)實(shí)現(xiàn)。如果我們現(xiàn)在想要讓某個(gè)用于發(fā)過來的請(qǐng)求一直打到相同的服務(wù)器上去處理,那該怎么辦呢?常見的實(shí)現(xiàn)就是一致性Hash算法了,這一算法在Nginx中配置負(fù)載均衡時(shí)也有體現(xiàn):

如上圖,當(dāng)有相同用戶的連接過來的時(shí)候,可以通過Hash映射的方式打到同一臺(tái)服務(wù)器上,這也是解決分布式Session的一種思路。
現(xiàn)在這里就涉及到一些問題了,我們一步一步來解決。首先用戶的請(qǐng)求過來,根據(jù)Hash算法計(jì)算出對(duì)應(yīng)的值后,如何找到對(duì)應(yīng)的服務(wù)器IP呢?
這很簡(jiǎn)單,將所有可能的HashCode值列出來,造一個(gè)Map,將HashCode與IP一一對(duì)應(yīng),大概多個(gè)HashCode會(huì)對(duì)應(yīng)到同一臺(tái)機(jī)器。
那如果HashCode的可能值有很多個(gè)呢?那豈不是需要去造一個(gè)占用內(nèi)存很大的Map?
嗯,客戶端發(fā)起的請(qǐng)求情況是無窮無盡的(客戶端地址不同,請(qǐng)求參數(shù)不同等等),所以對(duì)于哈希值也是無窮大的,所以我們不可能把所有的哈希值都進(jìn)行映射到服務(wù)端IP上,這里就需要用到哈希環(huán)了,如下圖:

-
哈希值如果在ip1和ip2之間,則應(yīng)該選擇ip2作為響應(yīng)服務(wù)器;
-
哈希值如果在ip2和ip3之間,則應(yīng)該選擇ip3作為響應(yīng)服務(wù)器;
-
哈希值如果在ip3和ip4之間,則應(yīng)該選擇ip4作為響應(yīng)服務(wù)器;
-
哈希值如果在ip4和ip1之間,則應(yīng)該選擇ip1作為響應(yīng)服務(wù)器;
如果多臺(tái)服務(wù)器所“控制”的區(qū)域很小,或者有時(shí)候其中一臺(tái)服務(wù)器宕機(jī)了,像下面這種出現(xiàn)哈希傾斜的情況,怎么辦?

嗯,像這種情況可以加入虛擬節(jié)點(diǎn),如下圖所示:

其中ip1-2、ip2-1等節(jié)點(diǎn)其實(shí)是虛擬的,等同于ip1和ip2服務(wù)器本身。實(shí)際上,這只是處理這種不平衡性的一種思路,實(shí)際上就算哈希環(huán)本身是平衡的,你也可以加入如更多的虛擬節(jié)點(diǎn)來使這個(gè)環(huán)更加平滑。
那么我們?nèi)绾蝸韺?shí)現(xiàn)這個(gè)算法呢?接下來我們就來看代碼實(shí)現(xiàn):
public class ConsitentHash {
// 用紅黑樹,有序
private static TreeMap<Integer, String> virtualNodes = new TreeMap<>();
private static final int VIRTUAL_NODES = 160;
static {
// 對(duì)每個(gè)真實(shí)的節(jié)點(diǎn)添加虛擬節(jié)點(diǎn),虛擬節(jié)點(diǎn)會(huì)根據(jù)哈希算法進(jìn)行散列
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);
// 獲取該樹的第一個(gè)元素,也就是最小的元素
Integer nodeIndex = subMap.firstKey();
// 如果沒有大于該元素的子樹了,則取整棵樹的第一個(gè)元素,相當(dāng)于取哈希環(huán)中的最小的那個(gè)元素
if (nodeIndex == null) {
nodeIndex = virtualNodes.firstKey();
}
// 返回對(duì)應(yīng)的虛擬節(jié)點(diǎn)名稱
return virtualNodes.get(nodeIndex);
}
// 計(jì)算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
}
}
四、最小連接數(shù)法
前面幾種方法費(fèi)盡心思來實(shí)現(xiàn)服務(wù)消費(fèi)者請(qǐng)求次數(shù)分配的均衡,當(dāng)然這么做是沒錯(cuò)的,可以為后端的多臺(tái)服務(wù)器平均分配工作量,最大程度地提高服務(wù)器的利用率,但是實(shí)際情況是否真的如此?實(shí)際情況中,請(qǐng)求次數(shù)的均衡真的能代表負(fù)載的均衡嗎?這是一個(gè)值得思考的問題。
上面的問題,再換一個(gè)角度來說就是:以后端服務(wù)器的視角來觀察系統(tǒng)的負(fù)載,而非請(qǐng)求發(fā)起方來觀察。最小連接數(shù)法便屬于此類。
最小連接數(shù)算法比較靈活和智能,由于后端服務(wù)器的配置不盡相同,對(duì)于請(qǐng)求的處理有快有慢,它正是根據(jù)后端服務(wù)器當(dāng)前的連接情況,動(dòng)態(tài)地選取其中當(dāng)前積壓連接數(shù)最少的一臺(tái)服務(wù)器來處理當(dāng)前請(qǐng)求,盡可能地提高后端服務(wù)器的利用效率,將負(fù)載合理地分流到每一臺(tái)機(jī)器。由于最小連接數(shù)設(shè)計(jì)服務(wù)器連接數(shù)的匯總和感知,設(shè)計(jì)與實(shí)現(xiàn)較為繁瑣,此處就不說它的實(shí)現(xiàn)了。
想進(jìn)大廠的小伙伴請(qǐng)注意,
大廠面試的套路很神奇,
早做準(zhǔn)備對(duì)大家更有好處,
埋頭刷題效率低,
看面經(jīng)會(huì)更有效率!
小編準(zhǔn)備了一份 大廠 常問面經(jīng) 匯總集
大致內(nèi)容包括了: Java 集合、JVM、多線程、并發(fā)編程、設(shè)計(jì)模式、Spring全家桶、Java、MyBatis、ZooKeeper、Dubbo、Elasticsearch、Memcached、MongoDB、Redis、MySQL、RabbitMQ、Kafka、Linux、Netty、Tomcat等大廠面試題等、等技術(shù)棧!

剩下的就不會(huì)給大家一展出來了,以上資料按照一下操作即可獲得
——將文章進(jìn)行 轉(zhuǎn)發(fā) 和 評(píng)論 , 關(guān)注公眾號(hào)【Java烤豬皮】 ,關(guān)注后繼續(xù)后臺(tái)回復(fù)領(lǐng)取口令“ 666 ”即可免費(fèi)領(lǐng)文章取中所提供的資料。
往期精品推薦
騰訊、阿里、滴滴后臺(tái)試題匯集總結(jié) — (含答案)
面試:史上最全多線程序面試題!
最新阿里內(nèi)推Java后端試題
JVM難學(xué)?那是因?yàn)槟銢]有真正看完整這篇文章
— 結(jié)束 —
關(guān)注作者微信公眾號(hào) — 《JAVA烤豬皮》
了解了更多java后端架構(gòu)知識(shí)以及最新面試寶典
看完本文記得給作者點(diǎn)贊+在看哦~~~大家的支持,是作者來源不斷出文的動(dòng)力~
