面试官:聊聊几种常见的限流算法?

🐶 大家好,我是周周,目前是就职于国内某短视频大厂的BUG攻城狮一枚。🤺 如果文章对你有帮助,<font color=#FF7F50>记得关注、点赞、收藏,一键三连哦</font>,你的支持将成为我最大的动力。

1 概述

1.1 什么是限流

限流其实在我们的生活中很常见,例如节假日的热门景区就会通过限售的方式限制景区的容纳游客数量。而在我们的系统服务中,也往往会采取一定措施限制到达系统的并发请求数,使得系统能够正常地处理部分用户的请求,从而保证系统的稳定性。

当然这样的举措不可避免的会造成用户的请求变慢甚至被拒的情况,影响用户体验。因此,限流需要在用户体验和系统稳定性之间做一个平衡。

1.2 什么时候限流

在我们的业务开发中,以下场景往往需要通过限流以保证我们系统的稳定性:

  1. 抢购、大促活动或者热点新闻事件等业务场景往往需要限制最大的请求访问量,防止用户流量的突增影响业务的进行;
  2. 我们对外的数据或文件上传等接口,容易受到爬虫、DDos 等手段的非正常流量攻击,故需要最大恶意的加固服务防止攻击者;
  3. 系统调用的第三方服务时应注意下游服务的稳定性,通过限制自身的请求量以保护下游服务。

2 限流算法

在我们使用较多的限流算法中,大体上可以分为一下五类**:简单计数器**、固定窗口计数器滑动窗口计数器漏桶令牌桶

2.1 简单计数器

简单计数器限流顾名思义,应该是最简单的限流算法了 。在我们的服务中维护一下全局计数器,每当接收一个请求时计数器加一,当请求处理完成后,计数器减一。

public boolean tryAcquire() {
  if (counter < threshold) {
    counter++;
    return true;
  }
  return false;
}

public boolean tryRelease() {
  if (counter > 0) {
    counter--;
    return true;
  }
  return false;
}

根据计数器存放位置我们又可以分为单机限流(计数器存本地内存)和分布式限流(计数器集中存储,如Redis)。

2.2 固定窗口

固定窗口限流其实也算计数器限流算法的一种,相比简单计数器多了一个时间窗口的概念,计数器每经过一个该时间窗口,就会被重置。

public boolean tryAcquire() {
  // 获取当前时间
  long now = System.currentTimeMillis();
  
  // 如果过了时间窗口,计数器清零
  if (now - lastAcquireTime > timeWindow) {
    counter = 0;
    lastAcquireTime = now;
  }
 
  // 如果计数器的值小于阈值,允许请求
  if (counter < threshold) {
    counter++;
    return true;
  }
  
  return false;
}

固定窗口看似完美,但有一个致命缺点:假设我们的服务每秒最大可以处理 100 个请求,但在 0.75s 时来了 100 个请求,1.25s 时又来了 100 个请求,根据上面的实现不难看出这两百个请求都不会被固定窗口限流算法拦截,此时在 0.5s 至 1.5s 这个 1s 的时间段内服务接收到了 200 个请求,是远大于服务处理能力的。

2.3 滑动窗口

为了解决固定窗口带来的问题,人们又引入了滑动窗口的概念,通过滑动窗口限流算法,可以保证在任意时间窗口内请求数量都不会超过阈值。

public boolean tryAcquire(String key, int period, int maxCount) {
    long nowTimes = System.currentTimeMillis();
    // 删除非时间段内的请求数据(清除老访问数据,比如 period=60 时,标识清除 60s 以前的请求记录)
    JEDIS_CLIENT.zremrangeByScore(key, 0, nowTimes - period * 1000);
    long currCount = JEDIS_CLIENT.zcard(key); // 当前请求次数
    if (currCount >= maxCount) {
        // 超过最大请求次数,执行限流
        return false;
    }
    // 未达到最大请求数,正常通过
    JEDIS_CLIENT.zadd(key, nowTimes, "" + nowTimes); // 请求记录 +1
    return true;
}

2.3 漏桶

漏桶算法中的漏桶是一个形象的比喻,我们把服务比作是一个漏桶,请求比作是水滴,水滴持续不断地滴入桶中,底部再定速流出。如果水滴滴入的速率大于流出的速率,当桶中的水满时就会溢出。

在漏铜算法中无论请求量是多少,请求速率有多大,都按照固定的速率流出,服务定速地从桶内拿请求并处理。这样经过漏桶的过滤系统可以平滑地处理请求,但无法应对突增的大流量请求,无法满足用户请求需要得到快速处理的需求场景。

2.4 令牌桶

令牌桶算法同样是实现限流是一种常见的思路,相比于漏桶定速地处理请求,令牌桶则是定速地往桶里生成令牌,请求只有拿到了令牌才能被服务器处理。当然,令牌桶的大小也是有限制的,假设桶里的令牌满了之后,之后生成的令牌会被丢弃。

同时在现有的工具 Guava 和 Sentinel 的实现中都有冷启动、预热的方式,为了避免在流量激增的同时把系统打挂,会有一个服务预热的过程,动态的调整生产令牌的速率。

3 使用场景

令牌桶可以应对突发的流量激增,最大限度的利用服务资源,并且业内有较为成熟的工具实现,故是一个万金油方案。

但其余限流算法并非一无是处,漏桶有一个稳定的处理请求速率,非常适合对下游服务调用时的限流,以保护第三方服务。(我们熟知的 Nginx 服务器即采用此算法进行限流)

4 RateLimiter源码

上面我们提到令牌桶需要生产者按照固定速率往桶中生成令牌,如果使用一个线程后台生产的话,一个 RateLimiter 并未太大问题,但如果我们的每个接口都有不同的限流策略,所需要的线程数也线性增加,这肯定不是一个好的实现。

Google 当然也是想到了这个问题,在 RateLimiter 的实现中,只有在每次令牌获取时才进行计算令牌是否足够的。它通过存储的下一个令牌生成的时间,和当前获取令牌的时间差,再结合阈值,去计算令牌是否足够,同时再记录下一个令牌的生成时间以便下一次调用。

void resync(long nowMicros) { // 当前微秒时间
    // 当前时间是否大于下一个令牌生成时间
    if (nowMicros > this.nextFreeTicketMicros) { 
        // 可生成的令牌数 newPermits = (当前时间 - 下一个令牌生成时间)/ 令牌生成时间间隔。
        // 如果 QPS 为2,这里的 coolDownIntervalMicros 就是 500000.0 微秒(500ms)
        double newPermits = (double)(nowMicros - this.nextFreeTicketMicros) / this.coolDownIntervalMicros();
        // 更新令牌库存 storedPermits。
        this.storedPermits = Math.min(this.maxPermits, this.storedPermits + newPermits);
        // 更新下一个令牌生成时间 nextFreeTicketMicros
        this.nextFreeTicketMicros = nowMicros;
    }
}

下面的实现也比较关键,RateLimiter 不会阻塞已经获取到令牌的线程,如果此时令牌不够就会把不够的令牌产生所需要的时间加到下一次获取令牌的线程上,因此严格保证了一段时间内的平均速率。

final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
      // 设置下一次获取令牌的时间为now:nextFreeTicketMicros+新产生的令牌放到桶里
    resync(nowMicros);
    long returnValue = nextFreeTicketMicros;
      // 取出需要的令牌数
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
        // 欠的令牌数
    double freshPermits = requiredPermits - storedPermitsToSpend;
        // 欠的令牌数产生需要的时间
    long waitMicros =
        storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
            + (long) (freshPermits * stableIntervalMicros);
        // 下一次获取令牌的时间顺延
    this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
    // 取出需要的令牌数
        this.storedPermits -= storedPermitsToSpend;
    return returnValue;
}

5 总结

总的来说,不同的限流算法没有绝对的优劣之分,我们需要根据具体业务场景选择更加适合的限流策略。

ref5种限流算法,7种限流方式,挡住突发流量?

---end---

PS:《后端面试小册子》已整理成册,目前共十三章节,总计约二十万字,******************************(最新系列文章也会在此陆续更新)。***************************。文中所有内容都会在 Github 开源,项目地址 csnotes,如文中存在错误,欢迎指出。如果觉得文章还对你有所帮助,赶紧点个免费的 star 支持一下吧!

#Java##秋招##校招##春招##面试#
全部评论

相关推荐

6 11 评论
分享
牛客网
牛客企业服务