干货文:浅谈负载均衡算法与实现
前言
记得同事曾说过一个故事:在他刚工作的时候,他同事有一天兴冲冲的跑到公司说,你们知道吗,公司请了个大牛。大牛?对,那人会写AJAX!哇,真是大牛啊,跟着他,可以学不少东西啊。我听了笑了,但有点难以理解,因为现在几乎只要是一个开发,都会写AJAX,怎么写个AJAX就算大牛呢?
后来我明白了,3 年前高深莫测的技术到现在变得普普通通,不足为奇。就像我们今天要讲的负载均衡,过去负载均衡只有大牛才能玩转,但是到今天,一个小开发都可以聊上几句。现在,我们就来简单聊聊负载均衡。
负载均衡的维度
从负载均衡设备的角度来看,分为硬件负载均衡和软件负载均衡:
- 硬件负载均衡:比如最常见的F5,还有Array等,这些负载均衡是商业的负载均衡器,性能比较好,毕竟他们就是为了负载均衡而生的,背后也有非常成熟的团队,可以提供各种解决方案,但是价格比较昂贵,所以没有充足的理由和充足的预算是不会考虑的。 
- 软件负载均衡:包括我们耳熟能详的Nginx、LVS、Tengine(阿里对Nginx进行的改造)等。优点就是成本比较低,但是也需要有比较专业的团队去维护,要自己去踩坑,去DIY。 
从负载均衡的技术角度来看,分为服务端负载均衡和客户端负载均衡:
- 服务端负载均衡:当我们访问一个服务,请求会先到另外一台服务器,然后这台服务器,会把请求分发到提供这个服务的服务器。当然如果只有一台服务器,那好说,直接把请求给那一台服务器就可以了,但是如果有多台服务器呢?这时候,就会根据一定的算法选择一台服务器。 
- 客户端负载均衡:客户端服务均衡的概念貌似是有了服务治理才产生的,简单的来说,就是在一台服务器上维护着所有服务的ip,名称等信息,当我们在代码中访问一个服务,是通过一个组件访问的,这个组件会从那台服务器上取到所有提供这个服务的服务器的信息,然后通过一定的算法,选择一台服务器进行请求。 
从负载均衡的算法来看,又分为随机、轮询、哈希、最小压力,当然可能还会加上权重的概念,负载均衡的算法就是本文的重点了。
1、随机
 
   
   
 
  
    
    
  public class Servers {
  
    
    
      public List<String> list = new ArrayList<>() {
  
    
    
          {
  
    
    
              add("192.168.1.1");
  
    
    
              add("192.168.1.2");
  
    
    
              add("192.168.1.3");
  
    
    
          }
  
    
    
      };
  
    
    
  }
 
   
   
  
 
   
   
 
  
    
    
  public class FullRandom {
  
    
    
      static Servers servers = new Servers();
  
    
    
      static Random random = new Random();
  
    
    
  
  
    
    
      public  static String  go() {
  
    
    
          var number = random.nextInt(servers.list.size());
  
    
    
          return servers.list.get(number);
  
    
    
      }
  
    
    
  
  
    
    
      public static void main(String[] args) {
  
    
    
          for (var i = 0; i < 15; i++) {
  
    
    
              System.out.println(go());
  
    
    
          }
  
    
    
      }
  
    
    
  }
 
   
   
  
运行结果:
虽说现在感觉并不是那么随机,有的服务器经常被获得到,有的服务器获得的次数比较少,但是当有充足的请求次数,就会越来越平均,这正是随机数的一个特性。
完全随机是最简单的负载均衡算法了,缺点比较明显,因为服务器有好有坏,处理能力是不同的,我们希望性能好的服务器多处理些请求,性能差的服务器少处理一些请求,所以就有了加权随机。
2、加权随机
加权随机,虽然还是采用的随机算法,但是为每台服务器设置了权重,权重大的服务器获得的概率大一些,权重小的服务器获得的概率小一些。
关于加权随机的算法,有两种实现方式:
一种是网上流传的,代码比较简单:构建一个服务器的List,如果A服务器的权重是2,那么往List里面Add两次A服务器,如果B服务器的权重是7,那么我往List里面Add7次B服务器,以此类推,然后再生成一个随机数,随机数的上限就是权重的总和,也就是List的Size。
这样权重越大,被选中的概率当然越高,代码如下:
 
   
   
 
  
    
    
  public class Servers {
  
    
    
      public HashMap<String, Integer> map = new HashMap<>() {
  
    
    
          {
  
    
    
              put("192.168.1.1", 2);
  
    
    
              put("192.168.1.2", 7);
  
    
    
              put("192.168.1.3", 1);
  
    
    
          }
  
    
    
      };
  
    
    
  }
 
   
   
  
 
   
   
 
  
    
    
  
  
    
    
  public class WeightRandom {
  
    
    
  
  
    
    
      static Servers servers = new Servers();
  
    
    
      static Random random = new Random();
  
    
    
  
  
    
    
      public static String go() {
  
    
    
          var ipList = new ArrayList<String>();
  
    
    
          for (var item : servers.map.entrySet()) {
  
    
    
              for (var i = 0; i < item.getValue(); i++) {
  
    
    
                  ipList.add(item.getKey());
  
    
    
              }
  
    
    
          }
  
    
    
          int allWeight = servers.map.values().stream().mapToInt(a -> a).sum();
  
    
    
          var number = random.nextInt(allWeight);
  
    
    
          return ipList.get(number);
  
    
    
      }
  
    
    
  
  
    
    
      public static void main(String[] args) {
  
    
    
          for (var i = 0; i < 15; i++) {
  
    
    
              System.out.println(go());
  
    
    
          }
  
    
    
      }
  
    
    
  }
 
   
   
  
运行结果:
可以很清楚的看到,权重小的服务器被选中的概率相对是比较低的。
当然我在这里仅仅是为了演示,一般来说,可以把构建服务器List的代码移动到静态代码块中,不用每次都构建。
这种实现方式相对比较简单,很容易就能想到,但是也有缺点,如果我几台服务器权重设置的都很大,比如上千、上万,那么服务器List也有上万条数据,这不是白白占用内存吗?
所以聪明的程序员想到了第二种方式,为了方便解释,还是就拿上面的例子来说。
如果A服务器的权重是2,B服务器的权重是7,C服务器的权重是1:
- 如果我生成的随机数是1,那么落到A服务器,因为1<=2(A服务器的权重); 
- 如果我生成的随机数是5,那么落到B服务器,因为5>2(A服务器的权重),5-2(A服务器的权重)=3,3<7(B服务器的权重); 
- 如果我生成的随机数是10,那么落到C服务器,因为10>2(A服务器的权重),10-2(A服务器的权重)=8,8>7(B服务器的权重),8-7(B服务器的权重)=1, 1<=1(C服务器的权重)。 
也许,光看文字描述还是不够清楚,可以结合下面丑到爆炸的图片来理解下:
- 如果生成的随机数是5,那么落到第二块区域; 
- 如果生成的随机数是10,那么落到第三块区域。 
代码如下:
 
   
   
 
  
    
    
  public class WeightRandom {
  
    
    
      static Servers servers = new Servers();
  
    
    
      static Random random = new Random();
  
    
    
  
  
    
    
      public static String go() {
  
    
    
          int allWeight = servers.map.values().stream().mapToInt(a -> a).sum();
  
    
    
          var number = random.nextInt(allWeight);
  
    
    
          for (var item : servers.map.entrySet()) {
  
    
    
              if (item.getValue() >= number) {
  
    
    
                  return item.getKey();
  
    
    
              }
  
    
    
              number -= item.getValue();
  
    
    
          }
  
    
    
          return "";
  
    
    
      }
  
    
    
  
  
    
    
      public static void main(String[] args) {
  
    
    
          for (var i = 0; i < 15; i++) {
  
    
    
              System.out.println(go());
  
    
    
          }
  
    
    
      }
  
    
    
  }
 
   
   
  
运行结果:
这种实现方式虽然相对第一种实现方式比较“绕”,但却是一种比较好的实现方式, 对内存没有浪费,权重大小和服务器List的Size也没有关系。
轮询
轮询又分为三种:完全轮询、加权轮询和平滑加权轮询。
1、完全轮询
 
   
   
 
  
    
    
  public class FullRound {
  
    
    
  
  
    
    
      static Servers servers = new Servers();
  
    
    
      static int index;
  
    
    
  
  
    
    
      public static String go() {
  
    
    
          if (index == servers.list.size()) {
  
    
    
              index = 0;
  
    
    
          }
  
    
    
          return servers.list.get(index++);
  
    
    
      }
  
    
    
  
  
    
    
  
  
    
    
      public static void main(String[] args) {
  
    
    
          for (var i = 0; i < 15; i++) {
  
    
    
              System.out.println(go());
  
    
    
          }
  
    
    
      }
  
    
    
  }
 
   
   
  
运行结果:
完全轮询,也是比较简单的,但是问题和完全随机是一样的,所以出现了加权轮询。
2、加权轮询
加权轮询还是有两种常用的实现方式,和加权随机是一样的,在这里,我就演示我认为比较好的一种:
 
   
   
 
  
    
    
  public class WeightRound {
  
    
    
  
  
    
    
      static Servers servers = new Servers();
  
    
    
  
  
    
    
      static int index;
  
    
    
  
  
    
    
      public static String go() {
  
    
    
          int allWeight = servers.map.values().stream().mapToInt(a -> a).sum();
  
    
    
          int number = (index++) % allWeight;
  
    
    
          for (var item : servers.map.entrySet()) {
  
    
    
              if (item.getValue() > number) {
  
    
    
                  return item.getKey();
  
    
    
              }
  
    
    
              number -= item.getValue();
  
    
    
          }
  
    
    
          return "";
  
    
    
      }
  
    
    
  
  
    
    
      public static void main(String[] args) {
  
    
    
          for (var i = 0; i < 15; i++) {
  
    
    
              System.out.println(go());
  
    
    
          }
  
    
    
      }
  
    
    
  }
 
   
   
  
运行结果:
加权轮询,看起来并没什么问题,但是还是有一点瑕疵,那就是其中一台服务器的压力可能会突然上升,而另外的服务器却很“悠闲(喝着咖啡,看着新闻)”。我们希望虽然是按照轮询,但是中间最好可以有交叉,所以出现了第三种轮询算法:平滑加权轮询。
3、平滑加权轮询
平滑加权是一个算法,很神奇的算法,我们有必要先对这个算法进行讲解。比如A服务器的权重是5,B服务器的权重是1,C服务器的权重是1。这个权重,我们称之为“固定权重”,既然这个叫“固定权重”,那么肯定还有叫“非固定权重”的,没错,“非固定权重”每次都会根据一定的规则变动。
- 第一次访问,ABC的“非固定权重”分别是 5 1 1(初始),因为5是其中最大的,5对应的就是A服务器,所以这次选到的服务器就是A,然后我们用当前被选中的服务器的权重-各个服务器的权重之和,也就是A服务器的权重-各个服务器的权重之和。也就是5-7=-2,没被选中的服务器的“非固定权重”不做变化,现在三台服务器的“非固定权重”分别是-2 1 1。 
- 第二次访问,把第一次访问最后得到的“非固定权重”+“固定权重”,现在三台服务器的“非固定权重”是3,2,2,因为3是其中最大的,3对应的就是A服务器,所以这次选到的服务器就是A,然后我们用当前被选中的服务器的权重-各个服务器的权重之和,也就是A服务器的权重-各个服务器的权重之和。也就是3-7=-4,没被选中的服务器的“非固定权重”不做变化,现在三台服务器的“非固定权重”分别是-4 1 1。 
- 第三次访问,把第二次访问最后得到的“非固定权重”+“固定权重”,现在三台服务器的“非固定权重”是1,3,3,这个时候3虽然是最大的,但是却出现了两个,我们选第一个,第一个3对应的就是B服务器,所以这次选到的服务器就是B,然后我们用当前被选中的服务器的权重-各个服务器的权重之和,也就是B服务器的权重-各个服务器的权重之和。也就是3-7=-4,没被选中的服务器的“非固定权重”不做变化,现在三台服务器的“非固定权重”分别是1 -4 3……以此类推,最终得到这样的表格: 
| 请求 | 获得服务器前的非固定权重 | 选中的服务器 | 获得服务器后的非固定权重 | 
|---|---|---|---|
| 1 | {5, 1, 1} | A | {-2, 1, 1} | 
| 2 | {3, 2, 2} | A | {-4, 2, 2} | 
| 3 | {1, 3, 3} | B | {1, -4, 3} | 
| 4 | {6, -3, 4} | A | {-1, -3, 4} | 
| 5 | {4, -2, 5} | C | {4, -2, -2} | 
| 6 | {9, -1, -1} | A | {2, -1, -1} | 
| 7 | {7, 0, 0} | A | {0, 0, 0} | 
| 8 | {5, 1, 1} | A | {-2, 1, 1} | 
当第8次的时候,“非固定权重“又回到了初始的5 1 1,是不是很神奇,也许算法还是比较绕的,但是代码却简单多了:
 
   
   
 
  
    
    
  public class Server {
  
    
    
  
  
    
    
      public Server(int weight, int currentWeight, String ip) {
  
    
    
          this.weight = weight;
  
    
    
          this.currentWeight = currentWeight;
  
    
    
          this.ip = ip;
  
    
    
      }
  
    
    
  
  
    
    
      private int weight;
  
    
    
  
  
    
    
      private int currentWeight;
  
    
    
  
  
    
    
      private String ip;
  
    
    
  
  
    
    
      public int getWeight() {
  
    
    
          return weight;
  
    
    
      }
  
    
    
  
  
    
    
      public void setWeight(int weight) {
  
    
    
          this.weight = weight;
  
    
    
      }
  
    
    
  
  
    
    
      public int getCurrentWeight() {
  
    
    
          return currentWeight;
  
    
    
      }
  
    
    
  
  
    
    
      public void setCurrentWeight(int currentWeight) {
  
    
    
          this.currentWeight = currentWeight;
  
    
    
      }
  
    
    
  
  
    
    
      public String getIp() {
  
    
    
          return ip;
  
    
    
      }
  
    
    
  
  
    
    
      public void setIp(String ip) {
  
    
    
          this.ip = ip;
  
    
    
      }
  
    
    
  }
 
   
   
  
 
   
   
 
  
    
    
  public class Servers {
  
    
    
      public HashMap<String, Server> serverMap = new HashMap<>() {
  
    
    
          {
  
    
    
              put("192.168.1.1", new Server(5,5,"192.168.1.1"));
  
    
    
              put("192.168.1.2", new Server(1,1,"192.168.1.2"));
  
    
    
              put("192.168.1.3", new Server(1,1,"192.168.1.3"));
  
    
    
          }
  
    
    
      };
  
    
    
  }
 
   
   
  
 
   
   
 
  
    
    
  public class SmoothWeightRound {
  
    
    
  
  
    
    
      private static Servers servers = new Servers();
  
    
    
  
  
    
    
      public static String go() {
  
    
    
          Server maxWeightServer = null;
  
    
    
  
  
    
    
          int allWeight = servers.serverMap.values().stream().mapToInt(Server::getWeight).sum();
  
    
    
  
  
    
    
          for (Map.Entry<String, Server> item : servers.serverMap.entrySet()) {
  
    
    
              var currentServer = item.getValue();
  
    
    
              if (maxWeightServer == null || currentServer.getCurrentWeight() > maxWeightServer.getCurrentWeight()) {
  
    
    
                  maxWeightServer = currentServer;
  
    
    
              }
  
    
    
          }
  
    
    
          assert maxWeightServer != null;
  
    
    
          maxWeightServer.setCurrentWeight(maxWeightServer.getCurrentWeight() - allWeight);
  
    
    
  
  
    
    
          for (Map.Entry<String, Server> item : servers.serverMap.entrySet()) {
  
    
    
              var currentServer = item.getValue();
  
    
    
              currentServer.setCurrentWeight(currentServer.getCurrentWeight() + currentServer.getWeight());
  
    
    
          }
  
    
    
          return maxWeightServer.getIp();
  
    
    
      }
  
    
    
  
  
    
    
      public static void main(String[] args) {
  
    
    
          for (var i = 0; i < 15; i++) {
  
    
    
              System.out.println(go());
  
    
    
          }
  
    
    
      }
  
    
    
  }
 
   
   
  
运行结果:
这就是平滑加权轮询,巧妙的利用了巧妙算法,既有轮询的效果,又避免了某台服务器压力突然升高,不可谓不妙。
哈希
负载均衡算法中的哈希算法,就是根据某个值生成一个哈希值,然后对应到某台服务器上去。当然可以根据用户,也可以根据请求参数,或者根据其他,想怎么来就怎么来。如果根据用户,就比较巧妙的解决了负载均衡下Session共享的问题,用户小明走的永远是A服务器,用户小笨永远走的是B服务器。
那么如何用代码实现呢?这里又需要引出一个新的概念:哈希环。
什么?我只听过奥运五环,这个哈希环又是什么鬼?容我慢慢道来。
哈希环,就是一个环!这……好像……有点难解释呀,我们还是画图来说明吧。
一个圆是由无数个点组成的,这是最简单的数学知识,相信大家都可以理解吧,哈希环也一样,哈希环也是有无数个“哈希点”构成的,当然并没有“哈希点”这样的说法,只是为了便于大家理解。
我们先计算出服务器的哈希值,比如根据IP,然后把这个哈希值放到环里,如上图所示。
来了一个请求,我们再根据某个值进行哈希,如果计算出来的哈希值落到了A和B的中间,那么按照顺时针算法,这个请求给B服务器。
理想很丰满,现实很孤单,可能三台服务器掌管的“区域”大小相差很大很大,或者干脆其中一台服务器坏了,会出现如下的情况:
可以看出,A掌管的“区域”实在是太大,B可以说是“很悠闲”,像这种情况,就叫“哈希倾斜”。
那么,怎么避免这种情况呢?——虚拟节点。
什么是虚拟节点呢,说白了,就是虚拟的节点……好像跟没解释一样啊……还是上一张丑到爆炸的图吧:
如图所示,正方形的是真实的节点,或者说真实的服务器,五边形的是虚拟节点,或者说是虚拟的服务器。当一个请求过来,落到了A1和B1之间,那么按照顺时针的规则,应该由B1服务器进行处理,但是B1服务器是虚拟的,它是从B服务器映射出来的,所以再交给B服务器进行处理。
要实现此种负载均衡算法,需要用到一个平时不怎么常用的Map:TreeMap,对TreeMap不了解的朋友可以先去了解下TreeMap,下面放出代码:
 
   
   
 
  
    
    
      private static String go(String client) {
  
    
    
          int nodeCount = 20;
  
    
    
          TreeMap<Integer, String> treeMap = new TreeMap();
  
    
    
          for (String s : new Servers().list) {
  
    
    
              for (int i = 0; i < nodeCount; i++)
  
    
    
                  treeMap.put((s + "--服务器---" + i).hashCode(), s);
  
    
    
          }
  
    
    
          int clientHash = client.hashCode();
  
    
    
          SortedMap<Integer, String> subMap = treeMap.tailMap(clientHash);
  
    
    
          Integer firstHash;
  
    
    
          if (subMap.size() > 0) {
  
    
    
              firstHash = subMap.firstKey();
  
    
    
          } else {
  
    
    
              firstHash = treeMap.firstKey();
  
    
    
          }
  
    
    
          String s = treeMap.get(firstHash);
  
    
    
          return s;
  
    
    
      }
  
    
    
  
  
    
    
      public static void main(String[] args) {
  
    
    
          System.out.println(go("今天天气不错啊"));
  
    
    
          System.out.println(go("192.168.5.258"));
  
    
    
          System.out.println(go("0"));
  
    
    
          System.out.println(go("-110000"));
  
    
    
          System.out.println(go("风雨交加"));
  
    
    
      }
 
   
   
  
运行结果:
哈希负载均衡算法到这里就结束了。
最小压力
最小压力负载均衡算法是指:选择一台当前最“悠闲”的服务器,如果A服务器有100个请求,B服务器有5个请求,而C服务器只有3个请求,那么毫无疑问会选择C服务器,这种负载均衡算法是比较科学的。但是遗憾的是,在当前的场景下无法模拟出“原汁原味”的最小压力负载均衡算法的。
当然在实际的负载均衡下,可能会将多个负载均衡算法合在一起实现,比如先根据最小压力算法,当有几台服务器的压力一样小的时候,再根据权重取出一台服务器,如果权重也一样,再随机取一台等等。
今天的内容到这里就结束了,谢谢大家。
来源:掘金
链接:https://juejin.cn/post/6844903793012768781
