限流算法与实现

351 阅读5分钟

1: 基本概念

服务治理的三大利器:降级,熔断,限流

  • 降级:
    降级也就是服务降级,当我们的服务器压力剧增为了保证核心功能的可用性 ,而选择性的降低一些功能的可用性,或者直接关闭该功能。这就是典型的丢车保帅了。 就比如贴吧类型的网站,当服务器吃不消的时候,可以选择把发帖功能关闭,注册功能关闭,改密码,改头像这些都关了,为了确保登录和浏览帖子这种核心的功能。
    一般而言都会建立一个独立的降级系统,可以灵活且批量的配置服务器的降级功能。当然也有用代码自动降级的,例如接口超时降级、失败重试多次降级等。具体失败几次,超时设置多久,由业务等其他因素决定.
  • 熔断
    降级一般而言指的是我们自身的系统出现了故障而降级。而熔断一般是指依赖的外部接口出现故障的情况断绝和外部接口的关系。
    例如A服务里面的一个功能依赖B服务,这时候B服务出问题了,返回的很慢。这种情况可能会因为这么一个功能而拖慢了A服务里面的所有功能,因此我们这时候就需要熔断!即当发现A要调用这B时就直接返回错误,就不去请求B了,防止服务出现雪崩。
  • 限流
    限流就是对服务请求速率作出限制,当请求速率超出系统的承受能力时,超出的请求可直接返回默认值或抛错,防止异常流量把服务打挂。

2: 常见的限流算法和实现

2.1 计数器算法

使用计数器来计算周期内的请求次数,当请求次数达到指定阈值,触发限流策略。下一个周期开始时计数器清零,重新进行计算。 对单实例限流可以直接创建一个全局变量,来计算请求次数,通过相邻两次请求的时间间隔来判断是否要重新计数,具体代码如下

// 计数器
type countLimiter struct {
   // 计数周期(s)
   interval      int64
   // 最后一次访问对应的计数
   lastCount     int64
   // 时间周期(interval)最大访问次数
   limit         int64
   // 最后一次访问对应的时间戳
   lastTimeStamp int64
   lock          sync.Mutex
}

func (cl *countLimiter) Acquire() bool {
   cl.lock.Lock()
   defer cl.lock.Unlock()

   currentStamp := time.Now().Unix()
   if currentStamp < cl.lastTimeStamp+cl.interval {
      cl.lastCount++
      if cl.lastCount > cl.limit {
         return false
      }
      return true
   }

   cl.lastCount = 1
   cl.lastTimeStamp = currentStamp
   return true
}

可以利用 redis 的 incr 命令实现限流的作用, redis 本身的特性保证了计数的线程安全, 具体代码如下

type RedisLimiter struct {
   redis goredis.Client
   // qps
   limit int64
}

func (rl *RedisLimiter) Acquire() bool {
   // 每秒对应一个 key
   key := "ip" + string(time.Now().Unix())
   currentCount, _ := rl.redis.Get(key).Int64()
   if currentCount > rl.limit {
      return false
   }
   rl.redis.Incr(key)
   rl.redis.Expire(key, 10)
   return true
}

计数算法实现简单,计算高效。但是算法存在一个临界问题:假设服务的 qps 为 100,0.0~0.5s 请求量为 0,0.5~1.0s 请求量为 100,1.0~1.5s 请求量仍可为 100,则 0.5~1.5s 的服务处理的请求量为 200, 超出了服务的承载能力。

2.2 漏桶算法

漏桶算法可以有效解决上述存在的临界问题,保证服务按照指定速率处理请求。不管服务调用方多么不稳定,通过漏桶算法进行限流,每固定时间处理一次请求。因为处理的速度是固定的,请求进来的速度是未知的,可能突然进来很多请求,没来得及处理的请求就先放在桶里,既然是个桶,肯定是有容量上限,如果桶满了,那么新进来的请求就丢弃。具体算法如下所示:

// TODO 丢弃请求
func NewLeakBucketLimiter(limit int64) *LeakBucketLimiter {
   return &LeakBucketLimiter{
      limit:   limit,
      LastReq: time.Now(),
   }
}

func (l *LeakBucketLimiter) Acquire() bool {
   l.lock.Lock()
   l.lock.Unlock()
   interval := time.Second / time.Duration(l.limit)
   now := time.Now()
   sleepFor := interval - now.Sub(l.LastReq)

   if sleepFor > 0 {
      time.Sleep(sleepFor)
      l.LastReq = now.Add(sleepFor)
   } else {
      l.LastReq = now
   }
   return true
}

漏桶算法保证固定速率处理请求,即使网络中资源不发生拥塞,漏桶算法也不能提升服务请求速率。因此,漏桶算法对于突发请求流量缺乏处理效率。

2.3 令牌桶算法

令牌桶算法算是对漏桶算法的一种改进,即允许一定速率计算请求,还允许一定程度的突发调用。如下图所示:令牌工厂以固定速率生产令牌并放入令牌桶,当请求过来时去令牌桶取令牌,如果令牌桶有令牌即处理该请求,否则丢弃。 令牌桶算法有两种实现方案。一:起一个单独线程来向令牌桶放令牌;二:在取令牌时计算上次取令牌到这个取令牌之间应产生多少令牌,更新令牌数量。

// 令牌桶算法
type TokenLimit struct {
   limit  int64
   tokens int64
   ticker *time.Ticker
}

// 更新 tokens 没有加锁,存在一定误差
func NewTokenLimit(limit int64) *TokenLimit {
   interval := int64(time.Second/time.Nanosecond) / limit
   limiter := &TokenLimit{
      limit:  limit,
      tokens: 1,
      ticker: time.NewTicker(time.Duration(interval)),
   }
   go limiter.StartTicker()
   return limiter
}

// 定时器添加 token
func (l *TokenLimit) StartTicker() {
   ch := l.ticker.C
   for range ch {
      if atomic.LoadInt64(&l.tokens) <= l.limit {
         atomic.AddInt64(&l.tokens, 1)
      } else {
         atomic.StoreInt64(&l.tokens, l.limit)
      }
   }
}

func (l *TokenLimit) Acquire() bool {
   if atomic.LoadInt64(&l.tokens) <= 0 {
      return false
   }
   return atomic.AddInt64(&l.tokens, -1) >= 0
}

// 每次取 token 时计算
type TokenLimitV2 struct {
   mu         sync.Mutex
   createTime time.Time
   limit      int64
   interval   time.Duration
   // 最后一次更新时对应的 token
   lastTokens int64
   // 最后一次更新时对应的 tick
   lastTick int64
}

func NewTokenLimitV2(limit int64) *TokenLimitV2 {
   interval := time.Duration(int64(time.Second/time.Nanosecond) / limit)
   return &TokenLimitV2{
      createTime: time.Now(),
      limit:      limit,
      interval:   interval,
      lastTokens: 1,
      lastTick:   0,
   }
}

func (tl *TokenLimitV2) Acquire() bool {
   tl.mu.Lock()
   defer tl.mu.Unlock()
   tl.adjustTokens(time.Now())
   if tl.lastTokens <= 0 {
      return false
   }
   tl.lastTokens -= 1
   return true
}

func (tl *TokenLimitV2) adjustTokens(now time.Time) {
   if tl.lastTokens >= tl.limit {
      return
   }
   curTick := int64(now.Sub(tl.createTime) / tl.interval)
   intervalTick := curTick - tl.lastTick
   // 每个周期产生一个 token
   tl.lastTokens += intervalTick * 1
   if tl.lastTokens > tl.limit {
      tl.lastTokens = tl.limit
   }
   tl.lastTick = curTick
}

参考:

juejin.im/post/684490…
juejin.im/entry/68449…
redis.io/commands/in…
github.com/didip/tollb…
github.com/uber-go/rat…
github.com/ulule/limit…
github.com/juju/rateli…