限流

一、业务场景

在秒杀活动中,总计有 100 个特价商品,且每个商品的价格都非常低,活动计划于 10 月 10 日晚上 10 点 10 分 0 秒开启。

当时,我们的服务器架构图如下,所有客户端的 API 请求先进入 1 个 Nginx 层,再由 Nginx 层转发至网关层(Java,使用 Spring Cloud Zuul),最后转发至后台服务1(Java)。

下载限流 java java限流框架_spring

预测到秒杀开始那一瞬间会有海量用户涌入,致使系统无法处理所有用户请求,为保障服务器承受住大流量,我们只能通过限流的方式将部分流量放入后台服务中。

那什么是限流呢?一说到限流,有些人总喜欢把它与熔断混在一起谈,其实它们是有区别的。

熔断一般发生在服务调用方,比如服务 A 需要调用服务 B,调用几次后发现服务 B 出现了问题且无法再调用,此时服务 A 必须立马触发熔断,在一段时间内不再调用服务 B。

限流一般发生在服务被调用方,且主要在网关层做限流操作。比如一个电商网站 1 秒钟内后台服务只能处理 10 万个请求,这时突然涌入了 100 万个请求,我们该怎么办?此时,我们可以把 90% 的请求全部抛弃且不做处理,然后专心处理 10% 的请求,以此保证至少 10 万人能正常操作。(这个比例看起来有点夸张,但是在实际秒杀场景中,就算我们把 99% 的流量抛弃掉都不要紧。)

再回到这一讲的业务场景中,这次我们的诉求是在某个层级通过限流的方式将秒杀活动的交易 TPS 控制在 100 笔/秒(因为秒杀活动总计 100 个库存,也就是说最终的交易只有 100 笔,而我们希望 100 笔交易在 1 秒内完成),此时我们应该怎么做呢?这就需要使用到限流的一些常用算法了。

二、限流算法

据我了解,市面上关于限流的算法总共分为固定时间窗口计数、滑动时间窗口计数、漏桶、令牌桶这 4 种,下面我们分别进行说明。

(一)固定时间窗口计数算法

假设我们的诉求是每 5 秒钟,后台服务处理 500 个请求(以 5 秒为单位方便举例),那么每 5 秒钟我们就需要一个时间窗口来统计请求,为了方便理解,我们梳理了如下一个业务表。

下载限流 java java限流框架_数据库_02

此时固定时间窗口计算算法看起来可以满足我们的诉求了,不过它会出现一个问题,我们细细分析下。

假设 1 秒至4 秒有 200 个请求过来,5 秒时有 300 个请求过来,6 秒至 9 秒有 499 个请求过来,10 秒时有 1 个请求过来,通过计算得知:1 秒至 5 秒总计 500 个请求,6 秒至 10 秒也是总计 500 个请求。

通过这种算法统计后,我们发现流量确实没有超出阈值。再仔细看看 5 秒~9 秒这个区间的请求数,它已经达到了 300+499=799 个,也就是说 5 秒~9 秒的请求数超标了 299 个,服务器明显扛不住了。

因此,固定时间窗口计数算法在现实中并不实用。

(二)滑动时间窗口计数算法

假设我们的诉求是在 1 秒内后台服务处理 100 个请求,滑动时间窗口计数算法是每 100 毫秒设置一个时间区间,每个时间区间统计该区间内的请求数量,然后每 10 个时间区间合并计算请求总数,请求数超出最大数量时就把多余的请求数据抛弃。当时间节点进入到下一个区间(比如第 11 个区间),我们便不再统计第 1 个区间的请求数量,而是将第 2 个区间~第 11 个区间的请求数量进行合并计算出一个总数,并以此类推,如下图所示。

下载限流 java java限流框架_限流_03

虽然滑动时间窗口计数算法并不能保证每秒的统计请求数 100% 精准,但是可以大大减少单位时间内请求数超出阈值且检测不出来的概率。比如请求都堆积在前 100ms 的尾端与后 100ms 的首端,有可能出现请求数超出最大数量且不被发现的情况。

当然,我们可以将这个区间分得更细,比如设置 10 毫秒为一个区间。因为区间分得越细,计算数据就越精密,不过资源损耗也越多。

这个算法目前看来似乎已经能满足我们的需求了。不过,我们的场景是这样的:库存中只有 100 个商品,如果我们想把 TPS 控制在每秒 100 笔,将滑动时间窗口设置为 1 秒就行,即被分成 10 个区间,每个区间 100 毫秒,此时就会出现在第 1 个 100ms 请求已经超出了 100 个的情况,也就是说商品已经被秒光。

这时问题就来了,什么人能在 100ms 内完成点击购买、下单、提交订单整个流程?我只能说机器人可以做到,也就是说秒杀商品基本是给机器人准备的,这并不是我们想要的结果。

(三)漏桶算法

关于漏桶算法的实现思路,我们先通过一张图进行说明,这样就能立马理解了。

下载限流 java java限流框架_限流_04

从上图中,我们可以看到漏桶算法的实现思路分为 3 个步骤:

  1. 任意请求进来后直接进入漏桶排队;
  2. 以特定的速率处理漏桶队列里面的请求;
  3. 超出漏桶负载范围的新请求直接抛弃掉,无法进入排队队列。

结合上方在 1 秒内控制 100 个请求的例子,我们可以把输出速率设置为 100r/s(即每 10ms 处理一次请求),再把桶的大小设置为 100 。

因为漏桶算法是按照先进先出的原则处理请求,所以会出现最终被处理的请求还是前面那 100 个,这就与滑动时间窗口计数方法遇到的问题一样了。

那如果我们把桶的大小设置为 1,不就可以达到我们的目的了。不过我们还有其他考虑,之后会进一步说明。

(四)令牌桶算法

令牌桶算法的实现思路是这样的:

  1. 按照特定的速率产生 tokens 并存放在令牌桶中,如果令牌桶满了,新的令牌不再产生;
  2. 新进来的请求如果需要处理,则消耗桶中的一个令牌;
  3. 如果桶中有令牌,直接消耗一个;
  4. 如果桶中没有令牌,进入一个队伍中等待新的令牌;
  5. 如果等待令牌的队伍满了,新请求就会直接被抛弃掉。

下载限流 java java限流框架_spring_05

再结合上方在 1 秒内控制 100 个请求的例子,如果使用令牌桶算法,我们需要先把令牌的产生速率设置为 100/s,等待令牌的排队队列设为 0,这样就能够满足我们的秒杀限流的诉求了。

那令牌桶数量到底设置为多少呢?如果设置为 100,假设令牌在秒杀前已经产生,那么秒杀开始时请求数已经是 100 了,前 100 个请求就会被放行,也就是说机器人又抢到了所有商品。

此时,我们可以设置令牌桶数量为 10,这样可以保证顶多 10 个机器人抢到商品。

三、方案实现

搞清楚限流的常见算法后,我们可以进行方案实现了,不过需要考虑以下 4 个问题。

(一)使用令牌桶还是漏桶模式?

刚刚提到令牌桶算法与漏桶算法都可以满足我们的诉求,但是做限流时,我们希望这个算法不仅可以用于秒杀功能,还可以用于其他限流场景。

而使用漏桶算法存在一个缺陷:比如服务器空闲时,理论上服务器可以直接处理一次洪峰,但是漏桶的机制是请求处理速率恒定,因此,前期服务器资源只能根据恒定的漏水速率逐步处理请求,无法用于其他限流场景。

要是使用令牌桶算法就不存在这个问题了,因为我们可以把令牌桶一下子装满。因此,针对这个问题,我们最终使用的是令牌桶。

(二)在 Nginx 中实现限流还是在网关层中实现限流?

在上述业务场景中,最终我们决定在网关层实现限流的原因有两点。

  • Nginx 中有一个限流插件,它可以对单个用户的请求数做限制,不过它基于漏桶算法;
  • 当时我们希望可以动态调整限流的相关配置,因为我们对 Nginx+Lua 不熟悉,以至于配置管理没办法直接操作 Nginx 中的数据。

(三)使用分布式限流还是单机限流?

如果使用单机限流的方式,我们需要提前算好服务器的数量,然后把 100/s 的 TPS 平分到各个服务器上进行一层换算。

如果使用分布式限流的方式,比如我们把令牌桶的数据存放在 Redis 中,即每次请求都需要访问 Redis,因秒杀开始时,下单的请求数往往很大,Redis 未必能承受住如此大的 QPS。

两害相权取其轻,最终我们决定使用单机限流的方式。

(四)使用哪个开源技术?

最终,我们使用开源库 Google-Guava 中的 RateLimiter 的相关类来实现限流,它是基于令牌桶算法的实现库。

在使用开源技术之前,我们需要先定义一个 Zuul 的 filter,再使用 Guava 的 RateLimiter 对提交订单的 API 请求进行过滤。

在使用 RateLimiter 的过程中,我们需要设置如下 3 个配置项。

  • permitsPerSecond:每秒允许的请求数。
  • warmupPeriod:令牌桶多久满。
  • tryAcquire 的超时时间:当令牌桶为空时,可以等待新的令牌多久。

针对以上配置项,我们是这样配置的:

  • permitsPerSecond 设置为 100/10,100 代表想达到的 TPS,10 是代表网关节点 10 台;
  • warmupPeriod 设置为 100ms;
  • tryAcquire 的超时时间设置为 0,即拿不到令牌的请求直接抛弃掉,它无须等待。

关于以上配置内容我们详细展开说明下:permitsPerSecond 为 10,说明 1 秒可以产生 10 个令牌。warmupPeriod=100ms,代表从开始到令牌桶塞满需要 100ms,即令牌桶的大小是 1,如果我们有 10 台网关服务器,那么总令牌桶的大小就是 10。(前面我们提到过,为防止抢到物品的都是机器人,我们需要把令牌桶设置为 10。)

四、限流方案注意事项

在做限流方案时,我曾经遇到过不少的坑,这一讲我把相关的注意事项总结如下,希望对你有所帮助。

(一)限流返回给客户端的错误代码

为了给用户带来好的体验,用户界面上尽量别出现错误,因此限流后被抛弃的请求应该返回一个特制的 HTTP CODE,供客户端进行特殊处理。

(二)实时监控

在实际工作中,我们最好对限流日志随时做好记录并实时统计,这样有助于我们实时监控限流情况,一旦出现意外,可以及时处理。

(三)实时配置

因为限流功能还需要应用到秒杀以外的场景,所以最好在配置中心就可以实现对令牌桶的动态管理+实时设置,这样也方便我们管理其他的限流场景。

(四)秒杀以外的场景的限流配置

在这次秒杀活动中,我们可以简单换算出控制在 100 的 TPS,而在平时的限流场景中,TPS 或 QPS(其他场景可能不使用 TPS)需要根据实际的压力测试结果来计算限流的正确配置。