背景
公司使用阿里的云主机部署计算型的服务,就是特别耗cpu的那种。使用过程中有一件事情很苦恼,那就是云主机的性能是不一致的,机器间的性能可相差30%,更严重的是由于是共享云主机,经常在晚上8点钟左右(各大网站的高峰期)有某些机器的系统cpu突然飙高(原因是一次系统调用消耗突然增加,系统cpu能飙到90%,机器基本不可用)。这个问题其实很好解释:阿里云在同一个物理机上虚拟好多云主机,并且云主机之间隔离做的不好,一台云主机可能会影响同一物理机上的另一台云主机,导致你的云主机性能有问题的可能是另外一家公司使用的云主机。多次向阿里提出这个问题,阿里并没给啥解释,就是推荐我们换一台云主机试试(这是玩我呢么?)。而我要在这么不靠谱的机器上要做到服务稳定,最需要解决的问题就是如何动态的根据机器的性能变化调整分配给每台机器的流量。
方案一:尝试阿里的slb(不能解决问题)
阿里的slb提供了几种流量调度算法:轮询调度、加权轮询调度、最小连接数优先调度。
- 轮询调度:顾名思义就是顺序的分配流量,它保证每台机器接受的请求量是相同的。这个算法不能解决问题,因为我们现在就是不能让每台机器接收的请求量相同,我们的要求是性能好的机器接收的多,性能差的机器接收的少,故pass。
- 加权轮询:这个方法给每台机器加了一个权重,比如默认可以都设置成100,这样所有服务接收等量的请求。也可以设置成100,100,80,这样如果有3台机器和280个请求,三台机器分别接收100,100,80个请求。看起来很美好,但是现实很骨感。它没法动态的调整权重,特别对于突发情况,必须人为手动去更改slb配置。更严重的是,对slb的操作会偶尔持续失败(阿里的slb也可能正在上线、维护),这对于及时处理问题可有不小的麻烦,故pass。
- 最小连接数优先:乍看起来可能觉得不错,其实和轮询调度一样的问题,它总尝试把所有机器的连接数压成一致,也就是说尽量保证每个机器的请求量一致,这就会使得性能差的机器cpu一直被压满。我们要的就是流量不均衡的调度,而不是这种非得保证流量均衡的调度,故pass。
所以,最终我放弃了。看来必须自己解决这个问题,那么就顺手写个流量调度算法吧。
方案二:客户端流量自适应算法
设计思路
首先要明确我们想要解决的问题是根据机器的性能分配流量,面对的第一个难题就是如何实时的获取机器性能信息呢?大多数小伙伴的思路是实时获取机器的cpu信息,但是我认为这太复杂,还需要开发一套实时收集cpu信息的辅助系统,不划算。我只利用一个信息:某段时间内调用某个机器上服务的失败次数。
第二个问题就是客户端自适应算法不是全局感知。我说的全局感知是:由统一的服务收集并记录客户端调用每个服务的失败次数,客户端根据这个集中统计数据来分配流量。我们不采用全局感知,就意味着每个客户端根据自己当前得到的信息进行局部的流量分配,这可行吗?可行!如果客户端数量足够多,根据统计学意义,所有局部感知的结果汇总和整体感知的效果基本相当。当客户端数目不够多的时候呢?反证法:如果存在一个服务接收过量的请求,那么推测肯定有某一客户端调用该服务会失败,那么该客户端就会主动减少对这台后端的服务的调用,从而减少这个有问题服务接收的请求数。接下来说说说算法设计思路:初始时设置每台后端机器的权重w=10,当超时10次的时间间隔(最后一次超时的时间戳与第一次超时的时间戳相差)小于10s时,则对这台机器的权重减1,从而减少10%的流量。
但是现在这个调度算法并不完善,有如下几个问题要解决。
- 集中失败问题 观察实际情况发现,当一个服务性能突然下降时,会有大量的请求同时失败。由于我们的算法是记录连续失败次数的时间段,这就可能导致机器的权重瞬间降至0(如果瞬间100次超时,就会出现这种情况),从而该服务获取不到流量。解决这个问题的方式是在降权一次后忽略接下来一个时间段内的失败次数(比如设置5秒或者10秒),这样把这些瞬间失败归为同一拨。
- 升权问题 阿里云的机器突发这种系统cpu飙高的情况持续的时间不一定,有的时候持续几分钟,有的时候持续几小时甚至几天,当这种情况恢复正常时,调度算法能够逐渐恢复该机器的请求数量。所以,可以在合适的时机试探性的增加流量。我的做法是:在一定的权值基础上,如果算法正常工作超过10分钟(10分钟内没有再发生降权的情况),可以尝试把权重+1,这样就多分配10%的流量,流量增加10%后还可能会由于性能不够导致降权。
- 比率问题 假如w=10时,服务在10秒内接收到100个请求,其中失败了10次,算法降权后w=9;w=9时,服务在10秒内接收了90个请求,失败了9次,这个时候应不应该降权呢?要降!因为w=9之所以失败次数少,是因为调用次数少,但是这同样说明服务的性能是有问题的。所以,在一开始我们的算法思路说的不完全准确,不是固定时间固定的失败次数,而是需要按照权重不断调整失败次数。w=10时,10s内失败10次就要降权;w=9时,10s内失败9次就要降权;以此类推。
- 保底策略 由于我们是统计失败10次所用的时间长度,可能在某些情况下(比如上线的时候,算法配置时间段,失败次数,不合理)会造成对某些机器一直降权,而真实情况是后端机器性能并没有差到哪去,这会造成机器资源浪费并且还会有更多的请求拒绝。所以算法需配置一个机器权重下限,比如设置成6,就能保证最起码有该机器正常情况下流量的60%会发送到该机器上。
具体实现
- 快速选择机器 每个服务对应一个调用次数:request_cnt,算法每次选择该服务时就对其累加+1。我们设置初始权重为w,当前权重为cur_w,那么当request_cnt % w < cur_w时,流量分配给该服务,否则跳过,这样就保证了安装权重分配流量。
- 解耦 为了解耦调度算法与具体环境,我们抽象一套算法接口。
- SelectAlgorithm:算法接口
- SimpleSelectAlgorithm:普通轮询调度算法
- WeightSelectAlgorithm:客户端动态自适应算法
- add_addr:为算法添加后端服务
- remove_addr:从算法中移除后端服务
- failed:当请求后端服务失败后调用,比如统计失败次数,最近10次失败的间隔,降权等。
- succeed:当请求后端服务成功时调用,如果最近一段时间内没有调用失败,尝试升高权重。
- next:返回下一次调用时可用的后端服务
如何使用我们的算法呢,实例如下:
Address addr; Algorithm *algorithm = new WeightSelectAlgorithm(...); algorithm->next(addr); //选择一个服务 int ret = request(addr); if(ret != 0) { algorithm->failed(addr.host, addr.port); //请求失败 } else { algorithm->succeed(addr.host, addr.port); //请求成功 }
我这里只给大家提供了这个设计思路,具体代码实现我相信难不倒各位小伙伴,这里就不贴了。算法初始化时需接受一些配置参数,一定要保证这些参数可以手动调整,直到达到满意的效果位置。这个算法上线后的效果还是挺好的,请求失败的次数能够降一个量级,由于客户端有重试策略,最终失败的调用次数可以忽略不计。