10--限流技术学习 | 青训营笔记

146 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 10 天

简介

所谓限流,就是指限制流量请求的频次。它主要是在高并发情况下,用于保护系统的一种策略,主要是避免在流量高峰导致系统崩溃,造成系统不可用的问题。

实现限流常见的算法4种,分别是计数器限流算法、滑动窗口限流算法、漏桶限流算法、令牌桶限流算法。

计数器限流

计数器是一种最简单限流算法,其原理就是:在一段时间间隔内,对请求进行计数,与阀值进行比较判断是否需要限流,一旦到了时间临界点,将计数器清零。

 package main
 
 import (
     "log"
     "sync"
     "time"
 )
 
 type Counter struct {
     rate  int           //计数周期内最多允许的请求数
     begin time.Time     //计数开始时间
     cycle time.Duration //计数周期
     count int           //计数周期内累计收到的请求数
     lock  sync.Mutex
 }
 
 func (l *Counter) Allow() bool {
     l.lock.Lock()
     defer l.lock.Unlock()
     if l.count == l.rate-1 {
         now := time.Now()
         if now.Sub(l.begin) >= l.cycle {
             //速度允许范围内, 重置计数器
             l.Reset(now)
             return true
         } else {
             return false
         }
     } else {
         //没有达到速率限制,计数加1
         l.count++
         return true
     }
 }
 
 func (l *Counter) Set(r int, cycle time.Duration) {
     l.rate = r
     l.begin = time.Now()
     l.cycle = cycle
     l.count = 0
 }
 
 func (l *Counter) Reset(t time.Time) {
     l.begin = t
     l.count = 0
 }
 
 func main() {
     var wg sync.WaitGroup
     var lr Counter
     lr.Set(3, time.Second) // 1s内最多请求3次
     for i := 0; i < 10; i++ {
         wg.Add(1)
         log.Println("创建请求:", i)
         go func(i int) {
           if lr.Allow() {
               log.Println("响应请求:", i)
           }
           wg.Done()
         }(i)
         time.Sleep(200 * time.Millisecond)
     }
     wg.Wait()
 }
  • 一段时间内(不超过时间窗口)系统服务不可用。比如窗口大小为1s,限流大小为100,然后恰好在某个窗口的第1ms来了100个请求,然后第2ms-999ms的请求就都会被拒绝,这段时间用户会感觉系统服务不可用。
  • 计数器算法存在“时间临界点”缺陷。比如每一分钟限制100个请求,可以在00:00:00-00:00:58秒里面都没有请求,在00:00:59瞬间发送100个请求,这个对于计数器算法来是允许的,然后在00:01:00再次发送100个请求,意味着在短短1s内发送了200个请求,如果量更大呢,系统可能会承受不住瞬间流量,导致系统崩溃。

滑动窗口限流

滑动窗口是针对计数器存在的临界点缺陷,所谓 滑动窗口(Sliding window)是一种流量控制技术,这个词出现在 TCP 协议中。滑动窗口把固定时间片进行划分,并且随着时间的流逝,进行移动,固定数量的可以移动的格子,进行计数并判断阀值。

 package utils
  
 import "time"
  
 var LimitQueue map[string][]int64
 var ok bool
  
 //单机时间滑动窗口限流法
 func LimitFreqSingle(queueName string, count uint, timeWindow int64) bool {
     currTime := time.Now().Unix()
     if LimitQueue == nil {
         LimitQueue = make(map[string][]int64)
     }
     if _, ok = LimitQueue[queueName]; !ok {
         LimitQueue[queueName] = make([]int64, 0)
     }
     //队列未满
     if uint(len(LimitQueue[queueName])) < count {
         LimitQueue[queueName] = append(LimitQueue[queueName], currTime)
         return true
     }
     //队列满了,取出最早访问的时间
     earlyTime := LimitQueue[queueName][0]
     //说明最早期的时间还在时间窗口内,还没过期,所以不允许通过
     if currTime-earlyTime <= timeWindow {
         return false
     } else {
         //说明最早期的访问应该过期了,去掉最早期的
         LimitQueue[queueName] = LimitQueue[queueName][1:]
         LimitQueue[queueName] = append(LimitQueue[queueName], currTime)
     }
     return true
 }

当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。

其实计数器就是滑动窗口,只不过只有一个格子而已,所以想让限流做的更精确只需要划分更多的格子就可以了,为了更精确我们也不知道到底该设置多少个格子,格子的数量影响着滑动窗口算法的精度,依然有时间片的概念,无法根本解决临界点问题。

漏桶限流

漏桶算法(Leaky Bucket),漏桶算法其实很简单,往桶中以一定速率流出水,以任意速率流入水,当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。

一个固定容量的桶,有水流进来,也有水流出去。对于流进来的水来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的速率(处理速度),从而达到 流量整形和 流量控制 的效果。

 type LeakyBucket struct {
     rate       float64 //固定每秒出水速率
     capacity   float64 //桶的容量
     water      float64 //桶中当前水量
     lastLeakMs int64   //桶上次漏水时间戳 ms
     lock sync.Mutex
 }
 
 func (l *LeakyBucket) Allow() bool {
     l.lock.Lock()
     defer l.lock.Unlock()
     now := time.Now().UnixNano() / 1e6
     eclipse := float64((now - l.lastLeakMs)) * l.rate / 1000 //先执行漏水
     l.water = l.water - eclipse                              //计算剩余水量
     l.water = math.Max(0, l.water)                           //桶干了
     l.lastLeakMs = now
     if (l.water + 1) < l.capacity {
         // 尝试加水,并且水还未满
         l.water++
         return true
     } else {
         // 水满,拒绝加水
         return false
     }
 }
 
 func (l *LeakyBucket) Set(r, c float64) {
     l.rate = r
     l.capacity = c
     l.water = 0
     l.lastLeakMs = time.Now().UnixNano() / 1e6
 }

漏桶算法有以下特点:

  • 漏桶具有固定容量,出水速率是固定常量(流出请求)
  • 如果桶是空的,则不需流出水滴
  • 可以以任意速率流入水滴到漏桶(流入请求)
  • 如果流入水滴超出了桶的容量,则流入的水滴溢出(新请求被拒绝)

漏桶限制的是常量流出速率(即流出速率是一个固定常量值),所以最大的速率就是出水的速率,不能出现突发流量。但漏桶算法的这种特点,实际上即是它的优点也是缺点。

有时候面对突发流量,我们往往会希望在保持系统稳定的同时,能更快地处理用户请求以提升用户体验,而不是按部就班的佛系工作。

在这种情况下又出现了令牌桶这样的限流算法,它在应对突发流量时,可以比漏桶算法更加激进。

令牌桶限流

令牌桶算法(Token Bucket)是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送。

令牌桶与漏桶的原理类似,只是漏桶是底部匀速处理,而令牌桶则是定速的向桶里塞入令牌,然后请求只有拿到了令牌才会被服务器处理:

  1. 所有的请求在处理之前都需要拿到一个可用的令牌才会被处理;
  2. 根据限流大小,设置按照一定的速率往桶里添加令牌;
  3. 桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃或者拒绝;
  4. 请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除;
  5. 令牌桶有最低限额,当桶中的令牌达到最低限额的时候,请求处理完之后将不会删除令牌,以此保证足够的限流;
 type TokenBucket struct {
     rate         int64 //固定的token放入速率, r/s
     capacity     int64 //桶的容量
     tokens       int64 //桶中当前token数量
     lastTokenSec int64 //桶上次放token的时间戳 s
     lock sync.Mutex
 }
 
 func (l *TokenBucket) Allow() bool {
     l.lock.Lock()
     defer l.lock.Unlock()
     now := time.Now().Unix()
     l.tokens = l.tokens + (now-l.lastTokenSec)*l.rate // 先添加令牌
     if l.tokens > l.capacity {
         l.tokens = l.capacity
     }
     l.lastTokenSec = now
     if l.tokens > 0 {
         // 还有令牌,领取令牌
         l.tokens--
         return true
     } else {
         // 没有令牌,则拒绝
         return false
     }
 }
 
 func (l *TokenBucket) Set(r, c int64) {
     l.rate = r
     l.capacity = c
     l.tokens = 0
     l.lastTokenSec = time.Now().Unix()
 }

总结

经过上述的描述,好像漏桶、令牌桶比时间窗口类算法好多了,那么时间窗口类算法是不是就没啥用了呢?

  • 其实并不是,虽然漏桶、令牌桶对比时间窗口类算法对流量的整形效果更好,但是它们也有各自的缺点,
  • 例如令牌桶,假如系统上线时没有预热,那么可能会出现由于此时桶中还没有令牌,而导致请求被误杀的情况;
  • 而漏桶中由于请求是暂存在桶中的,所以请求什么时候能被处理,则是有延时的,这并不符合互联网业务低延时的要求。

所以令牌桶、漏桶算法更适合阻塞式限流的场景,即后台任务类的限流。

而基于时间窗口的限流则更适合互联网实施业务限流的场景,即能处理快速处理,不能处理及时响应调用方,避免请求出现过长的等待时间。

微服务限流组件

目前市面上比较流行的限流组件主要有:Google Guava提供的限流工具类“RateLimiter”、阿里开源的Sentinel。

其中Google Guava提供的限流工具类“RateLimiter”,是基于令牌桶实现的,并且扩展了算法,支持了预热功能。

而阿里的Sentinel中的匀速限流策略,就是采用了漏桶算法。

单机限流和分布式限流

本质上单机限流和分布式限流的区别其实就在于 “阈值” 存放的位置。

单机限流就上面所说的算法直接在单台服务器上实现就好了,而往往我们的服务是集群部署的。因此需要多台机器协同提供限流功能。

像上述的计数器或者时间窗口的算法,可以将计数器存放至 Tair 或 Redis 等分布式 K-V 存储中。

例如滑动窗口的每个请求的时间记录可以利用 Redis 的 zset 存储,利用ZREMRANGEBYSCORE 删除时间窗口之外的数据,再用 ZCARD计数。

像令牌桶也可以将令牌数量放到 Redis 中。

不过这样的方式等于每一个请求我们都需要去Redis判断一下能不能通过,在性能上有一定的损耗,所以有个优化点就是 「批量」。例如每次取令牌不是一个一取,而是取一批,不够了再去取一批。这样可以减少对 Redis 的请求。

不过要注意一点,批量获取会导致一定范围内的限流误差。比如你取了 10 个此时不用,等下一秒再用,那同一时刻集群机器总处理量可能会超过阈值。

其实「批量」这个优化点太常见了,不论是 MySQL 的批量刷盘,还是 Kafka 消息的批量发送还是分布式 ID 的高性能发号,都包含了「批量」的思想。

当然分布式限流还有一种思想是平分,假设之前单机限流 500,现在集群部署了 5 台,那就让每台继续限流 500 呗,即在总的入口做总的限流限制,然后每台机子再自己实现限流。

限流的难点

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

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