cover_image

PHP生态 Hystrix 实践(二)

卢阳@贝壳找房 贝壳产品技术
2021年11月25日 12:52
图片


为了应对服务雪崩,我们在php生态中采用了hystrix的计数器和断路器设计,来实践其熔断机制。文章主要分为两个部分:第一部分介绍熔断背景、hystrix的流程、计数器原理以及断路器原理;第二部分介绍熔断组件的设计与实现、压测结果和配置监控等闭环。


《PHP生态 Hystrix 实践(一)》中介绍了为应对服务雪崩,对服务引入hystrix熔断能力,讨论了断路器的三种状态转换,以及计数器与断路器的内核。随后介绍了hystrix的四种模式和结合三种状态的转换详细解释了工作流程。


本篇为第二部分,首先详细介绍了两种计数器的设计,桶的存储设计,以及配置、监控等方案;其次通过比对介绍了单机版hystrix的设计的特殊之处;最终,通过对多种计数器内核的方案做压测,说明我们最终选取方案的取舍。


1 集群版关键环节实现


1.1 计数的实现


我们共尝试实现了两种方案的计数器,分别为循环桶和固定桶。以下分别阐述两种的实现方案。


1.1.1 循环桶实现


如下图所示,一个首尾相接的数组,可以给命名为A-E桶。根据hystrix的rolling window,来看,桶的大小为window 大小除以桶的个数。例如,window为20秒,每个桶为4s。


图片

图1:循环桶计数模型


那么桶什么时候产生和过期呢?横轴为时间轴,纵轴的0坐标表示当前时间,则window观察的始终是最新的以window大小的这段时间。那么对于桶来说,是不断的产生和不断的被淘汰的。这里桶的数据结构为:


[
    'bucket_A_value' => 2,
    'bucket_A_start_time' => '1624158371.119',
];


可以看到,这里记录了桶的value和start time。如果bucket C的start time已经超过了window,则这个桶已经过期,同时生成新的C桶,此时的数据都记录在新的C桶中。随着时间推移,如此往复。

图片

图2:循环桶的产生和过期


bucket_A_value记录的是一个64位整数,用于记录succ、fail、reject(成功、失败、拒绝)三种类型的统计计数,将这三个数字压缩为一个值的目的是为了减少redis io。


每个bucket的设计结构如下,分为2,2,18,2,16,2,22。最前端其中黄色2位没使用,中间有3个粉色的2位表示防溢出位,用于计数溢出,在增加多倍后才能影响到前面的类型计数。对每次succ计数,value增加2的0次方,即做+1操作。对于fail和reject分别做 +2^24 和 +2^42操作。


图片

图3:bucket计数值结构


防溢出位的设计是为了缓冲溢出的计数,不会立刻影响到其它类型的技术,在每次解析这个value的地方有溢出检查,可以配合matric上报或日志分析等手段抛出报警。


图片

表1:bit范围及说明


1.1.2 固定桶实现


固定桶是假象从1970年开始的时间的时间戳,以桶的大小来计算,在这条时间轴上已经生成了桶,任何一个时刻都有对应的固定的桶。于是,可以简化生成桶的环节,以桶的自然序号当做序列号即可区分每个桶。如,桶的大小为20s,当前时间为1624158371.119,则当前桶的序列号为81207918。以桶的个数为5为例,从当前时刻往前计算5个桶(包含当前桶)即可获取window,之前的桶认为已过期。


当需要计数时,依据当前的时间即可知道应该写入哪个桶。同样,在获取整个桶的计数时,也依据当前时间获取到有效的桶,并对每个桶中的计数求和即可知道succ,fail,reject分别是多少。


图片

图4:固定桶的产生和过期


以上已说明桶的编号和桶的start time是可换算的,桶中只需存放一个value即可。和循环桶的方案一样,为了减少redis io,也使用了对succ,fail,reject计数做压缩,放入一个64bit整数中存储。


1.2 分布式时间统一


为提升准确度消除分布式的时间差异,使用了中心化的时间。分布式系统中,虽然服务器会定期校准时间,任然会存在一定的差异。如果使用服务器的时间来作为记录上报的时间,可能在同一时刻上报的统计数据落入不同的桶中。例如服务器X比基准时间快了1s,服务器Y比基准时间慢了1s。桶的大小为2s。服务器X计算当前桶为B桶,则向B桶执行计数操作。而同时服务器Y也有一个请求,计算的当前桶为A,则向A中执行统计操作。此时,我们认为这种场景会造成统计混乱。于是在本解决方案是在每次计算当前桶的时候,会提前从redis获取当前时间,然后以这个时间来计算当前桶。虽然从redis中获取,如果遇到网络延时也会造成差异,但相比较于从服务器本地获取时间已有较大的改善。


在guzzle请求发出前和返回后,对应hystrix需要得知断路器状态和中心化时间。以下lua脚本展示了在一次redis io中获取这两个值。lua 脚本示例:


$lua = "
    local key = KEYS[1];
    local status = redis.call('get', key);
    local time = redis.call('TIME');
    return {status, time};
"
;


1.3 Apollo动态配置


熔断的参数一般是已经验值来设定的,比如1秒中有100个失败请求,失败率是10%才能触发断路器状态变化,但是随着网络环境的恶化,负载的增多需要调整失败率为5%。以上变化我们自然是希望能实时或准实时生效而不是去改代码和发布。在高并发情况下,这种配置不适合放在mysql中,因为在性能上mysql本身可能存在瓶颈。也不太适合放在redis上,redis性能上是优于mysql的,但也可能存在性能问题,同时还缺少了一个可修改配置的界面。


于是我们使用Apollo可以动态调整熔断的参数,主要是控制是否开启熔断模式,如果已开启,则读取熔断参数,参数如下:


图片

表2:Apollo配置项解释


以json形式存储在Apollo中:


{
    "thresholdCount"4,
    "thresholdPercent"10,
    "timeWindow"1000,
    "sleepTime"2,
    "circuitBreakerEnable"true,
    "storageType""redis"
}

图5:Apollo配置项展示


那么随着Apollo中的配置发布,每个请求也就相应的读取了最新的熔断参数。


1.4 事件接收


在断路器状态有变化的时候我们希望可以接受通知,并对通知做出相应的操作。


图片

表3:断路器事件及触发


我们对应这三种事件做了事件抛出,在每个项目里去接收这三类事件,并作出处理。一般推荐的方案是用同步的事件接收即可,做一个埋点上报,并配置埋点报警。如此,在断路器状态有变化的情况下开发人员可及时得知。


1.5 Monitor实现


“工欲善其事必先利其器”,在本composer包开发的过程中以及之后使用hystrix的时候都不知道计数器当前处以什么状态,或里面记录的数据当前值为多少,怎么来分析和维护呢?于是将当前计数器中的各项参数通过一个方法输出,并且在应用该composer包的项目中使用API输出当前的数据。


以下我们将这些数据做成了一个界面,放到了小工具平台中,此时和实时查询熔断器和计数器的状态和数值。


图片

图6:小工具后台hystrix monitor展示


其中不光有以上提到的熔断阈值参数(不做赘述),还有桶内部的情况,每个桶分别记录了多少成功,失败,拒绝的计数值。


2 单机版关键环节实现


单机版主要适用于QPS较高,负载均衡策略为轮询策略的情况。首先QPS较高对于公共存储redis读写的会增加压力,其次在机器配置和负载均衡策略为轮询的情况下,处理的请求数以及对下游发出请求数差别不大,也无需对每台机器做熔断配置,可使用同一份。


单机版的存储介质可以使用多种,这里使用Yac(Yet Another Cache)来实现。Yac的介绍请参考这里:https://www.laruence.com/2013/03/18/2846.html


单机版和集群版使用的计数器内核一致,可以使用循环桶或固定桶,实现原理和以上相同。


单机版无需解决分布式系统时间的问题,也无需解决trace的问题,但是需要解决的是Yac中key的长度限制的和与redis存储的公共代码抽象问题。


2.1 key长度限制


在Yac的介绍文章中,鸟哥明确指出key的长度不能超过48,也验证了这个说法。于是需要解释key的组成。对于集群版,这个key是由五部分组成:


{$APP_ID}:{$VERSION}:{$CLASS_NAME}:{$FUNCTION_NAME}:{$HYSTRIX_ITEM}

图片

表4:集群版key组成及解释


为了在存储介质中全局唯一标示一个需要熔断的RPC方法,需要这么多信息。以上这些除了$HYSTRIX_ITEM其余长度都不可控制,为了适应yac 的key 长度限制,对可变长度{$prefix}的部分拼接后做了crc32处理,这样可以大大缩短可变部分key的长度。


那么桶的编号,以上在3.1.2中提到了如“81207918”,如果桶越小这个编号越长。Crc32在我们的64位机器上执行(无负数返回)的最大值是20亿,转为字符串长度为10,那么再去压缩:

bucket_A_value -> b_A_v


拼接如下:

{$prefix}:b_A_v:{$bucket_serial}


{$bucket_serial}的长度最长可以使用到:48-10-7 = 31。这个桶编号的长度足够使用,如果31位全用完,桶的大小在10^(-20)s,这么灵敏已经失去意义了。


2.2 存储类抽象


为了实现代码复用,我们将集群版使用的Redis类和单机版使用的Yac类的公共部分整理到StorageMedia基类中,同时将两者实现的多态方法定义为Interface。


图片

图7:存储介质UML


以loadStatusAndTime方法为例,在Redis类中的行为:

  1. Lua 脚本获取redis中此服务的断路器status

  2. Lua脚本获取redis的时间

  3. 发送Lua脚本并整理返回结果


而在Yac类中这个方法的行为是:

  1. 读取yac中的该服务的断路器status

  2. Php获取本机时间


虽然存储介质不同,但这些行为得到了对齐,所以在hystrix处理单机版或集群版的主流程无任何差异,这也给我们的熔断增加了灵活性,可以单机熔断与集群熔断热切换。


3 结果验证及分析


在接入了熔断之后,发出RPC请求前和接收请求后都需要做处理,在高并发的情况造成服务器额外的性能损耗。在压测过程中发现单机版几乎没有带来多少额外的性能损耗,因为读本机的yac,然而集群版有一个明显的瓶颈是redis的CPU,在高QPS下,读写redis会对redis的cpu造成较高的占用。


3.1 压测结果


对于集群版,我们压测了其它部门的基于zset实现的计数器和使用循环桶以及固定桶的三种计数器内核做了压测,以下三张图分别展示了在压测中370QPS下redis的cpu占用:


图片

图8:zset计数器内核redis cpu占用


图片

图9:循环桶计数器内核redis cpu占用


图片

图10:固定桶计数器内核redis cpu占用


图片

表5:三种计数器内核性能比较


以zset内核为例,固定桶内核CPU占比的计算过程:(9.04% - 2.5%) / (13.95% - 2.5%)  *100% = 57.12%。实际节省了42.88%的redis cpu开销。


3.2 结果分析


这里也验证了本文最开始的担忧,认为zset内核的计数器劣势是有耗时操作zRemRangeByScore,且在更高并发下,随着元素的增更多这个耗时操作也更加明显。后续使用了循环桶内核的计数器,虽然无耗时操作,但每次写入先需要知道现有桶的情况,也增加了redis的性能开销。最终,固定桶内核的计数器虽然在准确度上比以上两者会略低,但在性能上远高于以上两者。


3.3 实际熔断效果


以下是再我们实际使用中某个项目项目的RPC熔断发生时的情况:


图片

图11:实际熔断在流量柱状图展示


发生的步骤如下:

  1. 首先,调用方发现返回的请求httpcode为500,超过10个,比例超过10%;

  2. 接着,熔断器触发状态变化,变为Open,此时请求不再发出,而是执行了降级方法;

  3. 最后,在一个sleep time 3s的周期后,发现请求正常,又恢复了请求。


4 总结


本文最先描述熔断能力对稳定性建设的重要性,随后解释了行业里通用的解决方案hystrix,接着描述了集群版和单机版熔断能力的实现,其中集群版使用了循环桶和固定桶两种计数器内核,最后做了集群版性能压测的比对,并分析结论。


在优化后的集群版熔断,在1000QPS下会占用redis cpu的20%,按照50%的容量来计算,一个redis只能接峰值之和为2500QPS的多个RPC。很明显瓶颈是redis的cpu,所以对redis读写可以进一步的优化。另一个优化的点在接入成本上,大部分逻辑已收敛在该组件中,但Apollo交互,redis,yac资源隔离,event行为需要各项目自行实现,在接入成本上有进一步的压缩空间。


目前这个熔断composer包已经广泛使用在多个项目中,为我们的php生态的项目提供熔断能力。


图片
php · 目录
上一篇PHP生态 Hystrix 实践(一)
继续滑动看下一个
贝壳产品技术
向上滑动看下一个