在前面的文章中我们介绍过秒杀系统的架构方案,其中涉及了限流的相关内容,因篇幅有限,当时并没有将这部分内容展开讨论,在这篇文章里就着重聊聊限流的相关知识。
为了方便理解内容,我们还是先从业务场景入手。
在秒杀活动中,总计有 100 个特价商品,且每个商品的价格都非常低,活动计划于 10 月 10 日晚上 10 点 10 分 0 秒开启。
当时,我们的服务器架构图如下,所有客户端的 API 请求先进入 1 个 Nginx 层,再由 Nginx 层转发至网关层(Java,使用 Spring Cloud Zuul),最后转发至后台服务 1(Java)。
预测到秒杀开始那一瞬间会有海量用户涌入,致使系统无法处理所有用户请求,为保障服务器承受住大流量,我们只能通过限流的方式将部分流量放入后台服务中。
那什么是限流呢?一说到限流,有些人总喜欢把它与熔断混在一起谈,其实它们是有区别的。
熔断一般发生在服务调用方,比如服务 A 需要调用服务 B,调用几次后发现服务 B 出现了问题且无法再调用,此时服务 A 必须立马触发熔断,在一段时间内不再调用服务 B。
限流一般发生在服务被调用方,且主要在网关层做限流操作。比如一个电商网站 1 秒钟内后台服务只能处理 10 万个请求,这时突然涌入了 100 万个请求,我们该怎么办?此时,我们可以把 90% 的请求全部抛弃且不做处理,然后专心处理 10% 的请求,以此保证至少 10 万人能正常操作。(这个比例看起来有点夸张,但是在实际秒杀场景中,就算我们把 99% 的流量抛弃掉都不要紧。)
再回到这一讲的业务场景中,这次我们的诉求是在某个层级通过限流的方式将秒杀活动的交易 TPS 控制在 100 笔/秒(因为秒杀活动总计 100 个库存,也就是说最终的交易只有 100 笔,而我们希望 100 笔交易在 1 秒内完成),此时我们应该怎么做呢?这就需要使用到限流的一些常用算法了。
据我了解,市面上关于限流的算法总共分为固定时间窗口计数、滑动时间窗口计数、漏桶、令牌桶这 4 种,下面我们分别进行说明。
假设我们的诉求是每 5 秒钟,后台服务处理 500 个请求(以 5 秒为单位方便举例),那么每 5 秒钟我们就需要一个时间窗口来统计请求,为了方便理解,我们梳理了如下一个业务表。
此时固定时间窗口计算算法看起来可以满足我们的诉求了,不过它会出现一个问题,我们细细分析下。
假设 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 个区间的请求数量进行合并计算出一个总数,并以此类推,如下图所示。
虽然滑动时间窗口计数算法并不能保证每秒的统计请求数 100% 精准,但是可以大大减少单位时间内请求数超出阈值且检测不出来的概率。比如请求都堆积在前 100ms 的尾端与后 100ms 的首端,有可能出现请求数超出最大数量且不被发现的情况。
当然,我们可以将这个区间分得更细,比如设置 10 毫秒为一个区间。因为区间分得越细,计算数据就越精密,不过资源损耗也越多。
这个算法目前看来似乎已经能满足我们的需求了。不过,我们的场景是这样的:库存中只有 100 个商品,如果我们想把 TPS 控制在每秒 100 笔,将滑动时间窗口设置为 1 秒就行,即被分成 10 个区间,每个区间 100 毫秒,此时就会出现在第 1 个 100ms 请求已经超出了 100 个的情况,也就是说商品已经被秒光。
这时问题就来了,什么人能在 100ms 内完成点击购买、下单、提交订单整个流程?我只能说机器人可以做到,也就是说秒杀商品基本是给机器人准备的,这并不是我们想要的结果。
关于漏桶算法的实现思路,我们先通过一张图进行说明,这样就能立马理解了。
从上图中,我们可以看到漏桶算法的实现思路分为 3 个步骤:
结合上方在 1 秒内控制 100 个请求的例子,我们可以把输出速率设置为 100r/s(即每 10ms 处理一次请求),再把桶的大小设置为 100 。
因为漏桶算法是按照先进先出的原则处理请求,所以会出现最终被处理的请求还是前面那 100 个,这就与滑动时间窗口计数方法遇到的问题一样了。
那如果我们把桶的大小设置为 1,不就可以达到我们的目的了。不过我们还有其他考虑,之后会进一步说明。
令牌桶算法的实现思路是这样的:
再结合上方在 1 秒内控制 100 个请求的例子,如果使用令牌桶算法,我们需要先把令牌的产生速率设置为 100/s,等待令牌的排队队列设为 0,这样就能够满足我们的秒杀限流的诉求了。
那令牌桶数量到底设置为多少呢?如果设置为 100,假设令牌在秒杀前已经产生,那么秒杀开始时请求数已经是 100 了,前 100 个请求就会被放行,也就是说机器人又抢到了所有商品。
此时,我们可以设置令牌桶数量为 10,这样可以保证顶多 10 个机器人抢到商品。
搞清楚限流的常见算法后,我们可以进行方案实现了,不过需要考虑以下 4 个问题。
刚刚提到令牌桶算法与漏桶算法都可以满足我们的诉求,但是做限流时,我们希望这个算法不仅可以用于秒杀功能,还可以用于其他限流场景。
而使用漏桶算法存在一个缺陷:比如服务器空闲时,理论上服务器可以直接处理一次洪峰,但是漏桶的机制是请求处理速率恒定,因此,前期服务器资源只能根据恒定的漏水速率逐步处理请求,无法用于其他限流场景。
要是使用令牌桶算法就不存在这个问题了,因为我们可以把令牌桶一下子装满。因此,针对这个问题,我们最终使用的是令牌桶。
在上述业务场景中,最终我们决定在网关层实现限流的原因有两点。
如果使用单机限流的方式,我们需要提前算好服务器的数量,然后把 100/s 的 TPS 平分到各个服务器上进行一层换算。
如果使用分布式限流的方式,比如我们把令牌桶的数据存放在 Redis 中,即每次请求都需要访问 Redis,因秒杀开始时,下单的请求数往往很大,Redis 未必能承受住如此大的 QPS。
两害相权取其轻,最终我们决定使用单机限流的方式。
最终,我们使用开源库 Google-Guava 中的 RateLimiter 的相关类来实现限流,它是基于令牌桶算法的实现库。
在使用开源技术之前,我们需要先定义一个 Zuul 的 filter,再使用 Guava 的 RateLimiter 对提交订单的 API 请求进行过滤。
在使用 RateLimiter 的过程中,我们需要设置如下 3 个配置项。
针对以上配置项,我们是这样配置的:
关于以上配置内容我们详细展开说明下:permitsPerSecond 为 10,说明 1 秒可以产生 10 个令牌。warmupPeriod=100ms,代表从开始到令牌桶塞满需要 100ms,即令牌桶的大小是 1,如果我们有 10 台网关服务器,那么总令牌桶的大小就是 10。(前面我们提到过,为防止抢到物品的都是机器人,我们需要把令牌桶设置为 10。)
在做限流方案时,我曾经遇到过不少的坑,这一讲我把相关的注意事项总结如下,希望对你有所帮助。
为了给用户带来好的体验,用户界面上尽量别出现错误,因此限流后被抛弃的请求应该返回一个特制的 HTTP CODE,供客户端进行特殊处理。
在实际工作中,我们最好对限流日志随时做好记录并实时统计,这样有助于我们实时监控限流情况,一旦出现意外,可以及时处理。
因为限流功能还需要应用到秒杀以外的场景,所以最好在配置中心就可以实现对令牌桶的动态管理+实时设置,这样也方便我们管理其他的限流场景。
在这次秒杀活动中,我们可以简单换算出控制在 100 的 TPS,而在平时的限流场景中,TPS 或 QPS(其他场景可能不使用 TPS)需要根据实际的压力测试结果来计算限流的正确配置。
微信公众号:服务端技术精选
个人博客网站:http://www.jiangyi.cool
领取专属 10元无门槛券
私享最新 技术干货