自古帝王多短命,假如皇帝也懂負載均衡算法...

大家都知道古代皇帝各個都是後宮佳麗三千,而皇帝身上都天然的帶着雨露均沾的精神,不想單獨的寵愛一人!

轉自:51CTO技術棧

弱水三千,又怎捨得只取一瓢飲?據傳皇帝們晚上睡覺個個都怕冷,因此每晚都需要有人侍寢,那麼這麼多後宮,該翻誰牌子、怎麼分配侍寢名額呢?

還別說,皇帝行房事竟還挺講究的!早在《春秋》就有記載“晦陰惑疾,明謠心疾,以闢六氣”。

九嬪以下,每九人中進御一人,八十一女御佔九個晚上,世婦二十七人佔三個晚上,九嬪佔一個晚上,三夫人佔一個晚上,以上共十四夜,皇后獨佔一個晚上,共十五夜。上半個月按上述安排進御,下半個月從十六日開始,由皇后起,再御九嬪、世婦、女御,與月亮由盛而衰相對應......

不同朝代的皇帝也有不同寵幸妃子的方法,著名的有羊車望幸、擲篩侍寢、蝶幸、翻牌懸燈等等。

不過在我看來,如果皇帝懂負載均衡算法的話,大可不必這麼折騰,一套算法便可搞定終身侍寢大事!因此我們今天來介紹幾種常用的負載均衡算法及代碼實現!

先看下文章的大綱:

  • 輪詢算法

  • 加權輪詢算法

  • 隨機算法

  • 加權隨機算法

  • 源地址 hash 算法

  • 一致性 hash 算法

輪詢算法

據史料記載,乾隆一生妃嬪就有 42 人,還不算大明湖畔的夏雨荷等在下江南時候留下的情。

假設在某個時期內,皇阿瑪最寵幸的有令妃、嫺妃、高貴妃、純妃四位。那普通的輪詢算法怎麼去選擇呢?

我們先定義一個妃子集合如下:

/**
 * *所有妃子集合
 */
public static final List<String> PRINCESS_LIST = Arrays.asList("令妃", "嫺妃", "高貴妃", "純妃");

然後從列表中輪詢侍寢的妃子,用一個變量 index 去記錄輪詢的位置。

// 記錄循環的位置
private static Integer index = 0;
public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        System.out.println(getPrincess());
    }
}
private static String getPrincess() {
    // 超過數組大小需要歸零(每次獲取前判斷,防止配置變化導致索引越界)
    if (index >= PrincessConfig.PRINCESS_LIST.size()) {
        index = 0;
    }
    String princess = PrincessConfig.PRINCESS_LIST.get(index);
    index++;
    return princess;
}

輸出結果就不貼出來了。該算法的特點就是簡單、簡單、簡單!但是也存在很大缺點!

如果皇帝更寵愛令妃,想讓她侍寢的概率更高呢?那就需要用到下面的加權輪詢算法!

加權輪詢算法

加權輪詢就是可以主觀的給每個妃子設置一個喜好值(權重值),以控制被選中的概率,因此我們需要定義如下的配置:

/**
 * *所有妃子集合
 */
public static final Map<String, Integer> PRINCESS_MAP = new LinkedHashMap<String, Integer>() {
    {
        put("令妃", 5);
        put("嫺妃", 1);
        put("高貴妃", 3);
        put("純妃", 2);
    }
};

這裏的配置就不再是簡單的一個集合,每個妃子都對應了一個權重值,那輪詢的時候怎麼根據這個值去提高被選中的概率呢?

下面我們來講三種比較常見的實現。

加權輪詢實現一

我們的思路是把這個 map 的 key(妃子)根據權重值轉存到一個 list 中,然後再去輪詢這個 list,如果權重值爲 5,那就在 list 中添加 5 條相同的記錄!

然後我們去遍歷這個 list,這樣權重值越高,在 list 中出現的概率就越高,被輪詢中的概率也就越高!

// 記錄循環的位置
private static Integer index = 0;

public static void main(String[] args) {
   for (int i = 0; i < 11; i++) {
       System.out.println(getPrincess1());
  }
}

private static String getPrincess1() {

   // 遍歷map放入到list中
   List<String> princessList = new ArrayList<String>();
   for (String princess : PrincessConfig.PRINCESS_MAP.keySet()) {
       int weight = PrincessConfig.PRINCESS_MAP.get(princess);
       // 根據權重值重複放入到一個list中
       for (int i = 0; i < weight; i++) {
           princessList.add(princess);
      }
  }

   if (index >= princessList.size()) {
       index = 0;
  }
   String princess = princessList.get(index);

   index++;

   return princess;
}

輸出結果如下:

該加權輪詢算法比較簡單,比較容易實現。但是也有個問題,我們配置的權重值是 5、1、3、2,那我們是不是也可以配置成 50、10、30、20 呢?

那按照上面的方式,我們是不是就需要把同樣的元素往 list 裏面放幾百個呢?這顯然是比較不合理且耗內存的!

加權輪詢實現二

基於上面的算法存在的問題,我們考慮用類似佔比的方式來處理。

比如我們配置的權重值爲 50、10、30、20,那在橫座標上表示就是 0_____50_60__80__110。

我們還是用一個 index 去記錄輪詢的位置,如果 index 在 0~50 之間就代表第一個妃子被選中,如果在 50~60 之間就代表第二個妃子被選中......

我們看具體代碼實現:

// 記錄循環的位置
private static Integer indexInteger = 0;

public static void main(String[] args) {
   for (int i = 0; i < 11; i++) {
       System.out.println(getPrincess2());
  }
}

private static String getPrincess2() {
   //記錄總權重值
   int totalWeight = 0;
   for (String princess : PrincessConfig.PRINCESS_MAP.keySet()) {
       int weight = PrincessConfig.PRINCESS_MAP.get(princess);
       totalWeight += weight;
  }

   // 歸零
   if (indexInteger >= totalWeight) {
       indexInteger = 0;
  }

   int index = indexInteger;
   String result = null;
   for (String princess : PrincessConfig.PRINCESS_MAP.keySet()) {
       int weight = PrincessConfig.PRINCESS_MAP.get(princess);

       // 落在當前區間 直接返回
       if (index < weight) {

           result = princess;
           break;
      }

       // 沒有落在當前區間 繼續循環
       index = index - weight;

  }

   indexInteger++;
   return result;
}

輸出結果與上面的方法一毛一樣:

該加權輪詢算法略複雜於第一種,但是這兩種實現都存在的共同問題是,按照我們目前的配置去輪詢會連續 5 次令妃、再 1 次嫺妃、再 3 次高貴妃......

連續 5 次!就算皇阿瑪再喜歡令妃,怕是令妃也有點吃不消!用計算機術語說也就是負載不是很均衡!

加權輪詢實現三(平滑加權輪詢)

平滑加權輪詢算法就是爲了解決上面負載不均衡的情況的,該算法實現起來相對比較複雜!

每個妃子不僅有一個權重值(weight),還有一個會變化的動態權重值(dynamicWeight)來輔助計算。

動態權重值計算邏輯如下:

  • 動態權重值 dynamicWeight 初始爲 0。

  • 每次獲取輪詢獲取目標妃子時先設置 dynamicWeight=dynamicWeight+weight。

  • 然後找到所有妃子中動態權重值 dynamicWeight 最大的一個,則爲本次輪詢到的目標。

  • 將本次輪詢到的目標的 dynamicWeight 設置爲 dynamicWeight-totalWeight(總權重值)。

這樣看可能會有點不是很明白,我們來做個推算,假設我們還是有如下配置(配置中只有妃子名稱及對應的權重值):

/**
 * *所有妃子集合
 */
public static final Map<String, Integer> PRINCESS_MAP = new LinkedHashMap<String, Integer>() {
    {
        put("令妃", 5);
        put("嫺妃", 1);
        put("高貴妃", 3);
        put("純妃", 2);
    }
};

在上面的配置中總權重值 totalWeight=5+1+3+2 等於 11。

①按照上面算法的第一點,在第一次輪詢目標之前她們的 dynamicWeight 都是0。

因此四位妃子的 weight 和 dynamicWeight 值如下:

②按照上面算法的第二點,在第一次輪詢選中目標的時候 dynamicWeight=dynamicWeight+weight。

變化後四位妃子的 weight 和 dynamicWeight 值如下:

③按照上面算法的第三點,然後找最大的 dynamicWeight,也就是 5,因此第一次輪詢選中的就是令妃。

④按照上面算法的第四點,令妃的 dynamicWeight 需要減去 totalWeight。

變化後四位妃子的 weight 和 dynamicWeight 值如下:

然後第二次輪詢的時候又需要按照算法的第一點設置 dynamicWeight。

設置後四位妃子的 weight 和 dynamicWeight 值如下:

就這樣一直按照算法處理下去,輪詢完 11 次後,所有妃子的 dynamicWeight 又會全部變爲 0......

如果大家依然還是有點模糊,我們只能上代碼爲敬了!我們需要先定義一個實體,來存放每個妃子及對應的 weight 及 dynamicWeight 屬性:

/**
 * *權重實體
 * 
 * @author sullivan
 *
 */
public class PrincessWeight {
    private String princess;
    private Integer weight;
    private Integer dynamicWeight;
    public PrincessWeight(String princess, Integer weight, Integer dynamicWeight) {
        super();
        this.princess = princess;
        this.weight = weight;
        this.dynamicWeight = dynamicWeight;
    }
}

然後定義兩個全局的對象存放對象:

// 每個妃子及對應的權重實體
private static Map<String, PrincessWeight> weightMap = new HashMap<String, PrincessWeight>();
// 總權重值
private static int totalWeight = 0;

再進行算法的實現:

private static String getPrincess() {    
    // 初始化妃子及對應的權重實體
    if (weightMap.isEmpty()) {
        //將配置初始化到map中去
        for (String princess : PrincessConfig.PRINCESS_MAP.keySet()) {
            // 算法的第一點:初始dynamicWeight爲0
            weightMap.put(princess, new PrincessWeight(princess, PrincessConfig.PRINCESS_MAP.get(princess), 0));
            totalWeight += PrincessConfig.PRINCESS_MAP.get(princess);
        }
    }

    // 算法的第二點:設置currentWeight=設置weight+currentWeight
    for (PrincessWeight weight : weightMap.values()) {
        weight.setDynamicWeight(weight.getWeight() + weight.getDynamicWeight());
    }

    // 算法的第三點:尋找最大的currentWeight
    PrincessWeight maxPrincessWeight = null;
    for (PrincessWeight weight : weightMap.values()) {
        if (maxPrincessWeight == null || weight.getDynamicWeight() > maxPrincessWeight.getDynamicWeight()) {
            maxPrincessWeight = weight;
        }
    }

    // 算法的第四點:最大的dynamicWeight = dynamicWeight-totalWeight
    maxPrincessWeight.setDynamicWeight(maxPrincessWeight.getDynamicWeight() - totalWeight);

    return maxPrincessWeight.getPrincess();
}

最終輸出如下:

這樣經過 11 次輪詢,令妃同樣出現了 5 次,但是明顯不會再像之前算法那樣連續出現了,會均衡得多!!!如果還有不清楚的,可以去文末的 Github 地址上下載代碼自己調試及理解!

隨機算法

平滑加權輪詢算法能很好的進行負載了!但是皇阿瑪又說了,按照輪詢算法,我自己都能夠推出來每晚侍寢的妃子,不刺激不刺激。

皇帝嘛,總喜歡來些新鮮的刺激的我們也可以理解!還好我們有隨機算法可以解決,每晚都是隨機選一個,讓皇帝無法提前推測,給皇帝足夠的刺激感!

我們依然先定義一個妃子集合如下:

/**
 * *所有妃子集合
 */
public static final List<String> PRINCESS_LIST = Arrays.asList("令妃", "嫺妃", "高貴妃", "純妃");

然後利用隨機函數去選擇一個目標:

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        System.out.println(getPrincess());
    }
}
/**
 * *隨機獲取侍寢妃子
 * @return
 */
private static String getPrincess() {
    SecureRandom rd = new SecureRandom();
    int index = rd.nextInt(PrincessConfig.PRINCESS_LIST.size());
    return PrincessConfig.PRINCESS_LIST.get(index);
}

因爲輸出是隨機的,所以這裏就不貼出來了。如果明白了輪詢算法,隨機算法理解起來也就簡單了,只是在輪詢中用一個全局的 index 去保存每次循環的位置,而在隨機中是每次去隨機出來一個值。

加權隨機算法

加權隨機實現一

加權隨機實現一與上面的加權輪詢實現一的思路幾乎一毛一樣,這裏就直接上代碼了:

public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            System.out.println(getPrincess());
        }
    }
private static String getPrincess() {
    List<String> princessList = new ArrayList<String>();
    for (String princess : PrincessConfig.PRINCESS_MAP.keySet()) {
        int weight = PrincessConfig.PRINCESS_MAP.get(princess);
        for (int i = 0; i < weight; i++) {
            princessList.add(princess);
        }
    }
    Random rd = new Random();
    int index = rd.nextInt(princessList.size());
    return princessList.get(index);
}

加權隨機實現二

加權隨機實現二與上面的加權輪詢實現二的思路幾乎一模一樣,這裏也就直接上代碼了:

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        System.out.println(getPrincess2());
    }
}

private static String getPrincess2() {

    List<String> princessList = new ArrayList<String>();
    int totalWeight = 0;
    for (String princess : PrincessConfig.PRINCESS_MAP.keySet()) {
        int weight = PrincessConfig.PRINCESS_MAP.get(princess);
        totalWeight += weight;
        for (int i = 0; i < weight; i++) {
            princessList.add(princess);
        }
    }

    Random rd = new Random();
    int index = rd.nextInt(totalWeight);

    String result = null;
    for (String princess : PrincessConfig.PRINCESS_MAP.keySet()) {
        int weight = PrincessConfig.PRINCESS_MAP.get(princess);

        // 落在當前區間 直接返回
        if (index < weight) {

            result = princess;
            break;
        }

        // 沒有落在當前區間 繼續循環
        index = index - weight;

    }
    return result;
}

源地址 hash 算法

我們的工作中開發系統很常見的一個需求就是需要登錄後才能訪問,這就會涉及到 session!

如果我們沒有做 session 共享,那登錄後的 session 信息只會存在我們調用登錄接口的那臺服務器上!

按照前面的輪詢算法或者隨機算法,我們同一客戶端的多次請求就會落在不同的服務器上,這樣就會導致部分接口沒權限訪問!

因此我們需要同一個客戶端多次的請求落在同一臺服務器上,這裏常見的一種做法是對源地址進行 hash!

到這裏我們也得讓我們的皇阿瑪歇會兒了,回到我們正常的業務場景中。假如我們有服務器配置如下:

/**
     * *所有服務器集合
     */
    public static final List<String> SERVER_IP_LIST = Arrays.asList(
        "192.168.1.10", 
        "192.168.2.20", 
        "192.168.3.30", 
        "192.168.4.40");

客戶端訪問的 ip 我也模擬了一個集合:

    /**
     * *客戶端ip
     */
    public static final List<String> CLIENT_IP_LIST = Arrays.asList(
        "113.88.97.173", 
        "106.11.154.33", 
        "207.46.13.149",
        "42.156.137.120", 
        "203.208.60.0", 
        "119.39.47.182", 
        "171.34.179.4", 
        "111.175.58.52", 
        "124.235.138.199",
        "175.184.166.184");

源地址 hash 算法的思路就是對客戶端的 ip 進行 hash,然後用 hash 值與服務器的數量進行取模,得到需要訪問的服務器的 ip。只要客戶端 ip 不變,那 hash 後的值就是固定的!

實現如下:

public static void main(String[] args) {
        for (String clientIp : CLIENT_IP_LIST) {
            int index = Math.abs(getHash(clientIp)) % PrincessConfig.SERVER_IP_LIST.size();
            String serverIp = PrincessConfig.SERVER_IP_LIST.get(index);
            System.out.println(clientIp + "請求的服務器ip爲" + serverIp);
        }
    }

最終輸出如下:

這樣不管執行多少次,相同的客戶端 ip 請求得到的服務器地址都是一樣的!

這種實現很簡單,但也很脆弱!因爲我們服務器的數量是可能變化的,今天下線一臺機器明天增加一臺機器是很常見的!

服務器數量一旦變化,那源地址 hash 之後取模的值可能就變化了,獲取到的服務器的 ip 自然就也會發生變化!

比如我們服務器去掉一臺 192.168.4.10 的機器再看下輸出結果:

對比輸出結果我們就能看到,影響幾乎是全局的!那我們能不能有一種方案就算是服務器數量變化,也能減少受影響的客戶端呢?這就需要用到下面的一致性 hash 算法!

一致性 hash 算法

加權輪詢算法實現二中我們講到過把權重值轉化爲橫座標展示,我們這裏是不是也可以用同樣的思路呢?

客戶端 ip 進行 hash 後不就是一個 int32 的數字嘛,那我們就可以把一個 int32 的數字分爲幾個段,讓每個服務器負責一個段的請求!

下面爲了直觀我們把服務器 192.168.2.10、192.168.2.20、192.168.2.30、192.168.2.40 分別用 IP1、IP2、IP3、IP4 表示,如上圖:

  • 如果客戶端 ip 進行 hash 後的值在 0~536870911 之間,那就交給 IP2 服務器處理。

  • 如果客戶端 ip 進行 hash 後的值在 536870911~1073741822 之間,那就交給 IP3 服務器處理。

  • 如果客戶端 ip 進行 hash 後的值在 1073741822~1610612733 之間,那就交給 IP4 服務器處理。

  • 如果客戶端 ip 進行 hash 後的值大於 1610612733 之間,那就交給 IP1 服務器處理。

但是專業一點的表示都會把這個橫座標掰彎,形成一個環,就叫所謂的 hash 環,如下圖:

這樣看就更直觀了!如果有天 IP4 這臺服務器宕機,那原來需要到 IP4 的請求就會全部轉移到 IP1 服務器進行處理。

這樣對部分客戶端的請求依然會有影響,但至少影響也只是局部的,如下圖:

這樣就可以了嗎?我們思考兩個問題:

  • 每個服務器在 hash 環上的位置是我們人爲的均勻的分配的,這樣經常需要擴容縮容的時候會不會比較難以維護呢?

  • IP4 宕機,原本會到 IP4 的請求全部轉移到 IP1,那會不會導致 IP1 的流量不均衡?能不能有一個更均衡一點的方案讓原本到 IP4 的流量均衡的轉移到 IP1、IP2、IP3 呢?

解決問題 1 的方案就是不再人爲分配結點所在的位置,而是根據服務器的 ip 計算出 hash 值,再看 hash 值落在環上的哪個位置!

這樣存在的一個問題是每個集羣的服務器 ip 都會不同,因此計算後落在環上的位置可能就是不可控的。

如上面四臺服務器計算後所在的位置可能會如下圖所示:

很明顯,這種情況是極爲不均勻的,會造成數據的傾斜!上面問題 2 的問題其實也是宕機導致的數據傾斜!

環的左上部分那麼空,我們是不是可以把現在的 4 臺服務器再根據其他的規則在左上方生成一些結點呢?這樣是不是請求就會稍微均勻一點呢?

這就是所謂的虛擬結點!虛擬結點就是同一個服務器 ip 會根據某個規則生成多個 hashcode,這樣在環上就存在多個結點了。

如下圖所示:

這裏只是模擬了每臺服務器有兩個虛擬結點,實際在開發中會更多!這樣就算 IP4 機器掛掉,請求也不會全部壓到某一臺服務器上去!

講了這麼多,但實現起來也不難,下面就該上代碼了(服務器配置及請求的客戶端 ip 與源地址 hash 算法部分的一致,這裏就不貼對應的代碼了,直接上算法邏輯):

//虛擬結點數量100
private static final Integer VIRTUAL_NODES = 100;

public static void main(String[] args) {

    // 遍歷服務器ip,生成對應的虛擬結點
    TreeMap<Integer, String> nodeMap = new TreeMap<Integer, String>();
    for (String serverIp : PrincessConfig.SERVER_IP_LIST) {
        for (int i = 0; i < VIRTUAL_NODES; i++) {
            nodeMap.put(getHash(serverIp + "VN" + i), serverIp);
        }
    }

    for (String clientIp : CLIENT_IP_LIST) {
        //這裏利用的TreeMap的特性,不清楚的可以去自己去了解一下tailMap方法的作用
        SortedMap<Integer, String> subMap = nodeMap.tailMap(getHash(clientIp));
        Integer firstKey = null;
        try {
            firstKey = subMap.firstKey();
        } catch (Exception e) {
        }

        if (firstKey == null) {
            firstKey = nodeMap.firstKey();
        }
        System.out.println("請求的服務器ip爲" + nodeMap.get(firstKey));
    }
}

到此,幾種常用的負載均衡算法及代碼實現都已介紹完畢!還有不清楚可以去同性交友網下載示例代碼自己調試:

https://github.com/sujing910206/load-balance

作者:蘇靜

簡介:有過多年大型互聯網項目的開發經驗,對高併發、分佈式、以及微服務技術有深入的研究及相關實踐經驗。經歷過自學,熱衷於技術研究與分享!格言:始終保持虛心學習的態度! 

編輯:陶家龍、孫淑娟

熱文推薦

爲了控制Bean的加載我使出了這些殺手鐗

農村小夥艱難的復工之路

這位RD同學,你好像對JD有點誤解!

好機會,我要幫女同事解決Maven衝突問題

上線前一個小時,dubbo這個問題可把我折騰慘了

如有收穫,點個在看,誠摯感謝

發佈了253 篇原創文章 · 獲贊 102 · 訪問量 55萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章