负载均衡算法及实现

一、简介

负载均衡(Load Balance):指由多台服务器以对称的方式组成一个服务器集合,每台服务器都具有等价的地位,都可以单独对外提供服务而无须其他服务器的辅助。通过某种负载分担技术,将外部发送来的请求均匀分配到对称结构中的某一台服务器上,而接收到请求的服务器独立地回应客户的请求。负载均衡能够平均分配客户请求到服务器阵列,借此提供快速获取重要数据,解决大量并发访问服务问题,这种集群技术可以用最少的投资获得接近于大型主机的性能。

二、分类

从负载均衡设备的角度来看,分为硬件负载均衡软件负载均衡

  • 硬件负载均衡:比如最常见的F5,还有Array等,这些负载均衡是商业的负载均衡器,性能比较好,毕竟他们的就是为了负载均衡而生的,背后也有非常成熟的团队,可以提供各种解决方案,但是价格比较昂贵,所以没有充足的理由,充足的软妹币是不会考虑的。
  • 软件负载均衡:包括我们耳熟能详的NginxLVSTengine(阿里对Nginx进行的改造)等。优点就是成本比较低,但是也需要有比较专业的团队去维护。

从负载均衡的技术来看,分为服务端负载均衡客户端负载均衡

  • 服务端负载均衡:当我们访问一个服务,请求会先到另外一台服务器,然后这台服务器会把请求分发到提供这个服务的服务器,当然如果只有一台服务器,那好说,直接把请求给那一台服务器就可以了,但是如果有多台服务器呢?这时候,就会根据一定的算法选择一台服务器。
  • 客户端负载均衡:客户端服务均衡的概念貌似是有了服务治理才产生的,简单的来说,就是在一台服务器上维护着所有服务的ip,名称等信息,当我们在代码中访问一个服务,是通过一个组件访问的,这个组件会从那台服务器上取到所有提供这个服务的服务器的信息,然后通过一定的算法,选择一台服务器进行请求。

从负载均衡的算法来看,又分为随机,轮询,哈希,最小压力,当然可能还会加上权重的概念。

三、服务端负载均衡

服务器负载均衡就是我们平时说的负载均衡,是指在服务器上游做服务分发,常用的方式有一下几种:

  • DNS域名解析负载均衡:假设我们的域名指向了多个IP地址,当一个域名请求来时,DNS服务器机进行域名解析将域名转换为IP地址是,在1:N的映射转换中实现负载均衡。DNS服务器提供简单的负载均衡算法,但当其中某台服务器出现故障时,通知DNS服务器移除当前故障IP
  • 反向代理负载均衡:反向代理只值对服务器的代理,代理服务器接受请求,通过负载均衡算法,将请求转发给后端服务器,后端服务返回给代理服务器然后代理服务器返回到客户端。反向代理服务器的优点是隔离后端服务器和客户端,使用双网卡屏蔽真实服务器网络,安全性更好,相比较于DNS域名解决负载均衡,反向代理在故障处理方面更灵活,支持负载均衡算法的横向扩展。目前使用非常广泛。当然反向代理也需要考虑很多问题,比如单点故障,集群部署等。
  • IP负载均衡:我们都知道反向代理工作到HTTP层,本身开销相对大一些,对性能有一定影响,LVS-NAT是一种卫浴传输层的负载均衡,它通过修改接受的数据包目标地址的方式实现负载均衡。Linux2.6.x以后版本内置了IPVS,专注用于实现IP负载均衡,故而在LinuxIP负载均衡使用非常广泛。LVS-DR工作在数据链路层,比LVS-NAT更霸道的时候它直接修改数据包的MAC地址。LVS-TUN——基于IP隧道的请求转发机制,将调度器收到的IP数据包进行封装,转交给服务器,然后服务器返回数据,通过调度器实现负载均衡。这种方式支持跨网段调度。总结一下,LVS-DRLVS-TUN都适合响应和请求不对称的Web服务器,如何从它们中做出选择,取决于你的网络部署需要,因为LVS-TUN可具有跨地域性,有类似这种需求的,就应该选择LVS-TUN

3.1 Nginx实现负载均衡

负载均衡是Nginx常用的一个功能,是在服务端通过的负载均衡算法实现的,Nginx也要具有很多不同的负载均衡策略。负载均衡的意思是将请求分摊到不同的服务器上执行,例如:web服务器、企业内部服务器等等,这样可以提高系统的吞吐量和请求的响应速度。

常见的负载均衡算法有三策略

3.1.1 轮询(默认)

#每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器down掉,能自动剔除
upstream test {
    server localhost:8080;
    server localhost:8081;
}

server {
  listen       8081;                                                         
  server_name  localhost;                                               
  client_max_body_size 1024M;

  location / {
    proxy_pass http://test;
    proxy_set_header Host $host:$server_port;
  }
}

这里模拟了两台tomcat服务器,采用不同的端口80808081,当两台服务器之间有一个服务器处于不能访问的状态(服务器挂了),请求就不会打到该服务器,所有避免了一台服务器挂了而直接影响到整个系统业务不能正常使用的情况,由于Nginx默认是采用RR策略,所有不需要替他更多的配置。

3.1.2 权重

#指定轮询几率,weight和访问比率成正比,用于后端服务器性能不均的情况
upstream test {
    server localhost:8080 weight=8;
    server localhost:8081 weight=2;
}

3.1.3 IP_HASH

upstream test {
    ip_hash;
    server localhost:8080;
    server localhost:8081;
}

四、客户端负载均衡

相比较服务器负载均衡而言,客户端负载均衡是一个非常小众的概念,但是面试在问道负载均衡相关知识的时候却会刻意了解候选人的知识广度。客户端负载均衡是在spring-cloud分布式框架组件Ribbon中定义的。我们在使用spring-cloud分布式框架时,同一个service大概率同时启动多个,当一个请求奔过来时,那么这多个serviceRibbon通过策略决定本次请求使用哪个service的方式就是客户端负载均衡。在spring-cloud分布式框架中客户端负载均衡对开发者是透明的,添加@LoadBalanced注解就可以了。客户端负载均衡和服务器负载均衡的核心差异在服务列表本身,客户端负载均衡服务列表在通过客户端维护,服务器负载均衡服务列表由中间服务单独维护。

4.1 SpringCloud Ribbon

配置在RestTemplate添加@LoadBalanced负载均衡注解,这时候会为RestTemplate整合Ribbon

@Bean
@LoadBalanced
RestTemplate restTemplate(){
    return new RestTemplate();
}

@Bean
public IRule ribbonRule() {
    //这里配置策略,和配置文件对应
    return new RandomRule();	
}

使用Ribbon我们不需要关注负载均衡算法的实现,因为Ribbon已经涵盖了丰富的负载均衡策略,我们只需要给Ribbon注册一个实例列表,Ribbon会自动帮我们选择具体的某个实例,引进Ribbon代码演进如下:

//调用用户微服务的/users/{userId}?使用RestTemplate,RestTemplate会帮我们自动转换成UserDTO
UserDTO userDTO = this.restTemplate.getForObject(
        "http://user-center/users/1",
        UserDTO.class);

五、java实现负载均衡算法

初始化数据

private static Map<String, Integer> getInitServerMap(){
    HashMap<String, Integer> serverWeightMap = new HashMap<String, Integer>();
    // 第一个参数是IP地址,第二个是权重。
    serverWeightMap.put("192.168.1.100", 1);
    serverWeightMap.put("192.168.1.101", 2);
    serverWeightMap.put("192.168.1.102", 3);
    serverWeightMap.put("192.168.1.103", 4);
    serverWeightMap.put("192.168.1.104", 3);
    serverWeightMap.put("192.168.1.105", 2);
    serverWeightMap.put("192.168.1.106", 1);
    serverWeightMap.put("192.168.1.107", 2);
    serverWeightMap.put("192.168.1.108", 1);
    serverWeightMap.put("192.168.1.109", 4);
    return serverWeightMap;	
}

5.1 轮询算法(Round Robin)

轮训法其实就是按照所有地址的序列,从前往后一次访问,实现代码如下:

public class RoundRobin {

    private static Integer pos = 0;

    public static String getServerIP() {
        // 重新在线程本地copy一份IPMap,避免服务器上线下线导致的并发问题
        Map<String, Integer> serverMap = getInitServerMap();

        // 取的IP地址的Set转List
        List<String> ipList = new ArrayList<>(serverMap.keySet());
        String server;
        synchronized (pos) {
            if (pos > ipList.size())
                pos = 0;
            server = ipList.get(pos);
            pos++;
        }
        return server;
    }

    /**
     * @param args
     */
    public static void main(String[] args) {
        String result;
        for (int i = 0; i < 10; i++) {
            result = getServerIP();
            System.out.println("load balance 的地址是:" + result);
        }
    }
}

由于serverWeightMap中的地址列表是动态的,随时可能有机器上线、下线或者宕机,因此为了避免可能出现的并发问题,方法内部要新建局部变量serverMap,现将serverMap中的内容复制到线程本地,以避免被多个线程修改。这样可能会引入新的问题,复制以后serverWeightMap的修改无法反映给serverMap,也就是说这一轮选择服务器的过程中,新增服务器或者下线服务器,负载均衡算法将无法获知。新增无所谓,如果有服务器下线或者宕机,那么可能会访问到不存在的地址。因此,服务调用端需要有相应的容错处理,比如重新发起一次server选择并调用。

对于当前轮询的位置变量pos,为了保证服务器选择的顺序性,需要在操作时对其加锁,使得同一时刻只能有一个线程可以修改pos的值,否则当pos变量被并发修改,则无法保证服务器选择的顺序性,甚至有可能导致ipList数组越界。

轮询法的优点在于:试图做到请求转移的绝对均衡。
轮询法的缺点在于:为了做到请求转移的绝对均衡,必须付出相当大的代价,因为为了保证pos变量修改的互斥性,需要引入重量级的悲观锁synchronized,这将会导致该段轮询代码的并发吞吐量发生明显的下降。

5.2 加权轮询算法(Weight Round Robin)

不同的服务器可能机器配置和当前系统的负载并不相同,因此它们的抗压能力也不尽相同,给配置高、负载低的机器配置更高的权重,让其处理更多的请求,而低配置、高负载的机器,则给其分配较低的权重,降低其系统负载。加权轮询法可以很好地处理这一问题,并将请求顺序按照权重分配到后端。加权轮询法的代码实现大致如下:

public class WeightRoundRobin {

    private static Integer pos = 0;

    public static String getServerIP() {
        // 重新在线程本地copy一份IPMap,避免服务器上线下线导致的并发问题
        Map<String, Integer> ipServerMap = getInitServerMap();
        Set<String> ipSet = ipServerMap.keySet();
        Iterator<String> ipIterator = ipSet.iterator();
        // 定义一个list放所有server
        ArrayList<String> ipArrayList = new ArrayList<String>();
        // 循环set,根据set中的可以去得知map中的value,给list中添加对应数字的server数量
        while (ipIterator.hasNext()) {
            String serverName = ipIterator.next();
            Integer weight = ipServerMap.get(serverName);
            for (int i = 0; i < weight; i++) {
                ipArrayList.add(serverName);
            }
        }
        String serverName;
        if (pos >= ipArrayList.size()) {
            pos = 0;
        }
        serverName = ipArrayList.get(pos);
        // 轮询+1
        pos++;
        return serverName;
    }

    public static void main(String[] args) {
        String result;
        for (int i = 0; i < 10; i++) {
            result = getServerIP();
            System.out.println("load balance 的地址是:" + result);
        }
    }
}

与轮询法类似,只是在获取服务器地址之前增加了一段权重计算的代码,根据权重的大小,将地址重复地增加到服务器地址列表中,权重越大,该服务器每轮所获得的请求数量越多。

5.3 随机算法(Random)

通过系统随机函数,根据后端服务器列表的大小值来随机选择其中一台进行访问。由概率统计理论可以得知,随着调用量的增大,其实际效果越来越接近于平均分配流量到每一台后端服务器,也就是轮询的效果。

public class RandomBalance {

    public static String getServerIP() {
        // 重新在线程本地copy一份IPMap,避免服务器上线下线导致的并发问题
        Map<String, Integer> serverMap = getInitServerMap();
        // 取的IP地址的Set
        List<String> ipList = new ArrayList<>(serverMap.keySet());
        // 获取IP的策略
        int pos = new Random().nextInt(ipList.size());
        return ipList.get(pos);
    }

    /**
     * @param args
     */
    public static void main(String[] args) {
        String result;
        for(int i = 0; i < 10; i++){
            result = getServerIP();
            System.out.println("load balance 的地址是:" + result);
        }
    }
}

整体代码思路和轮询法一致,先重建serverMap,再获取到server列表。在选取server的时候,通过RandomnextInt方法取0~ipList.size()区间的一个随机值,从而从服务器列表中随机获取到一台服务器地址进行返回。基于概率统计的理论,吞吐量越大,随机算法的效果越接近于轮询算法的效果。

5.4 加权随机算法

与加权轮询法类似,加权随机法也是根据后端服务器不同的配置和负载情况来配置不同的权重。不同的是,它是按照权重来随机选择服务器的,而不是顺序。加权随机法的代码实现如下:

public class WeightRandomBalance {

    public static String getServerIP() {
        // 重新在线程本地copy一份IPMap,避免服务器上线下线导致的并发问题
        Map<String, Integer> serverMap = getInitServerMap();
        // 取的IP地址的Set
        Set<String> ips = serverMap.keySet();
        Iterator<String> iterator = ips.iterator();
        List<String> ipList = new ArrayList<>();
        // 循环set,根据set中的可以去得知map中的value,给list中添加对应数字的server数量
        while (iterator.hasNext()) {
            String server = iterator.next();
            int weight = serverMap.get(server);
            // 按照权重来添加比例
            for (int i = 0; i < weight; i++) {
                ipList.add(server);
            }
        }
        // 获取IP的策略
        Random random = new Random();
        int pos = random.nextInt(ipList.size());
        return ipList.get(pos);
    }
	
	/**
     * @param args
     */
    public static void main(String[] args) {
        String result;
        for (int i = 0; i < 10; i++) {
            result = getServerIP();
            System.out.println("load balance 的地址是:" + result);
        }
    }
}

5.5 hash算法

源地址哈希的思想是获取客户端访问的IP地址值,通过哈希函数计算得到一个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是要访问的服务器的序号。源地址哈希算法的代码实现大致如下:

public class IPHash {

    public static String getServerIP(String clientIP) {
        // 重新在线程本地copy一份IPMap,避免服务器上线下线导致的并发问题
        Map<String, Integer> serverMap = getInitServerMap();
        // 取的IP地址的Set转List
        List<String> ipList = new ArrayList<>(serverMap.keySet());
        // 对ip的hashcode值取余数,每次都一样的
        int hashCode = clientIP.hashCode();
        hashCode = Math.abs(hashCode);//确保hash值是正数. 如果hash值是负数
        int serverListSize = ipList.size();
        int pos = hashCode % serverListSize;
        return ipList.get(pos);
    }

    /**
     * @param args
     */
    public static void main(String[] args) {
        String result;
        for (int i = 0; i < 10; i++) {
            result = getServerIP("192.168.21.2");
            System.out.println("load balance 的地址是:" + result);
        }
    }
}

这个方法需要注意的一点是,当获取到远端iphash值之后,一定要取绝对值,因为如果是负数最后定位的索引就会是负数,导致数组越界。其实不同负载均衡算法的重点就在于路由算法,通过客户端的ip也就是remoteIp,取得它的Hash值,对服务器列表的大小取模,结果便是选用的服务器在服务器列表中的索引值。

源地址哈希法的优点在于:保证了相同客户端IP地址将会被哈希到同一台后端服务器,直到后端服务器列表变更。根据此特性可以在服务消费者与服务提供者之间建立有状态的session会话。
源地址哈希算法的缺点在于:除非集群中服务器的非常稳定,基本不会上下线,否则一旦有服务器上线、下线,那么通过源地址哈希算法路由到的服务器是服务器上线、下线前路由到的服务器的概率非常低,如果是session则取不到session,如果是缓存则可能引发”雪崩”。(缓存雪崩指:某些原因导致缓存全部失效,意味着所有的数据请求都会到达数据库,导致数据库请求暴增,导致数据库挂掉。)

5.6 最少链接算法

前面几种方法费尽心思来实现服务消费者请求次数分配的均衡,当然这么做是没错的,可以为后端的多台服务器平均分配工作量,最大程度地提高服务器的利用率,但是实际情况是否真的如此?实际情况中,请求次数的均衡真的能代表负载的均衡吗?这是一个值得思考的问题。

上面的问题,再换一个角度来说就是:以后端服务器的视角来观察系统的负载,而非请求发起方来观察。最小连接数法便属于此类。

最小连接数算法比较灵活和智能,由于后端服务器的配置不尽相同,对于请求的处理有快有慢,它正是根据后端服务器当前的连接情况,动态地选取其中当前积压连接数最少的一台服务器来处理当前请求,尽可能地提高后端服务器的利用效率,将负载合理地分流到每一台机器。由于最小连接数设计服务器连接数的汇总和感知,设计与实现较为繁琐,此处就不说它的实现了。

import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;
 
public class LeastBalance {
 
    public static String getServerIP() {
        Map<String, ConnectionsServer> serverMap = new TreeMap<>(ConnectionsServerManager.serverMap);
        Iterator<String> iterator = serverMap.keySet().iterator();
 
        ConnectionsServer minConnectionsServer = null;
        while (iterator.hasNext()){
            ConnectionsServer server = serverMap.get(iterator.next());
            if(minConnectionsServer == null){
                minConnectionsServer = server;
            }
 
            if(minConnectionsServer.getConnnections() > server.getConnnections()){
                minConnectionsServer = server;
            }
        }
 
        minConnectionsServer.setConnnections(minConnectionsServer.getConnnections() + 1);
        ConnectionsServerManager.serverMap.put(minConnectionsServer.getServer(), minConnectionsServer);
        System.out.println(String.format("ip=%s, connections=%s",
                minConnectionsServer.getServer(),
                minConnectionsServer.getConnnections()));
        return minConnectionsServer.getServer();
    }
 
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            String server = getServerIP();
        }
    }
}

class ConnectionsServerManager {
    public volatile static Map<String, ConnectionsServer> serverMap = new TreeMap<>();

    static {
        serverMap.put("192.168.1.1", new ConnectionsServer("192.168.1.1", 1));
        serverMap.put("192.168.1.2", new ConnectionsServer("192.168.1.2", 2));
        serverMap.put("192.168.1.3", new ConnectionsServer("192.168.1.3", 3));
        serverMap.put("192.168.1.4", new ConnectionsServer("192.168.1.4", 4));
    }
}

@Data
class ConnectionsServer implements Serializable {
    private String server;
    private Integer connnections; 
}

六、总结

  • 随机,通过随机选择服务进行执行,一般这种方式使用较少;
  • 轮训,负载均衡默认实现方式,请求来之后排队处理;
  • 加权轮训,通过对服务器性能的分型,给高配置,低负载的服务器分配更高的权重,均衡各个服务器的压力;
  • 地址Hash,通过客户端请求的地址的HASH值取模映射进行服务器调度。
  • 最小链接数,即使请求均衡了,压力不一定会均衡,最小连接数法就是根据服务器的情况,比如请求积压数等参数,将请求分配到当前压力最小的服务器上。
  • 其他若干方式。

参考文章

posted @ 2022-04-25 16:31  夏尔_717  阅读(712)  评论(0编辑  收藏  举报