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

  • 2020 年 2 月 24 日
  • 筆記

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

轉自: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

作者:蘇靜

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

編輯:陶家龍、孫淑娟