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

🐶 大家好,我是周周,目前是就职于国内某短视频大厂的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##秋招##校招##春招##面试#
全部评论

相关推荐

bg:双非本,一段中小厂6个月测开实习今天发这个帖子主要是想聊一聊我秋招以来的一个发展我是在8月底辞职,打算秋招,可是看网上都说金九银十就想着自己就是一个普通本科生,现在九月份都是一些大神在争抢,所以9月份基本上没投,等到了10月份才开始秋招,可是这个时间好像已经有些晚了,今年秋招开启的格外早,提前到了7,8月份,我十月才开始,官网投了很多公司,没有任何一个面试机会,这个情况一直到了十月底才有了第一个面试,当时没有面试经验,所以不出意外的挂了后续就是漫长的投递,但是毫无例外没有面试,没有办法我只能另辟蹊径开始在BOSS上边投递,然后顺便也根据BOSS上边这个公司名称去浏览器搜索看看有没有官网投递渠道,毕竟官网上投递后还是可以第一时间被HR看到的,然后一直不停投递,一开始第一个星期基本上都是投的正式秋招岗位到了第二个星期才开始实习和正式一起投,到十一月底的时候已经沟通了700➕才有一共1个正式的,5个要提前实习的,3个实习的面试,最后结果是过了1个要提前实习的和2个实习的每次面试我都会复盘,发现这些小公司面试官问的五花八门,有的专问基础,有的专问项目,有的啥都问,不过自己也是看出来了一下门道,就是小公司不像大公司面试官那样能力比较强基本上你简历上边的他都会,然后会根据简历来问,小公司面试官他们更多的是看自己会什么,然后看看你简历上边哪些他也是会的然后来问,经过不断的复盘加上背各种各样面试题,到了11月底12月初才有了1个要提前实习的offer还有2个实习的offer,而且薪资待遇对我来说已经很可观了可是啊,人总是这样得了千钱想万钱,我又开始不满现状,但是此时的我面试能力经过这么多面试和复盘已经很强了,然后在十二月份运气爆棚,被极兔和小鹏补录捞起来面试,还有个百度测开的实习面试,这个时候因为有了offer所以感觉有了底气,面试也很自信,最后结果是全部都过了那个时候我感觉自己真的很厉害,我问了极兔那边的HR像我这样的双非本收到offer的在极兔有多少?他告诉我产研岗90%都是硕士,10%里边基本上都是211,985,想我这样的很少很少,那一刻感觉自己超级牛逼,小鹏就更不用说了,最后也是不出意外选择了小鹏所以我就我个人经历想对和我学历履历差不多的牛友一些建议第一:秋招一定要趁早,真到了9,10月,那个时候可能你投的结果可能还不如7,8,11月,第二:最好先拿小公司实习或者正式练练手,提升一下面试能力,我个人觉得因为小公司问的五花八门所以你会更加横向去提升自己能力,而且大公司其实面试没有那么难,除了一些非常卷的岗位,公司大神比较多会问的很难,一般好点的公司都不会问的那么难,他们也知道都是应届生不会要求那么高第三:当有一定能力后,就是坚持了,对于我们这样的学历,没有特别强的履历情况下,就是要抓住提前批和补录的机会,这个时候各方面不会卡的很严,是我们很好很好的一个机会第四:就是运气也是很重要的一部分,不过这个很难去说什么最后祝各位牛友都能收获自己满意的offer😁😁😁
秋招,不懂就问
点赞 评论 收藏
分享
评论
8
13
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务