[享学Netflix] 四十七、Ribbon多区域选择:ZoneAvoidanceRule.getAvailableZones()获取可用区

质量、速度、廉价,只能选择其中两个。

–> 返回专栏总目录 <–
代码下载地址:https://github.com/f641385712/netflix-learning

前言

随着微服务、云源生的流行,多云、多区域(zone)、跨机房部署的case越来越多。Ribbon作为微服务领域的优秀组件,自然也提供了对多区域支持的负载均衡能力。

作为基础,本文将介绍多zone负载均衡中最为重要的一个方法:ZoneAvoidanceRule.getAvailableZones(),它解决了根据LoadBalancerStats状态信息仲裁出可用区出来。


正文

关于getAvailableZones方法,其实有两处地方都叫这个名字,但是它们的功能是不一样的,且存在依赖的关系,为了避免读者迷糊,现分别进行阐述。


LoadBalancerStats#getAvailableZones实例方法

它是LoadBalancerStats里的一个实例方法:

LoadBalancerStats:

	volatile Map<String, List<? extends Server>> upServerListZoneMap = new ConcurrentHashMap<>();
    public Set<String> getAvailableZones() {
        return upServerListZoneMap.keySet();
    }

若有下面逻辑的存在,其实我觉得该方法的命令是颇具歧义的,或许叫getAllAvailableZones()会更合适一些。因为它仅是一个普通的获取方法,并不考虑对应zone内Server的负载情况、可用情况,这些都交给下面这个工具方法进行完成。


ZoneAvoidanceRule静态工具方法

首先我吐槽一下:作为static工具方法,为毛放在ZoneAvoidanceRule里呢?统一放在LoadBalancerStats里内聚起来不香吗?


randomChooseZone()

在开始之前,我们先了解下这个选择支持方法,但是它是非public。调用方仅有两处:

  • 下面的getAvailableZones()方法
  • ZoneAwareLoadBalancer#chooseServer()方法(后文重点阐述,非常重要)
ZoneAvoidanceRule:

    static String randomChooseZone(
    		Map<String, ZoneSnapshot> snapshot,
            Set<String> chooseFrom) {
        if (chooseFrom == null || chooseFrom.size() == 0) {
            return null;
        }
		// 注意:默认选择的是第一个zone区域
		// 若总共就1个区域,那就是它了。若有多个,那就需要随机去选
		String selectedZone = chooseFrom.iterator().next();
        if (chooseFrom.size() == 1) {
        	return chooseFrom.iterator().next();
        }

		// 所有的区域中总的Server实例数
        int totalServerCount = 0;
        for (String zone : chooseFrom) {
            totalServerCount += snapshot.get(zone).getInstanceCount();
        }
        // 从所有的实例总数中随机选个数字。比如总数是10台机器
        // 那就是从[1-10]之间随机选个数字,比如选中为6
        int index = random.nextInt(totalServerCount) + 1;
		
		// sum代表当前实例统计的总数
		// 它的逻辑是:当sum超过这个index时,就以这个区域为准
        int sum = 0;
        for (String zone : chooseFrom) {
            sum += snapshot.get(zone).getInstanceCount();
            if (index <= sum) {
                selectedZone = zone;
                break;
            }
        }
	
	}

这个随机算法最核心的就是最后面的indexsum算法,看完后你应该有如下疑问:为何不来个从chooseFrom这个集合里随机弹出一个zone就成,而非弄的这么麻烦呢?

其实这么做的是很有意义的,这么做能保证:zone里面机器数越多的话,被选中的概率是越大的,这样随机才是最合理的


getAvailableZones()

该方法是一个静态工具方法,顾名思义它用于获取真实的可用区,它在LoadBalancerStats#getAvailableZones方法的基础上,结合每个zone对应的ZoneSnapshot的情况再结合阈值设置,筛选真正可用的zone区域。

ZoneAvoidanceRule:

	// snapshot:zone对应的ZoneSnapshot的一个map
	// triggeringLoad:
	// triggeringBlackoutPercentage:
	public static Set<String> getAvailableZones(
			Map<String, ZoneSnapshot> snapshot,
			double triggeringLoad,
			double triggeringBlackoutPercentage) {
		// 为毛一个都木有不返回空集合???有点乱啊。。。。不过没关系
        if (snapshot.isEmpty()) {
            return null;
        }
		
		//最终需要return的可用区,中途会进行排除的逻辑
		Set<String> availableZones = new HashSet<>(snapshot.keySet());
		// 如果有且仅有一个zone可用,再糟糕也得用,不用进行其他逻辑了
        if (availableZones.size() == 1) {
            return availableZones;
        }
		
		// 记录很糟糕
		Set<String> worstZones = new HashSet<>();
		// 所有zone中,平均负载最高值
		double maxLoadPerServer = 0;
		// true:zone有限可用
		// false:zone全部可用
		boolean limitedZoneAvailability = false;

		// 对每个zone的情况逐一分析
		for (Map.Entry<String, ZoneSnapshot> zoneEntry : snapshot.entrySet()) {
            String zone = zoneEntry.getKey();
            ZoneSnapshot zoneSnapshot = zoneEntry.getValue();
            int instanceCount = zoneSnapshot.getInstanceCount();
            
            // 若该zone内一个实例都木有了,那就是完全不可用,那就移除该zone
            // 然后标记zone是有限可用的(并非全部可用喽)
            if (instanceCount == 0) {
                availableZones.remove(zone);
                limitedZoneAvailability = true;
            } else {
            	// 该zone的平均负载
				double loadPerServer = zoneSnapshot.getLoadPerServer();
				
				// 机器的熔断总数 / 总实例数已经超过了阈值(默认为1,也就是全部熔断才会认为该zone完全不可用)
				// 或者 loadPerServer < 0 (啥时候小于0???下面说)
                if (((double) zoneSnapshot.getCircuitTrippedCount()) / instanceCount >= triggeringBlackoutPercentage
                        || loadPerServer < 0) {
					// 证明这个zone完全不可用,就移除掉
                    availableZones.remove(zone);
                    limitedZoneAvailability = true;
				} else { // 并不是完全不可用,就看看状态是不是很糟糕

					// 若当前负载和最大负载相当,那认为已经很糟糕了
					if (Math.abs(loadPerServer - maxLoadPerServer) < 0.000001d) {
						worstZones.add(zone);
					
					// 或者若当前负载大于最大负载了
					} else if (loadPerServer > maxLoadPerServer) {
						maxLoadPerServer = loadPerServer;
						worstZones.clear();
						worstZones.add(zone);
					}
				}
            }
		}

		// 若最大负载小于设定的负载阈值 并且limitedZoneAvailability=false
		// 就是说全部zone都可用,并且最大负载都还没有达到阈值,那就把全部zone返回
        if (maxLoadPerServer < triggeringLoad && !limitedZoneAvailability) {
            // zone override is not needed here
            return availableZones;
        }
        String zoneToAvoid = randomChooseZone(snapshot, worstZones);
        if (zoneToAvoid != null) {
            availableZones.remove(zoneToAvoid);
        }
        return availableZones;
	}

这个选择可用区的步骤还是比较重要的,毕竟现在多区域部署、多云部署都比价常见,现在对它的处理过程做如下文字总结:

  1. 若zone为null,返回null。若只有一个zone,就返回当前zone,不用再继续判断。否则默认返回所有zone:availableZones。接下来会一步步做remove()移除动作
  2. 使用变量Set<String> worstZones记录所有zone中比较糟糕的zone们;用maxLoadPerServer表示所有zone中负载最高的区域;用limitedZoneAvailability表示是否是部分zone可用(true:部分可用,false:全部可用)
  3. 遍历所有的zone,根据其对应的快照ZoneSnapshot来判断负载情况
  4. 若当前zone的instanceCount也就是实例总数是0,那就remove(当前zone),并且标记limitedZoneAvailability=true(因为移除了一个,就不是全部了嘛)。若当前zone的实例数>0,那就继续
  5. 拿到当前总的平均负载loadPerServer,如果zone内的熔断实例数 / 总实例数 >= triggeringBlackoutPercentage阈值 或者 loadPerServer < 0的话,那就执行remove(当前zone),并且limitedZoneAvailability=true
    1. 熔断实例数 / 总实例数 >= 阈值标记为当前zone就不可用了(移除掉),这个很好理解。这个阈值为0.99999d也就说所有的Server实例被熔断了,该zone才算不可用了
    2. loadPerServer < 0是什么鬼?那么什么时候loadPerServer会是负数呢?它在LoadBalancerStats#getZoneSnapshot()方法里:if (circuitBreakerTrippedCount == instanceCount)的时候,loadPerServer = -1,也就说当所有实例都熔断了,那么loadPerServer也无意义了嘛,所以赋值为-1。
    3. 总的来说1和2触达条件差不多,只是1的阈值是可以配置的,比如你配置为0.9那就是只有当90%机器都熔断了就认为该zone不可用了,而不用100%(请原谅我把0.99999d当1来看待)
  6. 经过以上步骤,说明所有的zone是基本可用的,但可能有些负载高有些负载低,因此接下来需要判断区域负载情况,就是如下这段代码。这段代码的总体意思是:从所有zone中找出负载最高的区域们(若负载差在0.000001d只能被认为是相同负载,都认为是负载最高的们)。
    1. 说明:worstZones里面装载着负载最高的zone们,也就是top1(当然可能多个并列第一的情况)
	if (Math.abs(loadPerServer - maxLoadPerServer) < 0.000001d) {
	    // they are the same considering double calculation
	    // round error
	    worstZones.add(zone);
	} else if (loadPerServer > maxLoadPerServer) {
	    maxLoadPerServer = loadPerServer;
	    worstZones.clear();
	    worstZones.add(zone);
	}
  1. 分析好数据后,最后准备返回结果。若统计完所有的区域后,最高负载maxLoadPerServer仍旧小于提供的triggeringLoad阈值,并且并且limitedZoneAvailability=false(就是说所有zone都可用的情况下),那就返回所有的zone吧:availableZones
    1. 这个很好理解:所有的兄弟们负载都很低,并且一个哥们都没“死”,那就都返回出去呗
    2. triggeringLoad阈值的默认值是0.2,负载的计算方式是:loadPerServer = 整个zone的活跃请求总数 / 整个zone内可用实例总数
      1. 注意:一定是活跃连接数。也就是说正在处理中的链接数才算做服务压力嘛
  2. 若最大负载超过阈值(或者死了一个/N个兄弟),那么就不能返回全部拉。那就从负载最高的兄弟们中(因为可能多个,可能1个,大概率是只有1个值的)随机选择一个出来:randomChooseZone(snapshot, worstZones),然后执行移除remove(zoneToAvoid)掉,这么处理的目的是把负载最高的那个哥们T除掉,再返回结果。
    1. 说明:这里使用的随机算法就是上面所讲述的(谁的zone里面实例数最多,就越可能被选中)

总而言之:选择可用区的原则是T除掉不可用的、T掉负载最高的区域,其它区域返回结果,这样处理后返回的结果才是健康程度综合最好的。

另外,该方法还有个重载的,便捷使用方法:

ZoneAvoidanceRule:

	// 实际调用仍旧为getAvailableZones方法~
	// 它友好的只需要传参LoadBalancerStats即可,内部帮你构建snapshot这个Map
    public static Set<String> getAvailableZones(LoadBalancerStats lbStats,
            double triggeringLoad, double triggeringBlackoutPercentage) {
        if (lbStats == null) {
            return null;
        }
        Map<String, ZoneSnapshot> snapshot = createSnapshot(lbStats);
        return getAvailableZones(snapshot, triggeringLoad,
                triggeringBlackoutPercentage);
    }

该方法使用处

getAvailableZones()方法的调用处主要有两个地方:

  • ZoneAvoidancePredicate#apply():用于过滤掉哪些超过阈值的、不可用的zone区域们
  • ZoneAwareLoadBalancer#chooseServer():通过此方法拿到可用区域们availableZones,然后再通过randomChooseZone()方法从中随机选取一个出来,再从zone里选择一台Server就是最佳的Server

不合理的默认值

可以先看下面代码示例。计算可用区的两个阈值是:

  • triggeringLoad:平均负载阈值。该阈值可配置
    • ZoneAvoidancePredicate:默认值均为0.2d
      • 默认key:ZoneAwareNIWSDiscoveryLoadBalancer.triggeringLoadPerServerThreshold
      • 个性化key:"ZoneAwareNIWSDiscoveryLoadBalancer." + clientConfig.getClientName() + ".triggeringLoadPerServerThreshold"
    • ZoneAwareLoadBalancer:默认值亦为0.2
      • 配置同上
  • triggeringBlackoutPercentage:触发“熄灭”的百分比阈值(简单的说当你的实例挂了%多少时,就移除掉此区域)。

triggeringBlackoutPercentage这个阈值尚且合理(默认所有实例挂了才会移除这个zone),但是triggeringLoad这个阈值仅设置为0.2,what a fuck???也就说一个zone里面有10台机器的话,超过2个请求打进来就算负载过重,从而最终结果会移除掉一个负载最高的可用区,这么设定脑子不是怕陪驴砸了吧?

这么配置有何后果?
0.2的阈值等于所有zone都处于过载状态,因此选择可用区的时候永远会T除掉一个(当然你只有一个可用区除外),假如你总共只有2个可用区,这将使得负载均衡策略完全失效~~~~

说明:我强烈怀疑老外是想表达负载超过20%了就算负载过重了,只是它没考虑到ZoneSnapshot.loadPerServer它并不是一个百分比值~~~

在实际生产中:我个人强烈建议你增加默认配置ZoneAwareNIWSDiscoveryLoadBalancer.triggeringLoadPerServerThreshold = 100。表示单台机器超过100个并发后认为负载过高了(当然100这个数值你可以根据机器配置具体设定,此处仅供参考),这样能极大的提高zone之间的负载均衡能力

说明:这一切都建立在你的应用部署在多zone的情况下,若你仅有一个zone,那么请忽略本文内容~


代码示例

// 单独线程模拟刷页面,获取监控到的数据
private void monitor(LoadBalancerStats lbs) {
    List<String> zones = Arrays.asList("华南", "华东", "华北");
    new Thread(() -> {
        ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
        executorService.scheduleWithFixedDelay(() -> {
            // 打印当前可用区
            // 获取可用区
            Set<String> availableZones = ZoneAvoidanceRule.getAvailableZones(lbs, 0.2d, 0.99999d);
            System.out.println("=====当前可用区为:" + availableZones);

            zones.forEach(zone -> {
                System.out.printf("区域[" + zone + "]概要:");
                int instanceCount = lbs.getInstanceCount(zone);
                int activeRequestsCount = lbs.getActiveRequestsCount(zone);
                double activeRequestsPerServer = lbs.getActiveRequestsPerServer(zone);
                // ZoneSnapshot zoneSnapshot = lbs.getZoneSnapshot(zone);

                System.out.printf("实例总数:%s,活跃请求总数:%s,平均负载:%s\n", instanceCount, activeRequestsCount, activeRequestsPerServer);
                // System.out.println(zoneSnapshot);
            });
            System.out.println("======================================================");
        }, 5, 5, TimeUnit.SECONDS);
    }).start();
}


// 请注意:请必须保证Server的id不一样,否则放不进去List的(因为Server的equals hashCode方法仅和id有关)
// 所以此处使用index作为port,以示区分
private Server createServer(String zone, int index) {
    Server server = new Server("www.baidu" + zone + ".com", index);
    server.setZone(zone);
    return server;
}


// 多线程,模拟请求
private void request(ServerStats serverStats) {
    new Thread(() -> {
        // 每10ms发送一个请求(每个请求处理10-200ms的时间),持续不断
        ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
        executorService.scheduleWithFixedDelay(() -> {
            new Thread(() -> {
                // 请求之前 记录活跃请求数
                serverStats.incrementActiveRequestsCount();
                serverStats.incrementNumRequests();
                long rt = doSomething();
                // 请求结束, 记录响应耗时
                serverStats.noteResponseTime(rt);
                serverStats.decrementActiveRequestsCount();
            }).start();
        }, 10, 10, TimeUnit.MILLISECONDS);
    }).start();
}

// 模拟请求耗时,返回耗时时间
private long doSomething() {
    try {
        int rt = randomValue(10, 200);
        TimeUnit.MILLISECONDS.sleep(rt);
        return rt;
    } catch (InterruptedException e) {
        e.printStackTrace();
        return 0L;
    }
}

// 本地使用随机数模拟数据收集
private int randomValue(int min, int max) {
    return min + (int) (Math.random() * ((max - min) + 1));
}

// ============单元测试
@Test
public void fun6() throws InterruptedException {
    LoadBalancerStats lbs = new LoadBalancerStats("YoutBatman");

    // 添加Server
    List<Server> serverList = new ArrayList<>();
    serverList.add(createServer("华南", 1));
    serverList.add(createServer("华东", 1));
    serverList.add(createServer("华东", 2));

    serverList.add(createServer("华北", 1));
    serverList.add(createServer("华北", 2));
    serverList.add(createServer("华北", 3));
    serverList.add(createServer("华北", 4));
    lbs.updateServerList(serverList);

    Map<String, List<Server>> zoneServerMap = new HashMap<>();
    // 模拟向每个Server发送请求  记录ServerStatus数据
    serverList.forEach(server -> {
        ServerStats serverStat = lbs.getSingleServerStat(server);
        request(serverStat);

        // 顺便按照zone分组
        String zone = server.getZone();
        if (zoneServerMap.containsKey(zone)) {
            zoneServerMap.get(zone).add(server);
        } else {
            List<Server> servers = new ArrayList<>();
            servers.add(server);
            zoneServerMap.put(zone, servers);
        }
    });
    lbs.updateZoneServerMapping(zoneServerMap);

    // 从lbs里拿到一些监控数据
    monitor(lbs);

    TimeUnit.SECONDS.sleep(500);
}

运行程序,打印:

=====当前可用区为:[华南, 华东]
区域[华南]概要:实例总数:1,活跃请求总数:10,平均负载:10.0
区域[华东]概要:实例总数:2,活跃请求总数:18,平均负载:9.0
区域[华北]概要:实例总数:4,活跃请求总数:41,平均负载:10.25
======================================================
=====当前可用区为:[华南, 华北]
区域[华南]概要:实例总数:1,活跃请求总数:9,平均负载:9.0
区域[华东]概要:实例总数:2,活跃请求总数:22,平均负载:11.0
区域[华北]概要:实例总数:4,活跃请求总数:34,平均负载:8.5
======================================================
=====当前可用区为:[华南, 华东]
区域[华南]概要:实例总数:1,活跃请求总数:9,平均负载:9.0
区域[华东]概要:实例总数:2,活跃请求总数:18,平均负载:9.0
区域[华北]概要:实例总数:4,活跃请求总数:37,平均负载:9.25
======================================================
=====当前可用区为:[华北, 华东]
区域[华南]概要:实例总数:1,活跃请求总数:10,平均负载:10.0
区域[华东]概要:实例总数:2,活跃请求总数:17,平均负载:8.5
区域[华北]概要:实例总数:4,活跃请求总数:39,平均负载:9.75
======================================================
...

从中可以明显的看出:每次会把负载最高的Zone给T除掉(请认真观察输出的数据来发现规律),这是完全符合预期的。

说明:因为平均负载均超过阈值0.2,所以会从所有zone中排除掉一个负载最高的zone~


总结

关于Ribbon可用区选择逻辑就先介绍这,这里有必要再次强调:虽然它为static静态方法,但是它是可用区过滤逻辑、可用区选择的核心逻辑,这对后面的具有区域意识的LoadBalancer的理解具有核心要意。

这部分逻辑理解起来稍显费力,建议多读几遍,并且结合自己脑补的场景便可完成,当然喽,若有不知道的概念,请参阅前面相关文章,毕竟学习就像砌砖,跳不过去的。
分隔线

声明

原创不易,码字不易,多谢你的点赞、收藏、关注。把本文分享到你的朋友圈是被允许的,但拒绝抄袭。你也可【左边扫码/或加wx:fsx641385712】邀请你加入我的 Java高工、架构师 系列群大家庭学习和交流。
往期精选

发布了378 篇原创文章 · 获赞 572 · 访问量 51万+
App 阅读领勋章
微信扫码 下载APP
阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: Age of Ai 设计师: meimeiellie

分享到微信朋友圈

×

扫一扫,手机浏览