目錄
- 前言
- 基本的負載算法
- 平滑加權(quán)輪詢算法
- 一致性哈希算法
- 最小活躍數(shù)算法
- 最優(yōu)響應算法
- 總結(jié)
前言
負載均衡這個概念,幾乎在所有支持高可用的技術(shù)棧中都存在柱徙,例如微服務、分庫分表贩据、各大中間件(MQ饱亮、Redis、MyCat蠕啄、Nginx歼跟、ES)等格遭,也包括云計算、云調(diào)度骚秦、大數(shù)據(jù)中也是炙手可熱的詞匯作箍。
負載均衡策略主要分為靜態(tài)與動態(tài)兩大類:
- 靜態(tài)調(diào)度算法:指配置后只會依據(jù)配置好的策略進行請求分發(fā)的算法前硫。
- 動態(tài)調(diào)度算法:指配置后會根據(jù)線上情況(網(wǎng)絡/CPU 負載/磁盤 IO 等)來分發(fā)請求。
但負載均衡算法數(shù)量并不少阶剑,本篇主要對于一些常用且高效的負載策略進行剖析。
基本的負載算法
如果聊到最基本的負載均衡算法素邪,那么相信大家多少都有了解兔朦,例如:輪詢办龄、隨機淋昭、權(quán)重等這類算法。特點就在于實現(xiàn)簡單英融,先來快速過一遍基本的算法實現(xiàn)驶悟。
| 輪詢算法
輪詢算法是最為簡單材失、也最為常見的算法龙巨,也是大多數(shù)集群情況下的默認調(diào)度算法,這種算法會按照配置的服務器列表诗赌,按照順序依次分發(fā)請求秸弛,所有服務器都分發(fā)一遍后递览,又會回到第一臺服務器循環(huán)該步驟。
Java 代碼實現(xiàn)如下:
// 服務類:主要用于保存配置的所有節(jié)點
public class Servers {
// 模擬配置的集群節(jié)點
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"
);
}
// 輪詢策略類:實現(xiàn)基本的輪詢算法
public class RoundRobin{
// 用于記錄當前請求的序列號
private static AtomicInteger requestIndex = new AtomicInteger(0);
// 從集群節(jié)點中選取一個節(jié)點處理請求
public static String getServer(){
// 用請求序列號取余集群節(jié)點數(shù)量镜雨,求得本次處理請求的節(jié)點下標
int index = requestIndex.get() % Servers.SERVERS.size();
// 從服務器列表中獲取具體的節(jié)點IP地址信息
String server = Servers.SERVERS.get(index);
// 自增一次請求序列號冷离,方便下個請求計算
requestIndex.incrementAndGet();
// 返回獲取到的服務器IP地址
return server;
}
}
// 測試類:測試輪詢算法
public class Test{
public static void main(String[] args){
// 使用for循環(huán)簡單模擬10個客戶端請求
for (int i = 1; i <= 10; i++){
System.out.println("第"+ i + "個請求:" + RoundRobin.getServer());
}
}
}
/******輸出結(jié)果*******/
第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
上述案例中,整個算法的實現(xiàn)尤為簡單痹栖,就是通過一個原子計數(shù)器記錄當前請求的序列號瞭空,然后直接通過 % 集群中的服務器節(jié)點總數(shù),最終得到一個具體的下標值南捂,再通過這個下標值溺健,從服務器 IP 列表中獲取一個具體的 IP 地址钮蛛。
輪詢算法的優(yōu)勢:
- 算法實現(xiàn)簡單,請求分發(fā)效率夠高岭辣。
- 能夠?qū)⑺姓埱缶鶖偟郊褐械拿總€節(jié)點上沦童。
- 易于后期彈性伸縮叹话,業(yè)務增長時可以拓展節(jié)點渣刷,業(yè)務萎靡時可以縮減節(jié)點。
輪詢算法的劣勢:
- 對于不同配置的服務器無法合理照顧箩溃,無法將高配置的服務器性能發(fā)揮出來碌嘀。
- 由于請求分發(fā)時股冗,是基于請求序列號來實現(xiàn)的,所以無法保證同一客戶端的請求都是由同一節(jié)點處理的烹棉,因此需要通過 session 記錄狀態(tài)時,無法確保其一致性催束。
輪詢算法的應用場景:
- 集群中所有節(jié)點硬件配置都是相同的情況抠刺。
- 只讀不寫摘昌,無需保持狀態(tài)的情景。
| 隨機算法
隨機算法的實現(xiàn)也非常簡單罕容,也就是當客戶端請求到來時杀赢,每次都會從已配置的服務器列表中隨機抽取一個節(jié)點處理湘纵。
實現(xiàn)如下:
// 隨機策略類:隨機抽取集群中的一個節(jié)點處理請求
public class Random {
// 隨機數(shù)產(chǎn)生器梧喷,用于產(chǎn)生隨機因子
static java.util.Random random = new java.util.Random();
public static String getServer(){
// 從已配置的服務器列表中脖咐,隨機抽取一個節(jié)點處理請求
return Servers.SERVERS.get(random.nextInt(Servers.SERVERS.size()));
}
}
上述該算法的實現(xiàn),非常明了偿凭,通過 java.util 包中自帶的 Random 隨機數(shù)產(chǎn)生器弯囊,從服務器列表中隨機抽取一個節(jié)點處理請求胶果,該算法的結(jié)果也不測試了早抠,大家估計一眼就能看明白。
隨機算法的優(yōu)勢:個人看來該算法單獨使用的意義并不大悬垃,一般會配合下面要講的權(quán)重策略協(xié)同使用。
隨機算法的劣勢:
- 無法合理地將請求均攤到每臺服務器上酱床。
- 由于處理請求的目標服務器不明確扇谣,因此也無法滿足需要記錄狀態(tài)的請求闲昭。
- 能夠在一定程度上發(fā)揮出高配置的機器性能,但充滿不確定因素鸯绿。
| 權(quán)重算法
權(quán)重算法是建立在其他基礎算法之上推出的一種概念瓶蝴,權(quán)重算法并不能單獨配置租幕,因為權(quán)重算法無法做到請求分發(fā)的調(diào)度劲绪,所以一般權(quán)重會配合其他基礎算法結(jié)合使用。
如:輪詢權(quán)重算法歉眷、隨機權(quán)重算法等颤枪,這樣可以讓之前的兩種基礎調(diào)度算法更為“人性化”一些。
權(quán)重算法是指對于集群中的每個節(jié)點分配一個權(quán)重值扇住,權(quán)重值越高台囱,該節(jié)點被分發(fā)的請求數(shù)也會越多读整,反之同理。
這樣做的好處十分明顯膘侮,也就是能夠充分考慮機器的硬件配置的榛,從而分配不同權(quán)重值,做到“能者多勞”雕薪。
那如何實現(xiàn)呢所袁,先來看看隨機權(quán)重的實現(xiàn):
public class Servers{
// 在之前是Servers類中再加入一個權(quán)重服務列表
public static Map<String, Integer> WEIGHT_SERVERS = new LinkedHashMap<>();
static {
// 配置集群的所有節(jié)點信息及權(quán)重值
WEIGHT_SERVERS.put("44.120.110.001:8080",17);
WEIGHT_SERVERS.put("44.120.110.002:8081",11);
WEIGHT_SERVERS.put("44.120.110.003:8082",30);
}
}
// 隨機權(quán)重算法
public class Randomweight {
// 初始化隨機數(shù)生產(chǎn)器
static java.util.Random random = new java.util.Random();
public static String getServer(){
// 計算總權(quán)重值
int weightTotal = 0;
for (Integer weight : Servers.WEIGHT_SERVERS.values()) {
weightTotal += weight;
}
// 從總權(quán)重的范圍內(nèi)隨機生成一個索引
int index = random.nextInt(weightTotal);
System.out.println(index);
// 遍歷整個權(quán)重集群的節(jié)點列表燥爷,選擇節(jié)點處理請求
String targetServer = "";
for (String server : Servers.WEIGHT_SERVERS.keySet()) {
// 獲取每個節(jié)點的權(quán)重值
Integer weight = Servers.WEIGHT_SERVERS.get(server);
// 如果權(quán)重值大于產(chǎn)生的隨機數(shù)懦窘,則代表此次隨機分配應該落入該節(jié)點
if (weight > index){
// 直接返回對應的節(jié)點去處理本次請求并終止循環(huán)
targetServer = server;
break;
}
// 如果當前節(jié)點的權(quán)重值小于隨機索引畅涂,則用隨機索引減去當前節(jié)點的權(quán)重值,
// 繼續(xù)循環(huán)權(quán)重列表苹丸,與其他的權(quán)重值進行對比苇经,
// 最終該請求總會落入到某個IP的權(quán)重值范圍內(nèi)
index = index - weight;
}
// 返回選中的目標節(jié)點
return targetServer;
}
public static void main(String[] args){
// 利用for循環(huán)模擬10個客戶端請求測試
for (int i = 1; i <= 10; i++){
System.out.println("第"+ i + "個請求:" + getServer());
}
}
}
/********運行結(jié)果********/
第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
上面這個算法對比之前的基本實現(xiàn)扇单,可能略微有些復雜難懂蜘澜,我們先上個圖:
仔細觀看上圖后鄙信,邏輯應該會清晰很多忿晕,大體捋一下思路:
- 先求和所有的權(quán)重值,再隨機生成一個總權(quán)重之內(nèi)的索引宾巍。
- 遍歷之前配置的服務器列表渔伯,用隨機索引與每個節(jié)點的權(quán)重值進行判斷。如果小于选浑,則代表當前請求應該落入目前這個節(jié)點鲜侥;如果大于诸典,則代表隨機索引超出了目前節(jié)點的權(quán)重范圍,則減去當前權(quán)重舀寓,繼續(xù)與其他節(jié)點判斷肌蜻。
- 最終隨機出的索引總會落入到一個節(jié)點的權(quán)重范圍內(nèi)蒋搜,最后返回對應的節(jié)點 IP。
這樣一分析下來育谬,估摸著各位小伙伴應該都理解了帮哈,接著再來看看輪詢權(quán)重算法的實現(xiàn):
// 輪詢權(quán)重算法
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());
}
}
}
/********運行結(jié)果*********/
第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
觀察上述中的案例娘侍,此刻會發(fā)現(xiàn)出端倪,代碼實現(xiàn)過程相同嚎杨,但此刻的輸出結(jié)果氧腰,竟然全部請求都被分發(fā)到了 44.120.110.001:8080 這個節(jié)點刨肃,這是為什么呢之景?
因為此時是通過請求序列號去進行判斷的膏潮,所以最終效果會成為:
- 前 17 個請求會交給 44.120.110.001:8080 節(jié)點焕参。
- 后續(xù) 11 個請求會交給 44.120.110.002:8081 節(jié)點。
- 最后 30 個請求會交給 44.120.110.003:8082 節(jié)點刻帚。
- 然后持續(xù)重復該過程.....
此時似乎離我們預期的負載效果發(fā)生了偏離涩嚣,如果采用這種方案去實現(xiàn)輪詢權(quán)重算法航厚,最終會將一個集群變?yōu)閱吸c服務,這顯然并不是期待中的效果眯漩,因此需要一種新的方式去實現(xiàn)麻顶,那么又該如何去做呢?
此時需要牽扯到一種請求調(diào)度的高級算法:平滑加權(quán)輪詢算法队萤。
平滑加權(quán)輪詢算法
平滑輪詢加權(quán)算法的本質(zhì)就是為了解決之前實現(xiàn)方式中所存在的問題浮禾,能夠?qū)⒄埱缶鶆虻陌凑諜?quán)重值分發(fā)到每臺機器份汗。
這種算法設計的非常巧妙杯活,實現(xiàn)過程也尤為有趣熬词,我們一起來看看:
// 權(quán)重服務器的配置類
public class Servers {
public static Map<String, Integer> WEIGHT_SERVERS = new LinkedHashMap<>();
static {
// 權(quán)重值設置的略微小一點,方便后續(xù)理解算法
WEIGHT_SERVERS.put("44.120.110.001:8080",3);
WEIGHT_SERVERS.put("44.120.110.002:8081",2);
WEIGHT_SERVERS.put("44.120.110.003:8082",1);
}
}
// 權(quán)重類
public class Weight {
// 節(jié)點信息
private String server;
// 節(jié)點權(quán)重值
private Integer weight;
// 動態(tài)權(quán)重值
private Integer currentWeight;
// 構(gòu)造方法
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 {
// 初始化存儲每個節(jié)點的權(quán)重容器
private static Map<String,Weight> weightMap = new HashMap<>();
// 計算總權(quán)重值歪今,只需要計算一次寄猩,因此放在靜態(tài)代碼塊中執(zhí)行
private static int weightTotal = 0;
static {
sumWeightTotal();
}
// 求和總權(quán)重值,后續(xù)動態(tài)伸縮節(jié)點時替废,再次調(diào)用該方法即可泊柬。
public static void sumWeightTotal(){
for (Integer weight : Servers.WEIGHT_SERVERS.values()) {
weightTotal += weight;
}
}
// 獲取處理本次請求的具體服務器IP
public static String getServer(){
// 判斷權(quán)重容器中是否有節(jié)點信息
if (weightMap.isEmpty()){
// 如果沒有則將配置的權(quán)重服務器列表挨個載入容器
Servers.WEIGHT_SERVERS.forEach((servers, weight) -> {
// 初始化時,每個節(jié)點的動態(tài)權(quán)重值都為0
weightMap.put(servers, new Weight(servers, weight, 0));
});
}
// 每次請求時状答,更改動態(tài)權(quán)重值
for (Weight weight : weightMap.values()) {
weight.setCurrentWeight(weight.getCurrentWeight()
+ weight.getWeight());
}
// 判斷權(quán)重容器中最大的動態(tài)權(quán)重值
Weight maxCurrentWeight = null;
for (Weight weight : weightMap.values()) {
if (maxCurrentWeight == null || weight.getCurrentWeight()
> maxCurrentWeight.getCurrentWeight()){
maxCurrentWeight = weight;
}
}
// 最后用最大的動態(tài)權(quán)重值減去所有節(jié)點的總權(quán)重值
maxCurrentWeight.setCurrentWeight(maxCurrentWeight.getCurrentWeight()
- weightTotal);
// 返回最大的動態(tài)權(quán)重值對應的節(jié)點IP
return maxCurrentWeight.getServer();
}
public static void main(String[] args){
// 使用for循環(huán)模擬6次請求
for (int i = 1; i <= 6; i++){
System.out.println("第"+ i + "個請求:" + getServer());
}
}
}
/********輸出結(jié)果********/
第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
先看結(jié)果惊科,對比之前的實現(xiàn)方式而言译断,該算法在分發(fā)請求時或悲,確實均勻了很多很多。
而且請求分發(fā)的數(shù)量與我們配置的權(quán)重值也恰巧相符合:
- 44.120.110.001:8080:3 次
- 44.120.110.002:8081:2 次
- 44.120.110.003:8082:1 次
這是不是很神奇翎蹈?如何做到的呢男公,接下來簡單聊一下該算法的核心思想。
在之前的權(quán)重算法中澄阳,服務器列表中只有兩個值:服務器 IP碎赢、對應的權(quán)重值速梗,而在當前這種算法中襟齿,需要再引入一個動態(tài)權(quán)重值的概念猜欺。
所以我們再上述案例中拷窜,將服務器的列表抽象成了一個 Weight 類装黑,在該類中除開原本的 servers、weight 之外糠睡,多添加了一個字段 currentWeight疚颊,用于記錄每個節(jié)點的動態(tài)權(quán)重(該值是變化的)。
在該算法中均抽,會先計算已配置的權(quán)重值總和其掂,然后第一次請求,會初始化權(quán)重容器 weightMap深寥,將每個配置的節(jié)點都封裝成一個 Weight 對象惋鹅,并將其動態(tài)權(quán)重值初始化為 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,至此準備工作就緒武鲁,接下來是算法的核心過程蝠检,主要分為三步:
- 用原本的動態(tài)權(quán)重值加一次每個節(jié)點的靜態(tài)權(quán)重值,計算出新的動態(tài)權(quán)重值蝇率。
- 遍歷權(quán)重容器,找出動態(tài)權(quán)重值最大的節(jié)點排拷,將其作為處理本次請求的節(jié)點锅尘。
- 用最大的動態(tài)權(quán)重值減去已配置的靜態(tài)權(quán)重值總和,為一下輪分發(fā)做準備浪腐。
結(jié)合上述的算法過程和前面給出的案例议街,把整個過程攤開剖析一次:
上表中列出了六次請求的處理過程璧榄,整個過程到最后,動態(tài)權(quán)重值又會回歸初始值:0,0,0涂身,然后開啟新的一輪計算蛤售,周而復始之妒潭,格外的神奇_。
平滑加權(quán)輪詢算法也是應用最為廣泛的輪詢算法搜骡,在 Dubbo佑女、Robbin、Nginx摸吠、Zookeeper 等一些集群環(huán)境中寸痢,當你配置了權(quán)重時紊选,默認采用的就是該算法作為請求分發(fā)的策略道逗。
一致性哈希算法
其實平滑加權(quán)輪詢算法對于請求分發(fā)而言滓窍,是一種比較優(yōu)秀的策略了巩那,不過前面分析的所有策略即横,都存在一個致命問題:不能確保同一客戶端的所有請求都分發(fā)在同一臺服務器處理,因此無法實現(xiàn)有狀態(tài)的請求跺嗽。
好比最簡單的登錄功能舔庶,客戶端發(fā)送請求登錄成功,然后將其登錄的狀態(tài)保存在 session 中瞧甩,結(jié)果客戶端的第二次請求被分發(fā)到了另外一臺機器弥鹦。
由于第二臺服務器 session 中沒有相關(guān)的登錄信息,因此會要求客戶端重新登錄朦促,這顯然造成的用戶體驗感是極差的务冕,那么對于這種問題又該如何解決呢幻赚?
主要有兩種方案:
- 采用外部中間件存儲 session,例如 Redis箩退,然后從 Redis 中獲取登錄狀態(tài)佳谦。
- 采用特殊的請求分發(fā)策略,確保同一客戶端的所有請求都會去到同一臺機器上處理啥刻。
一致性哈希算法就是一種能夠能夠確保同一客戶端的所有請求都會被分發(fā)到同一臺機器的策略郑什,不過一致性哈希算法依舊會存在問題,就是當集群中某個節(jié)點下線,或者集群出現(xiàn)拓展時兜粘,那么也會影響最終分發(fā)的目標機器。
所以一般一致性哈希算法并不能 100% 解決 session 一致性的問題剃法,因此該算法一般很少用于網(wǎng)關(guān)層的請求分發(fā)贷洲,更多的場景是應用在分布式緩存等情況晋柱,接下來一起來看看。
| 通過其他分發(fā)算法實現(xiàn)緩存
在講解一致性哈希算法之前钦椭,大家先來簡單理解一下一致性哈希算法的產(chǎn)生背景碑诉。
先思考一個問題:假設目前單臺緩存服務器無法承擔外部的訪問壓力,此刻會如何去做呢德挣?
答案是增加新的緩存服務器節(jié)點格嗅,拓展出一個集群對外提供服務祸泪。
好的,那問題又來了懂扼,現(xiàn)在緩存服務器是一個集群環(huán)境,此刻來了一個請求后該落入哪個節(jié)點呢赶熟?
假設采用輪詢策略陷嘴,那么寫入 xxx 緩存信息的請求被分發(fā)到了第一個節(jié)點,客戶端讀取 xxx 時邑退,請求又被分發(fā)到了第三個節(jié)點上地技,那么顯然是讀不到之前的緩存秒拔。
而且最關(guān)鍵的是,一般的輪詢策略都是需要基于集群的節(jié)點數(shù)量進行請求分發(fā)的作谚,因此集群中的節(jié)點一旦出現(xiàn)伸縮庵芭,最終會導致所有緩存內(nèi)容全部失效。
就拿最基本的取模輪詢來說彬伦,原本集群是 3 個節(jié)點单绑,所以是基于取模 3 去分發(fā)請求曹宴,結(jié)果有臺節(jié)點宕機了,成為了取模 2区转,那最后整個緩存系統(tǒng)分發(fā)請求完全亂套.....
如果采用隨機策略.....版扩,更不靠譜.....
因此在這種需求背景下,大名鼎鼎的一致性哈希算法問世了蜻韭,一致性哈希算法其實也使用的取模方式,只是闺魏,剛才描述的取模輪詢法是對服務器的數(shù)量進行取模俯画,而一致性哈希算法是對 2^32 取模,什么意思呢泡仗?我們一點點來講材泄。
| 一致性哈希核心-哈希環(huán)
實現(xiàn)一致性哈希算法的核心結(jié)構(gòu)在于哈希環(huán),前面講到過一致性哈希是基于 2^32 做取模。
那么首先可以將二的三十二次方想象成一個圓辣辫,這個圓總共由 2^32 個點組成急灭,如下:
圓環(huán)的正上方第一個點代表 0,0 右側(cè)的點按照 1卖鲤、2畴嘶、3、4....的順序依此類推区匣,直到 2^32-1亏钩,也就是說 0 左側(cè)的第一個點代表著 2^32-1欺旧。
最終這個在邏輯上由 2^32 個點組成的圓,被稱為哈希環(huán)栅哀。
結(jié)合之前的緩存案例,假設有四臺緩存服務器 A钙蒙、B间驮、C、D扛施,然后再通過每臺服務器的 IP 哈希值取模 2^32疙渣,最終必然會得到一個 2^32 范圍之內(nèi)的整數(shù)堆巧,這個數(shù)在哈希環(huán)上定然也對應著一個點。
那么每臺服務器的 IP 就可以映射到哈希環(huán)上啦租,如下:
到此時荒揣,服務器已經(jīng)和哈希環(huán)建立起了聯(lián)系系任,那么此時當客戶端發(fā)送請求時,又可以通過相同的計算方式俩滥,將客戶端需要操作的緩存 Key 進行相同的哈希取模,然后同樣將其映射到哈希環(huán)上荆针。
例如寫入一條緩存 name=竹子航背,如下:
那么此時該緩存糾結(jié)要落入到哪臺服務器呢棱貌?答案是 B,為什么今魔?因為在哈希環(huán)結(jié)構(gòu)中,沿著順時針方向走错森,遇到的第一臺服務器是 B,所以最終會落到 B 服務器上殃姓。
當然瓦阐,如果一致性哈希算法被用于請求分發(fā),那么就以用戶的 IP 作為哈希取模的條件踏幻,這樣就能確保同一個客戶端的所有請求都會被分發(fā)到同一臺服務器戳杀。
一致性哈希算法中,就利用哈希環(huán)結(jié)構(gòu)+哈希取模判斷每個請求該落入的服務器吆倦,由于服務器 IP坐求、客戶端 IP 或緩存的 Key 都是相同的桥嗤,所以在服務器數(shù)量不變的情況仔蝌,相同的哈希條件進行哈希取模,最終計算出來的值永遠都是相同的渊鞋。
然后再通過計算出的值瞧挤,在哈希環(huán)結(jié)構(gòu)上進行順時針查找特恬,能夠定位到的服務器也是相同的,所以相同屬性的請求永遠會落入到同一服務器癌刽。
| 哈希環(huán)的映射偏移問題
經(jīng)過上述分析后,好像發(fā)現(xiàn)一致性哈希算法沒啥大毛病衡奥,但上述中屬于“理想狀態(tài)”:
可偏偏理想很豐滿,現(xiàn)實卻很骨感失息,實際映射服務器 IP 的過程中乏屯,可能會出現(xiàn)如下情況:
由于服務器 IP 哈希取模后,無法確保哈希得到的數(shù)字能夠均勻分布蛤迎,因此就有可能造成如上情況替裆,所有的服務器IP都被映射在“一塊兒”窘问,最終導致 A 服務器承載了 90% 以上的訪問壓力。
| 映射偏移造成的宕機連鎖反應
接上述惠赫,如果服務器 IP 映射在哈希環(huán)上出現(xiàn)偏移儿咱,在大流量的沖擊下,這種情況很容易導致整個集群崩塌怠缸,首先是A扛不住并發(fā)沖擊钳宪,宕機下線,緊接著流量交給 B搔体,B 也扛不住侦高,接著宕機,然后 C.....
因此哈希環(huán)映射偏移問題可能會造成的一系列連鎖反應奉呛,所以在一致性哈希算法中,為了確保整個集群的健壯性登馒,提出了一種虛擬節(jié)點的概念來解決此問題。
虛擬節(jié)點其實本質(zhì)上就是真實服務器節(jié)點的復制品圈纺,虛擬節(jié)點映射的 IP 都是指向于真實服務器的麦射。
就類似平時 .EXE 軟件的快捷方式,現(xiàn)在為 QQ 創(chuàng)建了一個快捷方式蛔琅,然后拷貝到了十個不同的目錄下峻呛,但本質(zhì)上這十個快捷方式指向的啟動文件都是相同 exe 程序。
哈希環(huán)中的虛擬節(jié)點也同理寨躁,如下:
從上圖中可以看出职恳,A方面、B、C、D 四臺服務器分別都映射出了一個虛擬節(jié)點贺氓,引入虛擬節(jié)點后會明顯感覺出來辙培,原本 A 服務器需要承載 90% 以上的流量,但此刻映射出的虛擬節(jié)點大大減輕了 A 的壓力搀别,將流量均攤到了集群中的每個節(jié)點尾抑。
在一致性哈希算法的實際應用場景中蒂培,絕非只映射一個虛擬節(jié)點护戳,往往會為一個真實節(jié)點映射數(shù)十個虛擬節(jié)點媳荒,以便于減小哈希環(huán)偏移所帶來的影響驹饺。
同時,虛擬節(jié)點的數(shù)量越多鱼炒,請求在分發(fā)時也能更均勻的分布卡儒,哈希環(huán)最終結(jié)構(gòu)如下:
| Java 實現(xiàn)一致性哈希算法
講了這么多骨望,那么一致性哈希算法究竟如何實現(xiàn)呢?接下來一起看看:
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 ConsistentHash {
// 使用有序的紅黑樹結(jié)構(gòu)擎鸠,用于實現(xiàn)哈希環(huán)結(jié)構(gòu)
private static TreeMap<Integer,String> virtualNodes = new TreeMap<>();
// 每個真實節(jié)點的虛擬節(jié)點數(shù)量
private static final int VIRTUAL_NODES = 160;
static {
// 對每個真實節(jié)點添加虛擬節(jié)點,虛擬節(jié)點會根據(jù)哈希算法進行散列
for (String serverIP : Servers.SERVERS) {
// 將真實節(jié)點的IP映射到哈希環(huán)上
virtualNodes.put(getHashCode(serverIP), serverIP);
// 根據(jù)設定的虛擬節(jié)點數(shù)量進行虛擬節(jié)點映射
for (int i = 0; i < VIRTUAL_NODES; i++){
// 計算出一個虛擬節(jié)點的哈希值(只要不同即可)
int hash = getHashCode(serverIP + i);
// 將虛擬節(jié)點添加到哈希環(huán)結(jié)構(gòu)上
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();
// 如果沒有大于該元素的子樹了锭吨,則取整棵樹的第一個元素凿傅,相當于取哈希環(huán)中的最小元素
if (sortedMap == null)
treeNodeKey = virtualNodes.firstKey();
// 返回對應的虛擬節(jié)點名稱
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;
// 如果算出來的值為負數(shù)則取其絕對值
if (hash < 0)
hash = Math.abs(hash);
return hash;
}
public static void main(String[] args){
// 用for循環(huán)模擬五個不同的IP訪問
for (int i = 1; i <= 5; i++){
System.out.println("第"+ i + "個請求:" + getServer("192.168.12.13"+i));
}
System.out.println("-----------------------------");
// 用for循環(huán)模擬三個相同的IP訪問
for (int i = 1; i <= 3; i++){
System.out.println("第"+ i + "個請求:" + getServer("192.168.12.131"));
}
}
}
/********輸出結(jié)果*******/
第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 實現(xiàn)一致性哈希算法的全過程聪舒,其實并不難理解箱残,里面用到了 TreeMap 實現(xiàn)了哈希環(huán)結(jié)構(gòu),并且指定了每個服務器節(jié)點的虛擬節(jié)點數(shù)量止吁,同時實現(xiàn)了一個簡單的哈希方法被辑,用于計算入?yún)⒌墓V怠?/p>
算法過程如下:
- 啟動時先根據(jù)指定的數(shù)量燎悍,映射對應的虛擬節(jié)點數(shù)量在哈希環(huán)上。
- 通過計算客戶端哈希值敷待,然后在哈希環(huán)上取得大于該值的節(jié)點间涵,然后返回對應的 IP。由于哈希環(huán)是取順時針方向的第一個節(jié)點作為處理請求的目標服務器榜揖,所以獲取大于該哈希值的節(jié)點中的第一個節(jié)點即可勾哩。
- 如果哈希環(huán)中沒有大于客戶端哈希值的節(jié)點思劳,那么則將這些客戶端的請求分發(fā)到整個 Map 上的第一臺服務器潜叛,從此實現(xiàn)哈希閉環(huán)威兜。
一致性哈希算法由于其特性椒舵,因此一般多被用于分布式緩存中的集群分片约谈,尤其是 MemCache 的緩存分片棱诱,就是采用一致性哈希算法實現(xiàn)的迈勋。
而 Redis 自身推出的 RedisCluster 分片集群中靡菇,也借用了一致性哈希算法的思想镰官,不過進行了改版實現(xiàn)泳唠,內(nèi)部采用 CRC16+HashSolt 實現(xiàn)了緩存分片笨腥,但核心思想也是相同的脖母。
當然谆级,文中給出的算法過程都是較為簡單的實現(xiàn),如若想要參考完整的實現(xiàn)脚仔,可以參考 :
- Dubbo 的 com.alibaba.dubbo.rpc.cluster.loadbalance 包
- 或參考 SpringCloudRibbon 的 com.netflix.loadbalancer 包下的實現(xiàn)
最小活躍數(shù)算法
上述分析的基本算法鲤脏、平滑輪詢加權(quán)猎醇、一致性哈希等算法都屬于靜態(tài)算法硫嘶,也就是說這些算法配置后音半,并不會根據(jù)線上的實際運行情況進行調(diào)整曹鸠,只會根據(jù)已配置的規(guī)則進行請求分發(fā)彻桃。
最小活躍數(shù)算法則會根據(jù)線上的實際情況進行分發(fā)邻眷,能夠靈活的檢測出集群中各個節(jié)點的狀態(tài)肆饶,能夠自動尋找并調(diào)用活躍度最低的節(jié)點處理請求驯镊。
Java 實現(xiàn)如下:
// 節(jié)點類:用于封裝集群中的每個節(jié)點
public class Server {
private String IP;
private AtomicInteger active;
// private Integer weight;
public Server(){}
public Server(String IP,int active) {
this.IP = IP;
// 將外部傳遞的活躍數(shù)作為默認活躍數(shù)
this.active = new AtomicInteger(active);
}
public String getIP() {
// 每分發(fā)一個請求時自增一次活躍數(shù)
active.incrementAndGet();
return IP;
}
public AtomicInteger getActive() {
return active;
}
}
// 集群類:用于模擬集群節(jié)點列表
public class Servers {
// 活躍度衰減器
public static void attenuator(){
new Thread(()->{
// 遍歷集群中的所有節(jié)點
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();
}
// 模擬的集群節(jié)點信息橄镜,活躍數(shù)最開始默認為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)
);
}
// 最小活躍數(shù)算法實現(xiàn)類
public class LeastActive {
public static String getServer(){
// 初始化最小活躍數(shù)和最小活躍數(shù)的節(jié)點
int leastActive = Integer.MAX_VALUE;
Server leastServer = new Server();
// 遍歷集群中的所有節(jié)點
for (Server server : Servers.SERVERS) {
// 找出活躍數(shù)最小的節(jié)點
if (leastActive > server.getActive().get()){
leastActive = server.getActive().get();
leastServer = server;
}
}
// 返回活躍數(shù)最小的節(jié)點IP
return leastServer.getIP();
}
public static void main(String[] args){
Servers.attenuator();
for (int i = 1; i <= 10; i++){
System.out.println("第"+ i + "個請求:" + getServer());
}
}
}
/********運行結(jié)果*********/
第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
觀察如上案例的運行結(jié)果,似乎結(jié)果好像是輪詢的效果呀裆馒?確實是的领追,這是因為在最開始绒窑,所有節(jié)點的活躍數(shù)都為 0些膨,三個節(jié)點的活躍數(shù)都相同订雾。
所以默認會先取集群中的第一個活躍數(shù)為 0 的節(jié)點處理請求洼哎,第一個節(jié)點的活躍數(shù)會變成 1噩峦,第二次請求時最小活躍數(shù)也為 0识补,然后取第二個節(jié)點處理請求凭涂,依此類推......
在線上環(huán)境下切油,不會出現(xiàn)輪詢的效果澎胡,因為每臺服務器隨著運行時間的增長,活躍數(shù)必然會不同,因此該算法總會取活躍數(shù)最小的節(jié)點提供服務巢株。
當然阁苞,上述案例中實現(xiàn)的最小活躍數(shù)那槽,是比較簡易的版本骚灸,對于完善的實現(xiàn)可以參考 Dubbo 框架中的 com.alibaba.dubbo.rpc.cluster.loadbalance.LeastActiveLoadBalance 類甚牲,其中也實現(xiàn)了權(quán)重機制丈钙。
簡單闡述一下其中的原理實現(xiàn):
- 先從注冊中心中拉取所有的服務實例雏赦,然后找出活躍數(shù)最小的節(jié)點。
- 如果只有一個填大,那么則直接返回對應的實例節(jié)點處理本次請求栋盹。
- 如果存在多個例获,則根據(jù)每個節(jié)點配置的權(quán)重值來決定本次處理請求的具體節(jié)點榨汤。
- 如果權(quán)重值不同妓灌,優(yōu)先選取權(quán)重值最大的實例虫埂,作為處理本次請求的節(jié)點掉伏。
- 如果存在相同的最大權(quán)重值斧散,那么則通過隨機的方式選擇一個節(jié)點提供服務鸡捐。
當然箍镜,由于需要對每個節(jié)點去實現(xiàn)活躍數(shù)監(jiān)聽,所以在 Dubbo 框架中薪夕,想要配置最小活躍數(shù)策略馏慨,那么需要首先啟用 ActiveLimitFilter 記錄每個節(jié)點的活躍數(shù)写隶。
或者也可以參考 Ribbon 框架 com.netflix.loadbalancer 包下面的 BestAvailableRule 最小活躍數(shù)算法實現(xiàn)類慕趴。
從最小活躍數(shù)算法特性不難得知冕房,該算法帶來的優(yōu)勢極為明顯耙册,永遠都能選取節(jié)點列表中最空閑的那臺服務器處理請求详拙,從而避免某些負載過高的節(jié)點蹲诀,還依舊承擔需要承擔新的流量訪問侧甫,造成更大的壓力。
最優(yōu)響應算法
與前面分析的最小活躍數(shù)算法一樣冷冗,最優(yōu)響應算法也是一種動態(tài)算法蒿辙,但它比最小活躍數(shù)算法更加智能思灌,因為最小活躍數(shù)算法中泰偿,如果一臺節(jié)點存在故障,導致它自身處理的請求數(shù)比較少攒发,那么它會遭受最大的訪問壓力羔砾,這顯然是并不合理的姜凄。
最小活躍數(shù)算法就類似于平時的搬磚工作檀葛,誰事情做的最少誰留下來加班屿聋,在正常情況下润讥,這種算法都能夠找到“摸魚”最厲害的員工留下來加班撮慨。
但如果有一天砌溺,某個員工由于身體出問題了规伐,導致自己做的工作量比較少猖闪,但按照這種算法的邏輯培慌,依舊會判定為該員工今天最閑吵护,所以留下來加班。
從上述這個案例中进胯,大家略微能夠感受出來最小活躍數(shù)算法的不合理性偎血。
而最優(yōu)響應算法則更加智能颇玷,該算法在開始前帖渠,會對服務列表中的各節(jié)點發(fā)出一個探測請求(例如 Ping 或心跳包檢測)空郊,然后根據(jù)各節(jié)點的響應時間來決定由哪臺服務器處理客戶端請求锁摔,該算法能較好根據(jù)節(jié)點列表中每臺機器的當前運行狀態(tài)分發(fā)請求谐腰。
Java 實現(xiàn)如下:
public class Servers {
// 模擬的集群節(jié)點信息十气,活躍數(shù)最開始默認為0
public static List<Server> SERVERS = Arrays.asList(
new Server("44.120.110.001:8080"),
new Server("44.120.110.002:8081"),
new Server("44.120.110.003:8082")
);
}
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之間的隨機數(shù)
int random = ThreadLocalRandom.current().nextInt(1000, 2000);
try {
// 隨機休眠一段時間,模擬不同的響應速度
Thread.sleep(random);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 最后返回自身的IP
return this.IP;
}
}
public class ResponseTime {
// 創(chuàng)建一個定長的線程池,用于去執(zhí)行ping任務
static ExecutorService pingServerPool =
Executors.newFixedThreadPool(Servers.SERVERS.size());
public static String getServer() throws InterruptedException {
// 創(chuàng)建一個CompletableFuture用于拼接任務
CompletableFuture cfAnyOf;
// 創(chuàng)建一個接收結(jié)果返回的server節(jié)點對象
final Server resultServer = new Server();
// 根據(jù)集群節(jié)點數(shù)量初始化一個異步任務數(shù)組
CompletableFuture[] cfs = new CompletableFuture[Servers.SERVERS.size()];
// 遍歷整個服務器列表离福,為每個節(jié)點創(chuàng)建一個ping任務
for (Server server : Servers.SERVERS) {
// 獲取當前節(jié)點在集群列表中的下標
int index = Servers.SERVERS.indexOf(server);
// 為每個節(jié)點創(chuàng)建一個ping任務妖爷,并交給pingServerPool線程池執(zhí)行
CompletableFuture<String> cf =
CompletableFuture.supplyAsync(server::ping,pingServerPool);
// 將創(chuàng)建好的異步任務加入數(shù)組中
cfs[index] = cf;
}
// 將創(chuàng)建好的多個Ping任務組合成一個聚合任務并執(zhí)行
cfAnyOf = CompletableFuture.anyOf(cfs);
// 監(jiān)聽執(zhí)行完成后的回調(diào)絮识,誰先執(zhí)行完成則返回誰
cfAnyOf.thenAccept(resultIP -> {
System.out.println("最先響應檢測請求的節(jié)點為:" + resultIP);
resultServer.setIP((String) resultIP);
});
// 阻塞主線程一段時間,防止CompletableFuture退出
Thread.sleep(3000);
// 返回最先響應檢測請求(ping)的節(jié)點作為本次處理客戶端請求的節(jié)點
return resultServer.getIP();
}
public static void main(String[] args) throws InterruptedException {
for (int i = 1; i <= 5; i++){
System.out.println("第"+ i + "個請求:" + getServer());
}
}
}
/******運行結(jié)果:******/
最先響應檢測請求的節(jié)點為:44.120.110.002:8081
第1個請求:44.120.110.002:8081
最先響應檢測請求的節(jié)點為:44.120.110.002:8081
第2個請求:44.120.110.002:8081
最先響應檢測請求的節(jié)點為:44.120.110.003:8082
第3個請求:44.120.110.003:8082
最先響應檢測請求的節(jié)點為:44.120.110.003:8080
第4個請求:44.120.110.001:8080
最先響應檢測請求的節(jié)點為:44.120.110.002:8081
第5個請求:44.120.110.002:8081
在該案例中彼念,其實現(xiàn)過程對比之前的算法略微復雜一些逐沙,首先在 Server 實例類中定義了一個 Ping() 方法,該方法中使用隨機數(shù)+線程休眠的方式簡單模擬了一下節(jié)點的不同的響應速度帝簇。
然后在算法實現(xiàn)類中,利用 CompletableFuture 分別對每一個節(jié)點都創(chuàng)建了對應的 Ping 任務捆毫,然后同時執(zhí)行绩卤,又通過 thenAccept() 回調(diào)方法監(jiān)聽了執(zhí)行結(jié)果濒憋,誰最先響應,則取其作為處理本次請求的節(jié)點条辟。
這個算法的實現(xiàn)過程中本姥,唯一難理解的就是 CompletableFuture婚惫,它是 JDK8 中推出的一種異步任務先舷。
這里只是舉例實現(xiàn)蒋川,所以通過 CompletableFuture 實現(xiàn)了檢測請求尔破,但實際過程中如果要選擇這種算法懒构,那么基于 Netty 會更為合適。
從上述案例的運行結(jié)果中也可以得知:最優(yōu)響應算法無論在何種情況下秩霍,都能從集群中選取性能最好的節(jié)點對外服務铃绒,Nginx 中也支持配置這種算法颠悬,但需要先安裝對應的 nginx-upstream-fair 模塊诞外。
總結(jié)
在本文中峡谊,對于比較常用的請求分發(fā)算法進行了剖析及手寫實踐既们,其中提到了較為傳統(tǒng)的靜態(tài)調(diào)度算法:輪詢啥纸、隨機、加權(quán)莹妒、一致性哈希等旨怠,也談到了一些較為智能的動態(tài)算法:最小活躍數(shù)、最優(yōu)響應等百揭。
但需要牢記的一點是:并非越智能的算法越好课锌,越是并發(fā)高渺贤、流量大的場景下瞭亮,反而選用最基本的算法更合適统翩,例如微信的紅包業(yè)務唆缴,就是采用最基本的輪詢算法進行集群調(diào)度面徽。
那這又是為何呢趟紊?因為越智能的調(diào)度算法霎匈,進行節(jié)點選擇時的開銷會更大,如果你對于文中給出的調(diào)度算法實現(xiàn)都一一運行過墨吓,那么大家會明顯感知出:越到后面的算法帖烘,分發(fā)請求的速度越慢秘症。
因此在面臨巨大訪問壓力的情景中乡摹,選擇最簡單的算法反而帶來的收益更高趟卸,但前提是需要集群中所有的節(jié)點硬件配置都一致图云,所有節(jié)點分配的資源都相同竣况,輪詢算法則是最佳的調(diào)度算法。