Java负载均衡算法实现与原理分析(轮询、随机、哈希、加权、最小连接)

一、负载均衡算法概述

负载均衡,英文名称为Load Balance,其含义就是指将负载(工作任务)进行平衡、分摊到多个操作单元上进行运行,例如FTP服务器、Web服务器、企业核心应用服务器和其它主要任务服务器等,从而协同完成工作任务。既然涉及到多个机器,就涉及到任务如何分发,这就是负载均衡算法问题。

二、轮询(RoundRobin)算法

1、概述

轮询即排好队,一个接一个。

2、Java实现轮询算法

手动写一个双向链表形式实现服务器列表的请求轮询算法。

public class RR {
    
    

    //当前服务节点
    Server current;

    //初始化轮询类,多个服务器ip用逗号隔开
    public RR(String serverName){
    
    
        System.out.println("init server list : "+serverName);
        String[] names = serverName.split(",");
        for (int i = 0; i < names.length; i++) {
    
    
            Server server = new Server(names[i]);
            if (current == null){
    
    
                //如果当前服务器为空,说明是第一台机器,current就指向新创建的server
                this.current = server;
                //同时,server的前后均指向自己。
                current.prev = current;
                current.next = current;
            }else {
    
    
                //否则说明已经有机器了,按新加处理。
                addServer(names[i]);
            }
        }

    }
    //添加机器
    void addServer(String serverName){
    
    
        System.out.println("add server : "+serverName);
        Server server = new Server(serverName);
        Server next = this.current.next;
        //在当前节点后插入新节点
        this.current.next = server;
        server.prev = this.current;

        //修改下一节点的prev指针
        server.next = next;
        next.prev=server;
    }
    //将当前服务器移除,同时修改前后节点的指针,让其直接关联
    //移除的current会被回收期回收掉
    void remove(){
    
    
        System.out.println("remove current = "+current.name);
        this.current.prev.next = this.current.next;
        this.current.next.prev = this.current.prev;
        this.current = current.next;
    }
    //请求。由当前节点处理即可
    //注意:处理完成后,current指针后移
    void request(){
    
    
        System.out.println(this.current.name);
        this.current = current.next;
    }

    public static void main(String[] args) throws InterruptedException {
    
    
        //初始化两台机器
        RR rr = new RR("192.168.0.1,192.168.0.2");
        //启动一个额外线程,模拟不停的请求
        new Thread(new Runnable() {
    
    
            @Override
            public void run() {
    
    
                while (true) {
    
    
                    try {
    
    
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
    
    
                        e.printStackTrace();
                    }
                    rr.request();
                }
            }
        }).start();

        //3s后,3号机器加入清单
        Thread.currentThread().sleep(3000);
        rr.addServer("192.168.0.3");

        //3s后,当前服务节点被移除
        Thread.currentThread().sleep(3000);
        rr.remove();

    }

    class Server{
    
    
        Server prev; // 前驱
        Server next; // 后继
        String name; // 名称
        public Server(String name){
    
    
            this.name = name;
        }
    }

}

初始化后,只有1,2,两者轮询
3加入后,1,2,3,三者轮询
移除2后,只剩1和3轮询

3、优缺点

实现简单,机器列表可以自由加减,且时间复杂度为o(1)。
无法针对节点做偏向性定制,节点处理能力的强弱无法区分对待。

三、随机(Random)算法

1、概述

从可服务的列表中随机取一个提供响应。

2、Java实现随机算法

随机存取的场景下,适合使用数组更高效的实现下标随机读取。
定义一个数组,在数组长度内取随机数,作为其下标即可。非常简单。

public class Rand {
    
    
    // 所有服务ip
    ArrayList<String> ips ;
    //初始化随机类,多个服务器ip用逗号隔开
    public Rand(String nodeNames){
    
    
        System.out.println("init list : "+nodeNames);
        String[] nodes = nodeNames.split(",");
        //初始化服务器列表,长度取机器数
        ips = new ArrayList<>(nodes.length);
        for (String node : nodes) {
    
    
            ips.add(node);
        }
    }
    //请求
    void request(){
    
    
        //下标,随机数,注意因子
        int i = new Random().nextInt(ips.size());
        System.out.println(ips.get(i));
    }
    //添加节点,注意,添加节点会造成内部数组扩容
    //可以根据实际情况初始化时预留一定空间
    void addnode(String nodeName){
    
    
        System.out.println("add node : "+nodeName);
        ips.add(nodeName);
    }
    //移除
    void remove(String nodeName){
    
    
        System.out.println("remove node : "+nodeName);
        ips.remove(nodeName);
    }


    public static void main(String[] args) throws InterruptedException {
    
    
        Rand rd = new Rand("192.168.0.1,192.168.0.2");

        //启动一个额外线程,模拟不停的请求
        new Thread(new Runnable() {
    
    
            @Override
            public void run() {
    
    
                while (true) {
    
    
                    try {
    
    
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
    
    
                        e.printStackTrace();
                    }
                    rd.request();
                }
            }
        }).start();

        //3s后,3号机器加入清单
        Thread.currentThread().sleep(3000);
        rd.addnode("192.168.0.3");

        //3s后,当前服务节点被移除
        Thread.currentThread().sleep(3000);
        rd.remove("192.168.0.2");
    }
}

初始化为1,2,两者不按顺序轮询,而是随机出现。
3加入服务节点列表。
移除2后,只剩1,3,依然是两者随机,无序。

四、源地址哈希(Hash)算法

1、概述

对当前访问的ip地址做一个hash值,相同的key被路由到同一台机器去。场景常见于分布式集群环境下,用户登录时的请求路由和会话保持。

2、Java实现地址哈希算法

使用HashMap可以实现请求值到对应节点的服务,其查找时的时间复杂度为o(1)。固定一种算法,将请求映射到key上即可。

举例,将请求的来源ip末尾,按机器数取余作为key:

public class Hash {
    
    
    // 所有服务ip
    ArrayList<String> ips ;
    //初始化hash类,多个服务器ip用逗号隔开
    public Hash(String nodeNames){
    
    
        System.out.println("init list : "+nodeNames);
        String[] nodes = nodeNames.split(",");
        //初始化服务器列表,长度取机器数
        ips = new ArrayList<>(nodes.length);
        for (String node : nodes) {
    
    
            ips.add(node);
        }
    }
    //添加节点,注意,添加节点会造成内部Hash重排,思考为什么呢???
    //这是个问题!在一致性hash中会进入详细探讨
    void addnode(String nodeName){
    
    
        System.out.println("add node : "+nodeName);
        ips.add(nodeName);
    }
    //移除
    void remove(String nodeName){
    
    
        System.out.println("remove node : "+nodeName);
        ips.remove(nodeName);
    }
    //映射到key的算法,这里取余数做下标
    private int hash(String ip){
    
    
        int last = Integer.valueOf(ip.substring(ip.lastIndexOf(".")+1,ip.length()));
        return last % ips.size();
    }
    //请求
    //注意,这里和来访ip是有关系的,采用一个参数,表示当前的来访ip
    void request(String ip){
    
    
        //下标
        int i = hash(ip);
        System.out.println(ip+"-->"+ips.get(i));
    }

    public static void main(String[] args) {
    
    
        Hash hash = new Hash("192.168.0.1,192.168.0.2");
        for (int i = 1; i < 10; i++) {
    
    
            //模拟请求的来源ip
            String ip = "192.168.0."+ i;
            hash.request(ip);
        }

        hash.addnode("192.168.0.3");
        for (int i = 1; i < 10; i++) {
    
    
            //模拟请求的来源ip
            String ip = "192.168.0."+ i;
            hash.request(ip);
        }

        hash.remove("192.168.0.2");
        for (int i = 1; i < 10; i++) {
    
    
            //模拟请求的来源ip
            String ip = "192.168.0."+ i;
            hash.request(ip);
        }
    }

}

初始化后,只有1,2,下标为末尾ip取余数,多次运行,响应的机器不变,实现了会话保持。
3加入后,重新hash,机器分布发生变化。
2被移除后,原来hash到2的请求被重新定位给3响应。

3、一致性哈希

源地址hash算法,让某些请求固定的落在对应的服务器上。这样可以解决会话信息保留的问题。

同时,标准的hash,如果机器节点数发生变更。那么请求会被重新hash,打破了原始的设计初衷,怎么解决呢?答案就是一致性hash。

(1)原理

以4台机器为例,一致性hash的算法如下:
首先求出各个服务器的哈希值,并将其配置到0~232的圆上;
然后采用同样的方法求出存储数据的键的哈希值,也映射圆上;
从数据映射到的位置开始顺时针查找,将数据保存到找到的第一个服务器上;
如果到最大值仍然找不到,就取第一个。这就是为啥形象的称之为环。
在这里插入图片描述
添加节点:
在这里插入图片描述
删除节点原理雷同

(2)特性

单调性(Monotonicity):单调性是指如果已经有一些请求通过哈希分派到了相应的服务器进行处理,又有新的服务器加入到系统中时候,应保证原有的请求可以被映射到原有的或者新的服务器中去,而不会被映射到原来的其它服务器上去。

分散性(Spread):分布式环境中,客户端请求时可能只知道其中一部分服务器,那么两个客户端看到不同的部分,并且认为自己看到的都是完整的hash环,那么问题来了,相同的key可能被路由到不同服务器上去。以上图为例,加入client1看到的是1,4;client2看到的是2,3;那么2-4之间的key会被俩客户端重复映射到3,4上去。分散性反应的是这种问题的严重程度。

平衡性(Balance):平衡性是指客户端hash后的请求应该能够分散到不同的服务器上去。一致性hash可以做到尽量分散,但是不能保证每个服务器处理的请求的数量完全相同。这种偏差称为hash倾斜。如果节点的分布算法设计不合理,那么平衡性就会收到很大的影响。

(3)优化

增加虚拟节点可以优化hash算法,使得切段和分布更细化。即实际有m台机器,但是扩充n倍,在环上放置m*n个,那么均分后,key的段会分布更细化。
在这里插入图片描述

(4)Java实现一致性哈希算法

import java.util.Random;
import java.util.SortedMap;
import java.util.TreeMap;
 
/**
 * 不带虚拟节点的一致性Hash算法
 */
public class ConsistentHashingWithoutVirtualNode {
    
    
 
	//服务器列表
	private static String[] servers = {
    
     "192.168.0.0", "192.168.0.1",
			"192.168.0.2", "192.168.0.3", "192.168.0.4" };
 
	//key表示服务器的hash值,value表示服务器
	private static SortedMap<Integer, String> serverMap = new TreeMap<Integer, String>();
 
	static {
    
    
		for (int i=0; i<servers.length; i++) {
    
    
			int hash = getHash(servers[i]);
			//理论上,hash环的最大值为2^32
			//这里为做实例,将ip末尾作为上限也就是254
			//那么服务器是0-4,乘以60后可以均匀分布到 0-254 的环上去
			//实际的请求ip到来时,在环上查找即可
			hash *= 60;
			System.out.println("add " + servers[i] + ", hash=" + hash);
			serverMap.put(hash, servers[i]);
		}
	}
 
	//查找节点
	private static String getServer(String key) {
    
    
		int hash = getHash(key);
		//得到大于该Hash值的所有server
		SortedMap<Integer, String> subMap = serverMap.tailMap(hash);
		if(subMap.isEmpty()){
    
    
			//如果没有比该key的hash值大的,则从第一个node开始
			Integer i = serverMap.firstKey();
			//返回对应的服务器
			return serverMap.get(i);
		}else{
    
    
			//第一个Key就是顺时针过去离node最近的那个结点
			Integer i = subMap.firstKey();
			//返回对应的服务器
			return subMap.get(i);
		}
	}
	
	//运算hash值
	//该函数可以自由定义,只要做到取值离散即可
	//这里取ip地址的最后一节
	private static int getHash(String str) {
    
    
		String last = str.substring(str.lastIndexOf(".")+1,str.length());
		return Integer.valueOf(last);
	}
 
	public static void main(String[] args) {
    
    
		//模拟5个随机ip请求
		for (int i = 0; i < 5; i++) {
    
    
			String ip = "192.168.0."+ new Random().nextInt(254);
			System.out.println(ip +" ---> "+getServer(ip));
		}
		//将5号服务器加到2-3之间,取中间位置,150
		System.out.println("add 192.168.0.5,hash=150");
		serverMap.put(150,"192.168.0.5");
		//再次发起5个请求
		for (int i = 0; i < 5; i++) {
    
    
			String ip = "192.168.0."+ new Random().nextInt(254);
			System.out.println(ip +" ---> "+getServer(ip));
		}
	}
}

4台机器加入hash环
模拟请求,根据hash值,准确调度到下游节点
添加节点5,key取150
再次发起请求

五、加权轮询(WRR)算法

1、概述

WeightRoundRobin,轮询只是机械的旋转,加权轮询弥补了所有机器一视同仁的缺点。在轮询的基础上,初始化时,机器携带一个比重

2、Java实现加权轮询算法

维护一个链表,每个机器根据权重不同,占据的个数不同。轮询时权重大的,个数多,自然取到的次数变大。举个例子:a,b,c 三台机器,权重分别为4,2,1,排位后会是a,a,a,a,b,b,c,每次请求时,从列表中依次取节点,下次请求再取下一个。到末尾时,再从头开始。

但是这样有一个问题:机器分布不够均匀,扎堆出现了…

解决:为解决机器平滑出现的问题,nginx的源码中使用了一种平滑的加权轮询的算法,规则如下:
每个节点两个权重,weight和currentWeight,weight永远不变是配置时的值,current不停变化;
变化规律如下:选择前所有current += weight,选current最大的响应,响应后让它的current -= total。
在这里插入图片描述
统计:a=4,b=2,c=1 且分布平滑均衡

public class WRR {
    
    
    //所有节点的列表
    ArrayList<Node> list ;
    //总权重
    int total;

    //初始化节点列表
    public WRR(String nodes){
    
    
        String[] ns = nodes.split(",");
        list = new ArrayList<>(ns.length);
        for (String n : ns) {
    
    
            String[] n1 = n.split("#");
            int weight = Integer.valueOf(n1[1]);
            list.add(new Node(n1[0],weight));
            total += weight;
        }
    }

    //获取当前节点
    Node getCurrent(){
    
    
        //执行前,current加权重
        for (Node node : list) {
    
    
            node.currentWeight += node.weight;
        }

        //遍历,取权重最高的返回
        Node current = list.get(0);
        int i = 0;
        for (Node node : list) {
    
    
            if (node.currentWeight > i){
    
    
                i = node.currentWeight;
                current = node;
            }
        }
        return current;
    }

    //响应
    void request(){
    
    
        //获取当前节点
        Node node = this.getCurrent();
        //第一列,执行前的current
        System.out.print(list.toString()+"---");
        //第二列,选中的节点开始响应
        System.out.print(node.name+"---");
        //响应后,current减掉total
        node.currentWeight -= total;
        //第三列,执行后的current
        System.out.println(list);
    }

    public static void main(String[] args) {
    
    
        WRR wrr = new WRR("a#4,b#2,c#1");
        //7次执行请求,看结果
        for (int i = 0; i < 7; i++) {
    
    
            wrr.request();
        }
    }

    class Node{
    
    
        int weight,currentWeight; // 权重和current
        String name;
        public Node(String name,int weight){
    
    
            this.name = name;
            this.weight = weight;
            this.currentWeight = 0;
        }

        @Override
        public String toString() {
    
    
            return String.valueOf(currentWeight);
        }
    }
}

六、加权随机(WR)算法

1、概述

WeightRandom,机器随机被筛选,但是做一组加权值,根据权值不同,选中的概率不同。在这个概念上,可以认为随机是一种等权值的特殊情况。

2、Java实现加权随机算法

设计思路依然相同,根据权值大小,生成不同数量的节点,节点排队后,随机获取。这里的数据结构主要涉及到随机的读取,所以优选为数组。

与随机相同的是,同样为数组随机筛选,不同在于,随机只是每台机器1个,加权后变为多个。

public class WR {
    
    
    //所有节点的列表
    ArrayList<String> list ;
    //初始化节点列表
    public WR(String nodes){
    
    
        String[] ns = nodes.split(",");
        list = new ArrayList<>();
        for (String n : ns) {
    
    
            String[] n1 = n.split("#");
            int weight = Integer.valueOf(n1[1]);
            for (int i = 0; i < weight; i++) {
    
    
                list.add(n1[0]);
            }
        }
    }

    void request(){
    
    
        //下标,随机数,注意因子
        int i = new Random().nextInt(list.size());
        System.out.println(list.get(i));
    }

    public static void main(String[] args) {
    
    
        WR wr = new WR("a#2,b#1");
        for (int i = 0; i < 9; i++) {
    
    
            wr.request();
        }
    }

}

运行9次,a,b交替出现,a=6,b=3,满足2:1比例
注意!既然是随机,就存在随机性,不见得每次执行都会严格比例。样本趋向无穷时,比例约准确

七、最小连接数(LC)算法

1、概述

LeastConnections,即统计当前机器的连接数,选最少的去响应新的请求。前面的算法是站在请求维度,而最小连接数是站在机器的维度。

2、Java实现最小连接数算法

定义一个链接表记录机器的节点id和机器连接数量的计数器。内部采用最小堆做排序处理,响应时取堆顶节点即是最小连接数。

public class LC {
    
    
    //节点列表
    Node[] nodes;

    //初始化节点,创建堆
    // 因为开始时各节点连接数都为0,所以直接填充数组即可
    LC(String ns){
    
    
        String[] ns1 = ns.split(",");
        nodes = new Node[ns1.length+1];
        for (int i = 0; i < ns1.length; i++) {
    
    
            nodes[i+1] = new Node(ns1[i]);
        }
    }

    //节点下沉,与左右子节点比对,选里面最小的交换
    //目的是始终保持最小堆的顶点元素值最小
    //i:要下沉的顶点序号
    void down(int i) {
    
    
        //顶点序号遍历,只要到1半即可,时间复杂度为O(log2n)
        while ( i << 1  <  nodes.length){
    
    
            //左子,为何左移1位?回顾一下二叉树序号
            int left = i<<1;
            //右子,左+1即可
            int right = left+1;
            //标记,指向 本节点,左、右子节点里最小的,一开始取i自己
            int flag = i;
            //判断左子是否小于本节点
            if (nodes[left].get() < nodes[i].get()){
    
    
                flag = left;
            }
            //判断右子
            if (right < nodes.length && nodes[flag].get() > nodes[right].get()){
    
    
                flag = right;
            }
            //两者中最小的与本节点不相等,则交换
            if (flag != i){
    
    
                Node temp = nodes[i];
                nodes[i] = nodes[flag];
                nodes[flag] = temp;
                i = flag;
            }else {
    
    
                //否则相等,堆排序完成,退出循环即可
                break;
            }

        }

    }

    //请求。非常简单,直接取最小堆的堆顶元素就是连接数最少的机器
    void request(){
    
    
        System.out.println("---------------------");
        //取堆顶元素响应请求
        Node node = nodes[1];
        System.out.println(node.name + " accept");
        //连接数加1
        node.inc();
        //排序前的堆
        System.out.println("before:"+Arrays.toString(nodes));
        //堆顶下沉
        down(1);
        //排序后的堆
        System.out.println("after:"+Arrays.toString(nodes));
    }

    public static void main(String[] args) {
    
    
        //假设有7台机器
        LC lc = new LC("a,b,c,d,e,f,g");
        //模拟10个请求连接
        for (int i = 0; i < 10; i++) {
    
    
            lc.request();
        }
    }

    class Node{
    
    
        //节点标识
        String name;
        //计数器
        AtomicInteger count = new AtomicInteger(0);
        public Node(String name){
    
    
            this.name = name;
        }
        //计数器增加
        public void inc(){
    
    
            count.getAndIncrement();
        }
        //获取连接数
        public int get(){
    
    
            return count.get();
        }

        @Override
        public String toString() {
    
    
            return name+"="+count;
        }
    }
}

初始化后,堆节点值都为0,即每个机器连接数都为0
堆顶连接后,下沉,堆重新排序,最小堆规则保持成立

八、应用案例

1、nginx upstream

upstream frontend {
    
    
	#源地址hash
	ip_hash;
	server 192.168.0.1:8081;
	server 192.168.0.2:8082 weight=1 down;
	server 192.168.0.3:8083 weight=2;
	server 192.168.0.4:8084 weight=3 backup;
	server 192.168.0.5:8085 weight=4 max_fails=3 fail_timeout=30s;
}

ip_hash:即源地址hash算法
down:表示当前的server暂时不参与负载
weight:即加权算法,默认为1,weight越大,负载的权重就越大。
backup:备份机器,只有其它所有的非backup机器down或者忙的时候,再请求backup机器。
max_fails:最大失败次数,默认值为1,这里为3,也就是最多进行3次尝试
fail_timeout:超时时间为30秒,默认值是10s。
注意!weight和backup不能和ip_hash关键字一起使用。

2、springcloud ribbon IRule

#设置负载均衡策略 eureka‐application‐service为调用的服务的名称
eureka‐application‐service.ribbon.NFLoadBalancerRuleClassName=com.netflix.loadbalancer.RandomRule

RoundRobinRule:轮询
RandomRule:随机
AvailabilityFilteringRule:先过滤掉由于多次访问故障而处于断路器跳闸状态的服务,还有并发的连接数量超过阈值的服务,然后对剩余的服务轮询
WeightedResponseTimeRule:根据平均响应时间计算所有服务的权重,响应时间越快服务权重越大。刚启动时如果统计信息不足,则使用RoundRobinRule策略,等统计信息足够,会切换到该策略
RetryRule:先按照RoundRobinRule的策略,如果获取服务失败则在指定时间内重试,获取可用的服务
BestAvailableRule:会先过滤掉由于多次访问故障而处于断路器跳闸状态的服务,然后选择一个并发量最小的服务
ZoneAvoidanceRule:默认规则,综合判断server所在区域的性能和server的可用性

3、dubbo负载均衡

使用Service注解

@Service(loadbalance = "roundrobin",weight = 100)

RandomLoadBalance: 随机,这种方式是dubbo默认的负载均衡策略
RoundRobinLoadBalance:轮询
LeastActiveLoadBalance:最少活跃次数,dubbo框架自定义了一个Filter,用于计算服务被调用的次数
ConsistentHashLoadBalance:一致性hash

猜你喜欢

转载自blog.csdn.net/A_art_xiang/article/details/132054477
今日推荐