为什么需要降级
微服务集群中,调用链路错综复杂,作为服务提供者需要有一种保护自己的机制,防止调用方无脑调用压垮自己,保证自身服务的高可用。
最常见的保护机制莫过于限流机制,使用限流器的前提是必须知道自身的能够处理的最大并发数,一般在上线前通过压测来得到最大并发数,而且日常请求过程中每个接口的限流参数都不一样,同时系统一直在不断的迭代其处理能力往往也会随之变化,每次上线前都需要进行压测然后调整限流参数变得非常繁琐。
那么有没有一种更加简洁的限流机制能实现最大限度的自我保护呢?
什么是自适应降级
自适应降级能非常智能的保护服务自身,根据服务自身的系统负载动态判断是否需要降级。
设计目标:
- 保证系统不被拖垮。
- 在系统稳定的前提下,保持系统的吞吐量。
那么关键就在于如何衡量服务自身的负载呢?
判断高负载主要取决于两个指标:
- cpu 是否过载。
- 最大并发数是否过载。
以上两点同时满足时则说明服务处于高负载状态,则进行自适应降级。
同时也应该注意高并发场景 cpu 负载、并发数往往波动比较大,从数据上我们称这种现象为毛刺,毛刺现象可能会导致系统一直在频繁的进行自动降级操作,所以我们一般获取一段时间内的指标均值来使指标更加平滑。实现上可以采用准确的记录一段时间内的指标然后直接计算平均值,但是需要占用一定的系统资源。
统计学上有一种算法:滑动平均(exponential moving average),可以用来估算变量的局部均值,使得变量的更新与历史一段时间的历史取值有关,无需记录所有的历史局部变量就可以实现平均值估算,非常节省宝贵的服务器资源。
滑动平均算法原理参考这篇文章讲的非常清楚。
变量 V 在 t 时刻记为 Vt,θt 为变量 V 在 t 时刻的取值,即在不使用滑动平均模型时 Vt=θt,在使用滑动平均模型后,Vt 的更新公式如下:
Vt=β⋅Vt−1+(1−β)⋅θt
- β = 0 时 Vt = θt
- β = 0.9 时,大致相当于过去 10 个 θt 值的平均
- β = 0.99 时,大致相当于过去 100 个 θt 值的平均
代码实现
接下来我们来看下 go-zero 自适应降级的代码实现。
core/load/adaptiveshedder.go
自适应降级接口定义:
1 | go复制代码 //回调函数 |
接口定义非常精简意味使用起来其实非常简单,对外暴露一个`Allow()(Promise,error)。
go-zero 使用示例:
业务中只需调该方法判断是否降级,如果被降级则直接结束流程,否则执行业务最后使用返回值 Promise 根据执行结果回调结果即可。
1 | go复制代码func UnarySheddingInterceptor(shedder load.Shedder, metrics *stat.Metrics) grpc.UnaryServerInterceptor { |
接口实现类定义 :
主要包含三类属性
- cpu 负载阈值:超过此值意味着 cpu 处于高负载状态。
- 冷却期:假如服务之前被降级过,那么将进入冷却期,目的在于防止降级过程中负载还未降下来立马加压导致来回抖动。因为降低负载需要一定的时间,处于冷却期内应该继续检查并发数是否超过限制,超过限制则继续丢弃请求。
- 并发数:当前正在处理的并发数,当前正在处理的并发平均数,以及最近一段内的请求数与响应时间,目的是为了计算当前正在处理的并发数是否大于系统可承载的最大并发数。
1 | go复制代码 //option参数模式 |
自适应降级构造器:
1 | go复制代码func NewAdaptiveShedder(opts ...ShedderOption) Shedder { |
降级检查 Allow()
:
检查当前请求是否应该被丢弃,被丢弃业务侧需要直接中断请求保护服务,也意味着降级生效同时进入冷却期。如果放行则返回 promise,等待业务侧执行回调函数执行指标统计。
1 | go复制代码// 降级检查 |
检查是否应该被丢弃 shouldDrop()
:
1 | go复制代码//请求是否应该被丢弃 |
cpu 阈值检查 systemOverloaded()
:
cpu 负载值计算算法采用的滑动平均算法,防止毛刺现象。每隔 250ms 采样一次 β 为 0.95,大概相当于历史 20 次 cpu 负载的平均值,时间周期约为 5s。
1 | go复制代码//cpu 是否过载 |
检查是否处于冷却期 stillHot
:
判断当前系统是否处于冷却期,如果处于冷却期内,应该继续尝试检查是否丢弃请求。主要是防止系统在过载恢复过程中负载还未降下来立,马又增加压力导致来回抖动,此时应该尝试继续丢弃请求。
1 | go复制代码func (as *adaptiveShedder) stillHot() bool { |
检查当前正在处理的并发数highThru()
:
一旦 当前处理的并发数 > 并发数承载上限 则进入降级状态。
这里为什么要加锁呢?因为自适应降级器时全局在使用的,为了保证并发数平均值正确性。
为什么这里要加自旋锁呢?因为并发处理过程中,可以不阻塞其他的 goroutine 执行任务,采用无锁并发提高性能。
1 | go复制代码func (as *adaptiveShedder) highThru() bool { |
如何得到正在处理的并发数与平均并发数呢?
当前正在的处理并发数统计其实非常简单,每次允许请求时并发数 +1,请求完成后 通过 promise 对象回调-1 即可,并利用滑动平均算法求解平均并发数即可。
1 | go复制代码type promise struct { |
得到了当前的系统数还不够 ,我们还需要知道当前系统能够处理并发数的上限,即最大并发数。
请求通过数与响应时间都是通过滑动窗口来实现的,关于滑动窗口的实现可以参考 自适应熔断器
那篇文章。
当前系统的最大并发数 = 窗口单位时间内的最大通过数量 * 窗口单位时间内的最小响应时间。
1 | go复制代码//计算每秒系统的最大并发数 |
参考资料
本文转载自: 掘金