限流-令牌桶算法

为什么要限流

由于 API 接口无法控制调用方的行为,因此当遇到瞬时请求量激增时,会导致接口占用过多服务器资源,使得其他请求响应速度降低或是超时,更有甚者可能导致服务器宕机。

限流(Ratelimiting)指对应用服务的请求进行限制,例如某一接口的请求限制为 100 个每秒,对超过限制的请求则进行快速失败或丢弃。

限流的作用:

因此,对于公开的接口最好采取限流措施。

限流的行为

限流的行为指的就是在接口的请求数达到限流的条件时要触发的操作,一般可进行以下行为。

  1. 拒绝服务:把多出来的请求拒绝掉

  2. 服务降级:关闭或是把后端服务做降级处理。这样可以让服务有足够的资源来处理更多的请求

  3. 特权请求:资源不够了,我只能把有限的资源分给重要的用户

  4. 延时处理:一般会有一个队列来缓冲大量的请求,这个队列如果满了,那么就只能拒绝用户了,如果这个队列中的任务超时了,也要返回系统繁忙的错误了

  5. 弹性伸缩:用自动化运维的方式对相应的服务做自动化的伸缩

限流对象类型分类

基于请求限流,一般的实现方式有限制总量和限制QPS。限制总量就是限制某个指标的上限,比如抢购某一个商品,放量是10w,那么最多只能卖出10w件。微信的抢红包,群里发一个红包拆分为10个,那么最多只能有10人可以抢到,第十一个人打开就会显示『手慢了,红包派完了』。

限制QPS,也是我们常说的限流方式,只要在接口层级进行,某一个接口只允许1秒只能访问100次,那么它的峰值QPS只能为100。

基于资源限流是基于服务资源的使用情况进行限制,需要定位到服务的关键资源有哪些,并对其进行限制,如限制TCP连接数、线程数、内存使用量等。限制资源更能有效地反映出服务当前的情况。

限流的难点

可以看到每个限流都有个阈值,这个阈值如何定是个难点。

定大了服务器可能顶不住,定小了就“误杀”了,没有资源利用最大化,对用户体验不好。

建议根据业务复杂度,服务器性能,服务器可用资源,真实压测来确定这个阀值

令牌桶算法原理

令牌桶算法是比较常见的限流算法之一,大概描述如下:

1)、所有的请求在处理之前都需要拿到一个可用的令牌才会被处理;

2)、根据限流大小,设置按照一定的速率往桶里添加令牌;

3)、桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃或者拒绝;

4)、请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除;

5)、令牌桶有最低限额,当桶中的令牌达到最低限额的时候,请求处理完之后将不会删除令牌,以此保证足够的限流;

image

Golang 标准库限流器 time/rate 使用介绍

构造一个限流器

我们可以使用以下方法构造一个限流器对象:

limiter := NewLimiter(10, 1);

这里有两个参数:

第一个参数是 r Limit。代表每秒可以向 Token 桶中产生多少 token。Limit 实际上是 float64 的别名。

第二个参数是 b int。b 代表 Token 桶的容量大小。

那么,对于以上例子来说,其构造出的限流器含义为,其令牌桶大小为 1, 以每秒 10 个 Token 的速率向桶中放置 Token。

除了直接指定每秒产生的 Token 个数外,还可以用 Every 方法来指定向 Token 桶中放置 Token 的间隔,例如:

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

以上就表示每 100ms 往桶中放一个 Token。本质上也就是一秒钟产生 10 个。

Limiter 提供了三类方法供用户消费 Token,用户可以每次消费一个 Token,也可以一次性消费多个 Token。 而每种方法代表了当 Token 不足时,各自不同的对应手段。

Wait/WaitN

func (lim *Limiter) Wait(ctx context.Context) (err error)
func (lim *Limiter) WaitN(ctx context.Context, n int) (err error)

Wait 实际上就是 WaitN(ctx,1)。

当使用 Wait 方法消费 Token 时,如果此时桶内 Token 数组不足 (小于 N),那么 Wait 方法将会阻塞一段时间,直至 Token 满足条件。如果充足则直接返回。

这里可以看到,Wait 方法有一个 context 参数。 我们可以设置 context 的 Deadline 或者 Timeout,来决定此次 Wait 的最长时间。

Allow/AllowN

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

Allow 实际上就是 AllowN(time.Now(),1)。

AllowN 方法表示,截止到某一时刻,目前桶中数目是否至少为 n 个,满足则返回 true,同时从桶中消费 n 个 token。 反之返回不消费 Token,false。

通常对应这样的线上场景,如果请求速率过快,就直接丢到某些请求。

Reserve/ReserveN

func (lim *Limiter) Reserve() *Reservation
func (lim *Limiter) ReserveN(now time.Time, n int) *Reservation

Reserve 相当于 ReserveN(time.Now(), 1)。

ReserveN 的用法就相对来说复杂一些,当调用完成后,无论 Token 是否充足,都会返回一个 Reservation * 对象。

你可以调用该对象的 Delay() 方法,该方法返回了需要等待的时间。如果等待时间为 0,则说明不用等待。 必须等到等待时间之后,才能进行接下来的工作。

或者,如果不想等待,可以调用 Cancel() 方法,该方法会将 Token 归还。

举一个简单的例子,我们可以这么使用 Reserve 方法。

r := lim.Reserve()
f !r.OK() {
    // Not allowed to act! Did you remember to set lim.burst to be > 0 ?
    return
}
time.Sleep(r.Delay())
Act() // 执行相关逻辑

动态调整速率

Limiter 支持可以调整速率和桶大小:

func (lim *Limiter) SetLimit(newLimit Limit) //改变放入Token的速率
func (lim *Limiter) SetLimitAt(now time.Time, newLimit Limit)

func (lim *Limiter) SetBurst(newBurst int) // 改变Token桶大小
func (lim *Limiter) SetBurstAt(now time.Time, newBurst int)

有了这两个方法,可以根据现有环境和条件以及我们的需求,动态地改变 Token 桶大小和速率。

获取速率和桶大小

func (lim *Limiter) Limit() Limit // 获取速率
func (lim *Limiter) Burst() int //获取桶容量

限流示例

func main() {
    r := rate.Every(1 * time.Millisecond)
    limit := rate.NewLimiter(r, 10)
    http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
        if limit.Allow() {
            fmt.Printf("请求成功,当前时间:%s\n", time.Now().Format("2006-01-02 15:04:05"))
        } else {
            fmt.Printf("请求成功,但是被限流了。。。\n")
        }
    })

    _ = http.ListenAndServe(":8081", nil)
}