限流器

346 阅读6分钟

使用场景

在某些场景下我们可能需要使用限流器,如接口防刷防爬,限流器可以让某个接口的请求qps最高不会超过某个特定的阈值,这也对后台服务有一定的保护作用,防止突然到来的大波流量压垮服务器,造成服务不可用。

限流方案

现在假设我们有一个动态限流的需求,每个请求到来时,需要根据请求的属性决定限流的具体值,抽象成函数调用为 func handle(limit int) bool {},其中limit代表每秒的请求量。

关于动态限流的场景,这里思考了下可能是有优先级场景,某些请求的优先级较高,在可容忍的前提下尽量多处理,而另外一些请求的优先级较低,可以推迟或者不进行处理。

计数器

我们直接使用一个计数器来存储一段时间内的请求数量。如果发现已经处理的数量超过了限流值,则不处理新的请求,直接返回false。如果没有超过,则让计数器自增。并且返回true,表示可以继续处理该请求。

这里有两个问题。

  • 一个是在并发场景下,对计数器的自增需要加锁,但是限流模块更希望是一个高性能组件,如果每次加锁,势必会造成大量的加锁开销。类似的,在多机场景下,不同节点之间同步计数器的值可能有延迟。
  • 并发对应的解决方法,一般是采用redis存储计数器,并使用incr指令进行自增操作,redis可以天然地避免并发场景的锁问题,同时满足了分布式条件,并且incr指令是原子的,不会有并发问题。
  • 另外一个是,如果使用计数器存储一秒内的请求数量,有可能在(t-0.1s, t), (t, t+0.1)两个时间段内分别有limit个请求到达,根据我们的限流策略,这两批请求在各自的计数器上都是满足条件的,所以不会被限流,但是这样我们就需要在0.2s内处理2*limit的请求,峰值达到了我们设定目标的10倍。
  • 针对瞬时峰值的问题,可以采用滑动窗口方法解决。即不使用一个计数器存储1s内的请求数量,而是将1s划分为n个片段,每个片段维护一个计数器,记录在这个片段时间内到来的请求数量。当新的请求到来时,计算前n个片段的请求数量之和是否超过limit。如果超过则触发限流。这种方案可以做到更加平滑的限流,但是在新的请求到来的时候,需要额外的计算(复杂度为O(n),n为窗口的长度)。

静态限流

在一些情况下,我们的限流值也可以保持不变,比如一个基本稳定的线上系统,它的接口访问量一般不会有特别大的波动,我们可以设置一个静态的值来进行限流。这种情况下,我们可以使用漏桶或者令牌桶策略。

漏桶

如果我们按照和limit对应的速率处理请求,丢弃掉其他请求,也能达到限流的目的。为了实现这种做法,有一种叫做漏桶的方案。我们有一个大小固定的桶用来存放到来的请求,每经过固定的时间,从桶中漏出来一个最早到来的请求,如果请求到来的时候桶满了,则直接认为触发限流并返回。

漏桶算法可以很好地满足限流的要求,但是,由于请求需要在桶中等待处理,对应的请求rt可能非常高(和桶的大小以及限流值相关)。这是漏桶算法的致命缺点。其他的缺点还包括,将请求保存起来需要消耗额外的机器资源,无法处理突发流量等。

令牌桶

我们尝试换一种思路,漏桶方案中,桶中是请求,处理线程定时来取走请求并处理,如果我们把处理线程放到桶中,在请求到来的时候直接拿走处理线程并执行,就可以解决漏桶算法的rt问题。

这个思路和线程池比较接近,区别是,我们并不需要把处理线程预先定义出来(这样可能会有一些存储开销),我们把(存在可用线程)这个状态封装成一个叫令牌的概念,定时向桶中加入令牌,在新的请求到来的时候,直接从桶中获取令牌,如果成功则继续处理,如果失败,说明最近一段时间的请求已经达到了阈值,需要进行限流。

令牌桶并不能保证任意时间段内请求量不超过limit。1s内请求的数量最多可能是limit+n。

时间数组

如果需要实现任意1s内请求量都小于limit,可以采用时间数组方法,开辟一段长度不小于limit的数组,每当请求到来时,检查数组最旧的请求是否和当前请求的时间差超过了1s,如果超过则删除,直到数组中最旧的请求时间和当前时间差在1s以内。然后判断数组内元素数量,如果超过limit则触发限流。否则不触发限流,将当前请求的时间写入数组末尾。

这种方案的优点在于可以保证任意1s内的请求数量不超过limit,并且整体的时间复杂度是O(1),但是需要额外的空间存储时间,并且由于有写操作,在并发的时候需要加锁,因此不推荐使用。

实现

官方实现

golang官方有令牌桶实现的限流组件,golang.org/x/time/rate

请求对限流器的调用有几类方法,

wait(ctx, n)会阻塞直到令牌数满足,消耗令牌并返回,也可能中途ctx超时取消,

Allow(t time.Time, n)会判断截止到t时刻令牌数是否满足,如果满足则消耗对应数量的令牌并返回true,否则返回false并丢弃请求,这种情况一般是线上使用的。

Reserve(t time.Time, n) *Reservation 会返回一个对象,对象的delay方法返回了要拿到对应数量的令牌,需要等待的时间,可以在等待对应时间后执行操作并消耗令牌,也可以调用cancel方法归还令牌。

channel实现

type TokenChannel struct {
   token chan struct{}
   ticker time.Ticker
}

func NewTokenChannel(n int) *TokenChannel {
   ch := make(chan struct{}, n)
   ticker := time.NewTicker(time.Second / time.Duration(n))
   go AddToken(ch, ticker)
   return &TokenChannel{
      token: ch,
   }
}

func (t TokenChannel) Close() {
   close(t.token)
   t.ticker.Stop()
}

func (t TokenChannel) Pass() bool {
   select {
   case <- t.token:
      return true 
   default:
      return false
   }
}

func AddToken(ch chan struct{}, ticker *time.Ticker) {
   for range ticker.C {
      select {
      case ch <- struct{}{}:
      default:
      }
   }
}