最小活跃数算法
上述分析的基本算法、平滑轮询加权、一致性哈希等算法都属于静态算法,也就是说这些算法配置后,并不会根据线上的实际运行情况进行调整,只会根据已配置的规则进行请求分发。
最小活跃数算法则会根据线上的实际情况进行分发,能够灵活的检测出集群中各个节点的状态,能够自动寻找并调用活跃度最低的节点处理请求。
Java 实现如下:
// 节点类:用于封装集群中的每个节点 public class Server { private String IP; private AtomicInteger active; // private Integer weight; public Server(){} public Server(String IP,int active) { this.IP = IP; // 将外部传递的活跃数作为默认活跃数 this.active = new AtomicInteger(active); } public String getIP() { // 每分发一个请求时自增一次活跃数 active.incrementAndGet(); return IP; } public AtomicInteger getActive() { return active; } } // 集群类:用于模拟集群节点列表 public class Servers { // 活跃度衰减器 public static void attenuator(){ new Thread(()->{ // 遍历集群中的所有节点 for (Server server : Servers.SERVERS) { // 如果活跃度不为0 if (server.getActive().get() != 0){ // 则自减一个活跃度 server.getActive().getAndDecrement(); } } try { // 每隔 2 秒中衰减一次活跃度 Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } // 模拟的集群节点信息,活跃数最开始默认为0 public static List<Server> SERVERS = Arrays.asList( new Server("44.120.110.001:8080",0), new Server("44.120.110.002:8081",0), new Server("44.120.110.003:8082",0) ); } // 最小活跃数算法实现类 public class LeastActive { public static String getServer(){ // 初始化最小活跃数和最小活跃数的节点 int leastActive = Integer.MAX_VALUE; Server leastServer = new Server(); // 遍历集群中的所有节点 for (Server server : Servers.SERVERS) { // 找出活跃数最小的节点 if (leastActive > server.getActive().get()){ leastActive = server.getActive().get(); leastServer = server; } } // 返回活跃数最小的节点IP return leastServer.getIP(); } public static void main(String[] args){ Servers.attenuator(); for (int i = 1; i <= 10; i++){ System.out.println("第"+ i + "个请求:" + getServer()); } } } /********运行结果*********/ 第1个请求:44.120.110.001:8080 第2个请求:44.120.110.002:8081 第3个请求:44.120.110.003:8082 第4个请求:44.120.110.001:8080 第5个请求:44.120.110.002:8081 第6个请求:44.120.110.003:8082 第7个请求:44.120.110.001:8080 第8个请求:44.120.110.002:8081 第9个请求:44.120.110.003:8082 第10个请求:44.120.110.001:8080
观察如上案例的运行结果,似乎结果好像是轮询的效果呀?确实是的,这是因为在最开始,所有节点的活跃数都为 0,三个节点的活跃数都相同。
所以默认会先取集群中的第一个活跃数为 0 的节点处理请求,第一个节点的活跃数会变成 1,第二次请求时最小活跃数也为 0,然后取第二个节点处理请求,依此类推......
在线上环境下,不会出现轮询的效果,因为每台服务器随着运行时间的增长,活跃数必然会不同,因此该算法总会取活跃数最小的节点提供服务。
当然,上述案例中实现的最小活跃数,是比较简易的版本,对于完善的实现可以参考 Dubbo 框架中的 com.alibaba.dubbo.rpc.cluster.loadbalance.LeastActiveLoadBalance 类,其中也实现了权重机制。
简单阐述一下其中的原理实现:
- 先从注册中心中拉取所有的服务实例,然后找出活跃数最小的节点。
- 如果只有一个,那么则直接返回对应的实例节点处理本次请求。
- 如果存在多个,则根据每个节点配置的权重值来决定本次处理请求的具体节点。
- 如果权重值不同,优先选取权重值最大的实例,作为处理本次请求的节点。
- 如果存在相同的最大权重值,那么则通过随机的方式选择一个节点提供服务。
当然,由于需要对每个节点去实现活跃数监听,所以在 Dubbo 框架中,想要配置最小活跃数策略,那么需要首先启用 ActiveLimitFilter 记录每个节点的活跃数。
或者也可以参考 Ribbon 框架 com.netflix.loadbalancer 包下面的 BestAvailableRule 最小活跃数算法实现类。
从最小活跃数算法特性不难得知,该算法带来的优势极为明显,永远都能选取节点列表中最空闲的那台服务器处理请求,从而避免某些负载过高的节点,还依旧承担需要承担新的流量访问,造成更大的压力。
最优响应算法
与前面分析的最小活跃数算法一样,最优响应算法也是一种动态算法,但它比最小活跃数算法更加智能,因为最小活跃数算法中,如果一台节点存在故障,导致它自身处理的请求数比较少,那么它会遭受最大的访问压力,这显然是并不合理的。
最小活跃数算法就类似于平时的搬砖工作,谁事情做的最少谁留下来加班,在正常情况下,这种算法都能够找到“摸鱼”最厉害的员工留下来加班。
但如果有一天,某个员工由于身体出问题了,导致自己做的工作量比较少,但按照这种算法的逻辑,依旧会判定为该员工今天最闲,所以留下来加班。
从上述这个案例中,大家略微能够感受出来最小活跃数算法的不合理性。
而最优响应算法则更加智能,该算法在开始前,会对服务列表中的各节点发出一个探测请求(例如 Ping 或心跳包检测),然后根据各节点的响应时间来决定由哪台服务器处理客户端请求,该算法能较好根据节点列表中每台机器的当前运行状态分发请求。
Java 实现如下:
public class Servers { // 模拟的集群节点信息,活跃数最开始默认为0 public static List<Server> SERVERS = Arrays.asList( new Server("44.120.110.001:8080"), new Server("44.120.110.002:8081"), new Server("44.120.110.003:8082") ); } public class Server { private String IP; public Server(){} public Server(String IP) { this.IP = IP; } public String getIP() { return IP; } public void setIP(String IP){ this.IP = IP; } public String ping(){ // 生成一个1000~3000之间的随机数 int random = ThreadLocalRandom.current().nextInt(1000, 2000); try { // 随机休眠一段时间,模拟不同的响应速度 Thread.sleep(random); } catch (InterruptedException e) { e.printStackTrace(); } // 最后返回自身的IP return this.IP; } } public class ResponseTime { // 创建一个定长的线程池,用于去执行ping任务 static ExecutorService pingServerPool = Executors.newFixedThreadPool(Servers.SERVERS.size()); public static String getServer() throws InterruptedException { // 创建一个CompletableFuture用于拼接任务 CompletableFuture cfAnyOf; // 创建一个接收结果返回的server节点对象 final Server resultServer = new Server(); // 根据集群节点数量初始化一个异步任务数组 CompletableFuture[] cfs = new CompletableFuture[Servers.SERVERS.size()]; // 遍历整个服务器列表,为每个节点创建一个ping任务 for (Server server : Servers.SERVERS) { // 获取当前节点在集群列表中的下标 int index = Servers.SERVERS.indexOf(server); // 为每个节点创建一个ping任务,并交给pingServerPool线程池执行 CompletableFuture<String> cf = CompletableFuture.supplyAsync(server::ping,pingServerPool); // 将创建好的异步任务加入数组中 cfs[index] = cf; } // 将创建好的多个Ping任务组合成一个聚合任务并执行 cfAnyOf = CompletableFuture.anyOf(cfs); // 监听执行完成后的回调,谁先执行完成则返回谁 cfAnyOf.thenAccept(resultIP -> { System.out.println("最先响应检测请求的节点为:" + resultIP); resultServer.setIP((String) resultIP); }); // 阻塞主线程一段时间,防止CompletableFuture退出 Thread.sleep(3000); // 返回最先响应检测请求(ping)的节点作为本次处理客户端请求的节点 return resultServer.getIP(); } public static void main(String[] args) throws InterruptedException { for (int i = 1; i <= 5; i++){ System.out.println("第"+ i + "个请求:" + getServer()); } } } /******运行结果:******/ 最先响应检测请求的节点为:44.120.110.002:8081 第1个请求:44.120.110.002:8081 最先响应检测请求的节点为:44.120.110.002:8081 第2个请求:44.120.110.002:8081 最先响应检测请求的节点为:44.120.110.003:8082 第3个请求:44.120.110.003:8082 最先响应检测请求的节点为:44.120.110.003:8080 第4个请求:44.120.110.001:8080 最先响应检测请求的节点为:44.120.110.002:8081 第5个请求:44.120.110.002:8081
在该案例中,其实现过程对比之前的算法略微复杂一些,首先在 Server 实例类中定义了一个 Ping() 方法,该方法中使用随机数+线程休眠的方式简单模拟了一下节点的不同的响应速度。
然后在算法实现类中,利用 CompletableFuture 分别对每一个节点都创建了对应的 Ping 任务,然后同时执行,又通过 thenAccept() 回调方法监听了执行结果,谁最先响应,则取其作为处理本次请求的节点。
这个算法的实现过程中,唯一难理解的就是 CompletableFuture,它是 JDK8 中推出的一种异步任务。
这里只是举例实现,所以通过 CompletableFuture 实现了检测请求,但实际过程中如果要选择这种算法,那么基于 Netty 会更为合适。
从上述案例的运行结果中也可以得知:最优响应算法无论在何种情况下,都能从集群中选取性能最好的节点对外服务,Nginx 中也支持配置这种算法,但需要先安装对应的 nginx-upstream-fair 模块。
总结
在本文中,对于比较常用的请求分发算法进行了剖析及手写实践,其中提到了较为传统的静态调度算法:轮询、随机、加权、一致性哈希等,也谈到了一些较为智能的动态算法:最小活跃数、最优响应等。
但需要牢记的一点是:并非越智能的算法越好,越是并发高、流量大的场景下,反而选用最基本的算法更合适,例如微信的红包业务,就是采用最基本的轮询算法进行集群调度。
那这又是为何呢?因为越智能的调度算法,进行节点选择时的开销会更大,如果你对于文中给出的调度算法实现都一一运行过,那么大家会明显感知出:越到后面的算法,分发请求的速度越慢。
因此在面临巨大访问压力的情景中,选择最简单的算法反而带来的收益更高,但前提是需要集群中所有的节点硬件配置都一致,所有节点分配的资源都相同,轮询算法则是最佳的调度算法。