为什么需要进行系统过载保护
随着业务的发展流量可能会不断增长,系统资源承担的压力也随着增大,或者由于某次推广活动,系统遭受 DDOS 攻击等原因带来的异常流量增长。这时我们希望系统在一定的压力之下通过拒绝部分流量达到系统部分可用,通常我们会通过对系统或接口进行限流以达到保护系统的目的,接下来将会介绍常见系统限流算法或手段。
限流算法
常见的限流算法有固定窗口、滑动窗口、漏斗、令牌桶,这几种算法都是固定配额的,通常是写死一个固定的值。这样导致无法合理使用系统的资源,比如系统硬件升级、系统水平扩容等因素带来的服务能力提升,但由于QPS配置写死导致不得不修改配置重新发版,或者有时候干脆忘了或者不修改配置。所以更合理的做法是能根据系统硬件能力或者集群整体能力进行限流,所以就有了系统过载保护 BBR 算法和分布式限流。本文主要介绍一下 BBR 算法。
BBR 算法
bbr 原本是 tcp 中用于拥塞控制的算法,通过对网络带宽、响应时延维度进行评估建模,从而对网络进行流量控制。sentinel 正是受 bbr 算法的启发开发了 bbr 限流算法。kratos 框架也实现了 bbr 算法,接下来介绍一下 kratos bbr 算法的实现。
指标
bbr 算法是根据对系统指标进行收集从而评估系统整体状况的算法,我们可以通过如下指标对系统进行评估
- CPU:使用一个独立的线程或协程对系统进行采样,比如每100ms统计一次,使用简单滑动平均去除峰值的影响。
- Inflight:当前系统正在服务的请求
- Pass&RT: 最近5s,pass 为每100ms采样窗口内成功请求的数量,rt 为单个采样窗口中平均响应时间
过载保护
- 使用 CPU 的滑动均值(CPU > 800)作为启发阈值,一旦触发进入到过载保护阶段,算法为:(pass* rt) < inflight
- 限流效果生效后,CPU 会在临界值(800)附近抖动,如果不使用冷却时间,那么一个短时间的 CPU 下降就可能导致大量请求被放行,严重时会打满 CPU。
- 在冷却时间后,重新判断阈值(CPU > 800 ),是否持续进入过载保护
冷却时间是为了防止系统负载降低后马上放行导致大量流量重新涌入,系统负载上升,如此反复导致的系统抖动,所以冷却一段时间可以给系统一段缓冲期。
实现
配置
var (
cpu int64
// decay 加权平均因子
decay = 0.95
initTime = time.Now()
defaultConf = &Config{
// Window 窗口大小
Window: time.Second * 10,
// WinBucket 桶树木
WinBucket: 100,
// CPUThreshold CPU 配额
CPUThreshold: 800,
}
)
CPU 加权平均统计
// cpu = cpuᵗ⁻¹ * decay + cpuᵗ * (1 - decay)
// 基于加权平均统计实现的 CPU 负载统计算法,可以有效避免 CPU 抖动带来的统计失真
func cpuproc() {
ticker := time.NewTicker(time.Millisecond * 250)
defer func() {
ticker.Stop()
if err := recover(); err != nil {
log.Error("rate.limit.cpuproc() err(%+v)", err)
go cpuproc()
}
}()
// EMA algorithm: https://blog.csdn.net/m0_38106113/article/details/81542863
for range ticker.C {
stat := &cpustat.Stat{}
cpustat.ReadStat(stat)
prevCpu := atomic.LoadInt64(&cpu)
curCpu := int64(float64(prevCpu)*decay + float64(stat.Usage)*(1.0-decay))
atomic.StoreInt64(&cpu, curCpu)
}
}
Allow 是否限流
// Allow checks all inbound traffic.
// Once overload is detected, it raises ecode.LimitExceed error.
func (l *BBR) Allow(ctx context.Context, opts ...limit.AllowOption) (func(info limit.DoneInfo), error) {
allowOpts := limit.DefaultAllowOpts()
for _, opt := range opts {
opt.Apply(&allowOpts)
}
if l.shouldDrop() {
return nil, ecode.LimitExceed
}
// 新增处理中计数
atomic.AddInt64(&l.inFlight, 1)
stime := time.Since(initTime)
// 返回一个函数,用于调用法结束后使用统计程序执行时间并减少 inflight 计数
return func(do limit.DoneInfo) {
rt := int64((time.Since(initTime) - stime) / time.Millisecond)
// // 向 RollingCounter 中添加 rt(这是把 RollingCounter 当做累加器使用)
l.rtStat.Add(rt)
atomic.AddInt64(&l.inFlight, -1)
switch do.Op {
case limit.Success:
l.passStat.Add(1)
return
default:
return
}
}, nil
}
MaxPass 计算
MaxPass
表示最近 5s 内,单个采样窗口(window)中最大的请求数。换言之,就是找出当前时间戳的滑动窗口的所有桶中,最大的请求计数器的值(单个桶)
func (l *BBR) maxPASS() int64 {
passCache := l.maxPASSCache.Load()
if passCache != nil {
ps := passCache.(*CounterCache)
// 当前的时间跨度未超过一个单位
if l.timespan(ps.time) < 1 {
return ps.val
}
}
rawMaxPass := int64(l.passStat.Reduce(func(iterator metric.Iterator) float64 {
var result = 1.0
for i := 1; iterator.Next() && i < l.conf.WinBucket; i++ {
bucket := iterator.Bucket()
count := 0.0
// 叠加 bucket.Points
for _, p := range bucket.Points {
count += p
}
result = math.Max(result, count)
}
return result
}))
if rawMaxPass == 0 {
rawMaxPass = 1
}
// 存储在 rawMaxPASS 中并返回
l.maxPASSCache.Store(&CounterCache{
val: rawMaxPass,
time: time.Now(),
})
return rawMaxPass
}
MinRt 计算
MinRt 表示最近 5s 内,单个采样窗口中最小的响应时间。windows 表示一秒内采样窗口的数量,默认配置中是 5s 50 个采样,那么 windows 的值为 10。
func (l *BBR) minRT() int64 {
rtCache := l.minRtCache.Load()
if rtCache != nil {
rc := rtCache.(*CounterCache)
if l.timespan(rc.time) < 1 {
return rc.val
}
}
rawMinRT := int64(math.Ceil(l.rtStat.Reduce(func(iterator metric.Iterator) float64 {
var result = math.MaxFloat64
for i := 1; iterator.Next() && i < l.conf.WinBucket; i++ {
bucket := iterator.Bucket()
if len(bucket.Points) == 0 {
continue
}
total := 0.0
for _, p := range bucket.Points {
total += p
}
avg := total / float64(bucket.Count)
result = math.Min(result, avg)
}
return result
})))
if rawMinRT <= 0 {
rawMinRT = 1
}
l.minRtCache.Store(&CounterCache{
val: rawMinRT,
time: time.Now(),
})
return rawMinRT
}
MaxFlight
maxFlight
代表什么?其实就是使用利特尔法则计算出来的系统最大吞吐量(系统的最大负载),即计算过去一段时间系统的最大负载是多少
func (l *BBR) maxFlight() int64 {
return int64(math.Floor(float64(l.maxPASS()*l.minRT()*l.winBucketPerSec)/1000.0 + 0.5))
}
限流公式
在 Kratos 的限流算法中,判断是否丢弃当前请求的算法如下
( cpu>800\(OR\)(Now−PrevDrop)<1s) AND (MaxPass∗MinRt∗windows/1000<InFlight
-
(Now - PrevDrop) < 1s
:表示只要触发过限流(1
次),那么在1s
内都会去做限流的判 -
(Now - PrevDrop) < 1s
:表示只要触发过限流(1
次),那么在1s
内都会去做限流的判定,这是为了避免反复出现限流恢复导致请求时间和系统负载产生大量毛刺 -
(MaxPass * MinRt * windows / 1000) < InFlight
:判断最大负载是否小于当前实际的负载(即过载了)InFlight
:表示当前系统中有多少请求(MaxPass * MinRt * windows / 1000)
:表示过去一段时间的最大负载MaxPass
:表示最近5s
内,单个采样窗口中最大的请求数MinRt
:表示最近5s
内,单个采样窗口中最小的响应时间windows
:表示一秒内采样窗口的数量,默认配置中是5s
50
个采样,那么windows
的值为10
shouldDrop
是否应该丢弃请求,可以配合以上公式理解
func (l *BBR) shouldDrop() bool {
if l.cpu() < l.conf.CPUThreshold {
prevDrop, _ := l.prevDrop.Load().(time.Duration)
if prevDrop == 0 {
return false
}
if time.Since(initTime)-prevDrop <= time.Second {
inFlight := atomic.LoadInt64(&l.inFlight)
return inFlight > 1 && inFlight > l.maxFlight()
}
l.prevDrop.Store(time.Duration(0))
return false
}
inFlight := atomic.LoadInt64(&l.inFlight)
drop := inFlight > 1 && inFlight > l.maxFlight()
if drop {
prevDrop, _ := l.prevDrop.Load().(time.Duration)
if prevDrop != 0 {
return drop
}
l.prevDrop.Store(time.Since(initTime))
}
return drop
}
shouldDrop
方法的主要逻辑如下
-
首先检查 CPU 的使用率(通过EWMA算法计算出)是否达到了阈值
80%
-
如果未超过CPU
80%
阈值,则判断一下上次触发限流到现在是否在1s
以内- 如果在
1s
内,判断当前并发请求量inFlight
是否超过系统的最大吞吐量maxFlight()
,如果超过了就需要丢弃 - 如果不在
1s
内或者是请求数量已经降下来了,那么把prevDrop
清零然后返回false
,放行请求
- 如果在
-
如果超过CPU
80%
阈值,则判断一下当前并发请求量inFlight
是否超过系统的最大吞吐量maxFlight()
-
如果超过了,则设置丢弃时间
prevDrop
,返回true
需要丢弃请求 -
如果没超过直接返回
false
,放行请求
-
Github:wang1309