最常用的限流算法以及如何在http中间件中加入流控

Golang
357
0
0
2022-04-20

[TOC]

最常用的限流算法以及如何在http中间件中加入流控

何为限流?

通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理

说白了就是限制请求数量,或者是在某一段时间内限制总的请求数量

例如秒杀网站,限制22点5分 – 22点10分 秒杀999份产品, 限制放行 5w 个请求,若在该段时间内,请求在第5w以后的请求,直接拒之门外, 也就是我们在进入网站的时候显示,系统繁忙

为什么要限流?

  • 后台服务能力有限,需要限流,否则服务会崩掉
  • 可以根据测试性能去评估限流的设置,例如测试最大连接数,qps数量(每秒钟能处理的最大请求数)
  • 防止爬虫、恶意攻击

例如当系统的访问量突然剧增,大量的请求涌入过来,我们可能会知道会突然有一波高峰,这个时候如果服务器承受不了压力的话,就会崩溃,例如如下几类业务

  • 秒杀业务
  • 各种刷单
  • 微博上的热搜
  • 因为某些原因用户猛增,太过热情
  • 大量用户(可以是恶意的,也可以是正常的用户量请求过多)高频访问服务器,服务器承受能力不足
  • 网页爬虫 等等

限流一般是如何去实现的?

我们在某宝或某东的热门节日上剁手,付款的时候,还急我们怀着焦灼的心等待着排队的人数一个一个下降的时候吗?

我们在疯狂抢购商品,由于点击太快,热情太高,导致多次弹出系统繁忙,请稍后再试,还记得吗?

更有甚者,在流量过大的时候,直接提示拒绝访问的,这些是不是都一一浮现在脑海呢?

根据如上场景,我们的限流思路会是这个样子的:

  • 拒绝请求
  • 设置排队,直到单位请求数趋于正常水平

关于拒绝请求就相对简单粗暴,对于设置排队就会有多种排队方式了,咱们继续聊

除了限流还有什么方式可以解决或者缓解这种突然大量请求的情况呢?

还有熔断,降级,都可以有效的解决这样的问题

那啥是降级?

服务降级,当我们的服务器压力剧增时,为了保证核心模块的高可用,这里指的是我们自身的系统出现了故障而降级有如下2个**常用的解决方式

  • 降低非核心模块的性能
  • 直接关闭不重要的功能,为保障核心模块的功能正常

如图,某网站,当用户请求数猛增,服务器吃不消的时候,就可以选择把评论功能,修改密码等功能关闭,确保支付系统,数据系统等核心功能能够正常运行

哦?那熔断是啥?

与服务降级还是有区别的,这里指的是指依赖的外部接口出现故障的情况下,会设置断绝和外部接口的关系。

服务器A依赖于服务器B的对外接口,在某个时刻服务器B的接口出现异常,响应时间极其的慢,可是此接口会影响到服务器的整个运作,那么这个时候,服务器A就可以在请求服务器B该接口的时候,默认设置返回错误

最常用的限流算法

我们来分享一个最常用的限流算法,大致分为以下 4

  • 固定窗口计数器
  • 滑动窗口计数器
  • 漏桶
  • 令牌桶

固定时间窗口控制

最简单的是 使用计数器来控制,设置固定的时间内,处理固定的请求数

上述图,固定时间窗口来做限制,1 s只能处理2个请求,红色请求则会被直接丢弃

  • 固定每1秒限制同时请求数为2
  • 上述红色部分的请求会被扔掉,扔掉之后 整个服务负荷可能会降低
  • 但是这个会丢掉请求,对于体验不好

滑动窗口计数器算法

能够去平滑一下处理的任务数量。滑动窗口计数器是通过将窗口再细分,并且按照时间滑动,这种算法避免了固定窗口算法带来的双倍突发请求,但时间区间精度越高,算法所需的空间容量越大

  • 将时间划分为多个区间
  • 在每个区间内每有一次请求就讲计数器加1维持一个时间窗口,占据多个区间
  • 每经过一个区间的时间,则抛弃最老的一个区间,并纳入最新的一个区间
  • 若当前的窗口内区间的请求总数和超过了限制数量,则本窗口内的请求都被丢弃

漏桶

为了解决上述红色部分丢掉的问题,引入了 漏桶的方式进行限流,漏桶是有缓存的,有请求就会放到缓存中

漏桶,听起来有点像漏斗的样子,也是一滴一滴的滴下去的

如图,水滴即为请求的事件,如果漏桶可以缓存5000个事件,实际服务器1s处理1000个事件,那么在高峰期的时候,响应时间最多等5秒,但是不能一直是高峰期,否则,一直响应时间都是5s,就会是很慢的时间了,这个时间也是很影响体验的

如果桶满了,还有请求过来的话,则会被直接丢弃,这种做法,还是丢弃了请求

  • 将每个请求看成 水滴, 放入水滴 进行存储
  • 漏桶以固定的速率往外漏水,若桶空了则停止漏水。比如说,1s 漏 1000滴水,正如1s 处理1000个请求
  • 如果漏桶慢了,则多余的水滴也会被直接舍弃

优势

  • 有一定的缓存能力,比上述2种方式会好一些

劣势

  • 桶满的时候若有新请求,仍然会丢掉数据
  • 长时间桶满,则会影响响应速率,这个根据桶的大小来定体验是否ok

令牌桶

通过动态控制令牌的数量,来更好的服务客户端的请求事情,令牌的生成数量和生产速率都是可以灵活控制的

如上,令牌桶和漏桶不同的地方在于

  • 令牌桶可以自己控制生成令牌的速率,例如高峰期就可以多生成一些令牌来满足客户端的需求
  • 还可以缓存数据

若发现一直是出于高峰期,可以考虑扩大令牌桶

优势
  • 令牌桶可以动态的自己控制生成令牌的速率
  • 还可以缓存数据

如何在http middleware加入流控

如何在http 中间件中加入流控呢,目的是限流,每一个请求,都需要经过这个中间件,才有机会向后走,才有机会被处理

type middleWareHandler struct {
    r *httprouter.Router
    l *ConnLimiter
}

func NewMiddleWareHandler(r *httprouter.Router, cc int) http.Handler {
    m := middleWareHandler{}
    m.r = r
    m.l = NewConnLimiter(cc) // 限制数量return m
}

说完令牌桶,我们来说说限流器

限流器

限流器是后台服务中的非常重要的组件

它可以用做啥呢?

  • 限制请求速率
  • 保护服务
  • 限流器的实现方法有很多种,基本上都是基于上述的限流算法来实现的
  • 滑动窗口法
  • Token Bucket(令牌桶)
  • Leaky Bucket(漏桶)

golang标准库中就自带了限流算法的实现,不需要我们自己造轮子

golang.org/x/time/rate,直接用就好了,该限流器是基于Token Bucket(令牌桶)实现的

令牌桶就是我们上面说的桶,里面装令牌,系统会以恒定速率向桶中放令牌

桶满则暂时不放。 用户请求就要向桶里面拿令牌

  • 如果有剩余Token就可以一直取
  • 如果没有剩余令牌,则需要等到系统中被放置了Token才可以往下进行

我们来看看限流器咋用

构造一个限流对象

limiter := NewLimiter(5, 1);
  • 第一个参数是r Limit,这是代表每秒可以向令牌桶中产生多少令牌
  • 第二个参数是b int,这是代表令牌桶的容量大小

也就是说,其构造出的限流器是

  • 令牌桶大小为1
  • 以每秒5个令牌的速率向桶中放置令牌

我们当然也可以使用另外的设置方式,包中也有提供

limit := Every(500 * time.Millisecond);
limiter := NewLimiter(limit, 1);

可以用Every方法来指定向Token桶中放置令牌的间隔,上面代码就表示每500 ms往桶中放一个令牌

也就说,上述代码是1 秒钟,产生2个令牌

Limiter是支持可以调整速率和桶大小的,我们来看看源码

// 改变放入令牌的速率
SetLimit(Limit) 
// 改变令牌桶大小
SetBurst(int) 

我们来写一个案例看看:

package main

import ("context""log""time"

    "golang.org/x/time/rate"
)


func main() {
    l := rate.NewLimiter(1, 2)// limit表示每秒产生token数,buret最多存令牌数
    log.Println(l.Limit(), l.Burst())for i := 0; i < 50; i++ {//这里是阻塞等待的,一直等到取到一个令牌为止
        log.Println("... ... Wait")
        c, _ := context.WithTimeout(context.Background(), time.Second*2)// Wait阻塞等待if err := l.Wait(c); err != nil {
            log.Println("limiter wait error : " + err.Error())}
        log.Println("Wait  ... ... ")

        // Reserve返回等待时间,再去取令牌// 返回需要等待多久才有新的令牌, 这样就可以等待指定时间执行任务
        r := l.Reserve()
        log.Println("reserve time :", r.Delay())

        //判断当前是否可以取到令牌// Allow判断当前是否可以取到令牌
        a := l.Allow()
        log.Println("Allow == ", a)}
}

看到个数的案例,我们可以看到,包里面提供给我们使用的消费方法有3种

img

  • Wait

Wait , 等于 WaitN(ctx,1)

若此时桶内令牌数组不足(小于N),那么Wait方法将会阻塞一段时间,直至令牌满足条件,否则就一直阻塞

若满足条件,则直接返回结果

Wait的context参数。 可以设置超时时间

  • Allow

看看函数原型

func (lim *Limiter) Allow() bool
func (lim *Limiter) AllowN(now time.Time, n int) bool

Allow 等于 AllowN(time.Now(),1), 当前取一个令牌,若满足,则为true,否则 false

AllowN方法 指的是,截止到某一时刻,目前桶中令牌数目是否至少为N个,满足则返回true,同时从桶中消费N个令牌。 反之返回不消费令牌,返回false

  • Reserve

Reserve , 等于 ReserveN(time.Now(), 1)

ReserveN 当调用完成后,无论令牌是否充足,都会返回一个Reservation*对象

我们可以调用该对象的Delay()方法,有如下注意:

该方法返回了需要等待的时间

  • 如果等待时间为0秒,则说明不用等待
  • 若大于0秒,则必须等到等待时间之后,才能向后进行

当然,若不想等待,你可以归还令牌,一个都不能少,调用该对象的Cancel() 方法即可

总结

  • 简单介绍了限流,熔断,服务降级
  • 形象分享了限流的4种算法
  • 介绍了http 中间件接入流控的简单写法
  • 分享了go golang.org/x/time/rate中,限流器的基本使用

好了,本次就到这里,下一次 互联网协议介绍和分享

技术是开放的,我们的心态,更应是开放的。拥抱变化,向阳而生,努力向前行。

我是小魔童哪吒,欢迎点赞关注收藏,下次见~