「并发编程实战」常见的限流方案
文章目录
- 「并发编程实战」常见的限流方案
- 一、概述
- 二、计数器限流方案
- 三、时间窗口限流方案
- 四、令牌桶限流方案
- 五、漏桶限流方案
- 六、高并发限流算法小结
文章参考:
追忆四年前:一段关于我被外企CTO用登录注册吊打的不堪往事
新来个技术总监,把限流实现的那叫一个优雅,佩服!
接口限流算法总结
一、概述
曾经在一个大神的里看到这样一句话:在开发高并发系统时,有三把利器用来保护系统:缓存、降级和限流。那么何为限流呢?顾名思义,限流就是限制流量,就像你宽带包了1个G的流量,用完了就没了。通过限流,我们可以很好地控制系统的qps,从而达到保护系统的目的。本篇文章将会介绍一下常用的限流算法以及他们各自的特点。
什么是限流呢?限流是限制到达系统的并发请求数量,保证系统能够正常响应部分用户请求,而对于超过限制的流量,则通过拒绝服务的方式保证整体系统的可用性。
根据限流作用范围,可以分为单机限流和分布式限流;根据限流方式,又分为计数器、滑动窗口、漏桶限令牌桶限流,下面我们对这块详细进行讲解。
二、计数器限流方案
计数器方案属于限流算法中最简单、并且实现难度最低的算法,比如以短信接口调用为例,规定了「短信」接口的调用频率,不允许在十分钟内超出三次。
这时实现起来就很简单,在「短信」接口的类中,创建一个Map<String,AtomicInteger>
类型的容器即可,其中Key
存储用户ID
,而Value
则存储一个原子计数器,每当一个用户调用一次短信接口后,就将容器中对应的计数器加一,同时开启一个定时任务,每十分钟对计数器做归零重置。
当然,上述这种做法在用户量较大的情况下,显然会对程序造成较大的性能损耗,假设有
100W
用户,那就需要维护100W
个计数器,这会使得内存占用率直线飙升,同时还需要创建100W
个定时器,来分别维护每个用户的调用计数器。
更好一些的做法是借助中间件实现,比如基于Redis
缓存中间件来完成,将用户ID
设计成Key
,而Value
则是计数器,并且创建每个Key
时将过期时间指定为10s
,这样就能充分利用资源,不会造成太大的资源与性能开销,伪逻辑如下:
@Autowired
private StringRedisTemplate redis;
@RequestMapping("/sendSmsVerification")
public ResultVO sendSmsVerification(String sign, String userId){
// 用 SMS_ 拼接用户ID作为Key
String userIdSMS = "SMS_" + userId;
// 先通过前面生成的Key去Redis中进行查询
String value = redis.opsForValue().get(userIdSMS);
// 如果目前已经达到了调用次数限制
if ("3".equals(value)) {
return new ResultVO(200, "短信调用次数已达上限,请在十分钟后重试...");
}
// 如果该用户的Key在Redis中不存在,说明是第一次调用短信接口
if ("".equals(value)) {
// 首次调用短信接口时,则在Redis中创建一个计数器
redis.opsForValue().set(lockKey, 1, 10, TimeUnit.SECONDS);
}
// 如果该用户的Key在Redis中存在,说明并非第一次调用短信接口
else {
// 此时则通过Redis的incr命令,把对应的计数器加一
redis.opsForValue().increment(key);
}
// 省略其他业务代码......
}
复制代码
这段限流代码并不算特别复杂,整体下来无非还是前面说的那几步:
- ①先通过用户
ID
拼接得到Key
,然后去Redis
中进行查询。 - ②如果查询出的结果为
3
,说明目前已达到了调用限制,则直接返回调用已达上限。 - ③如果查询出的结果为空,则说明用户是第一次调用短信接口,此时则在
Redis
中创建计数器。 - ④如果查询出的
value
和上面两条都不匹配,则对Redis
中的计数器加一。
这种计数器限流算法实现起来尤为简单,但前面也聊过它所存在的问题:临界问题,如果在两个时间单位的临界处调用,比如在第9:59
秒调用了三次,接着又在第10:01
秒调用了三次,那依旧会发生“超出调用上限”的情况,毕竟以十分钟作为单位,第9、10
分钟属于一个时间单位内,这时就超出了调用上限,调用次数达到6
次。
三、时间窗口限流方案
时间窗口限流方案被提出的主要目的,就是为了解决传统的计数器方案存在的临界问题,它的演变前身为TCP
协议的滑动窗口,如果对于TCP
协议较为熟悉的小伙伴,听到这个词汇相信一定不陌生,如若对这块内容并不熟悉的小伙伴也没关系,可参考之前文章中聊过的《TCP粘包、半包问题-滑动窗口》。
限流方案中的时间窗算法,主要可被分为固定窗口限流、滑动窗口限流两种方案,而前面聊到的计数器方案,实际上就是一种特殊的固定窗口限流方案,在前面的例子中,时间窗口大小为
10min
,速率限制为3
次,这种方案存在明显的临界限制问题。
下面重点聊一聊滑动时间窗口,这种方案是解决临界问题而被提出的,但对于滑动窗口的概念有些不好理解,所以先上一副逻辑图,如下:
在上图中,整个用虚红线圈出来的代表一个时间窗口,以上述例子来说,一个窗口的大小为600s/10min
,并且每个窗口被分为了三个单位,每个单位大小是200s
,这也就意味着每过200s
,窗口会向后滑动一个单位,这个动作也可以被称之为向后滑动一格,目前的窗口分布如下:
- 第一格:
0~200s
- 第二格:
201~400s
- 第三格:
401~600s
划分出来的每个格子,都具备各自独立的计数器,比如在第138s
时发生了一次接口调用,此时第一格的计数器就会+1
,还是以之前的例子来说:
第
9:59
秒调用了三次,接着又在第10:01
秒调用了三次。
将这里的分钟转换为具体秒数,也就是在第599s
调用了三次,第601s
调用了三次,此时来看,每当时间过去200s
,窗口就会向后滑动一格,这也就意味着整个窗口会变成图中的下面的样子,此时的窗口分布为:
- 第一格:
201~400s
- 第二格:
401~600s
- 第三格:
601~800s
当第599s
调用了三次「短信」接口后,第二格的计数器会累加到3
,此时再当第601s
尝试调用「短信」接口时,就会检测出已达到调用上限,此时就会拒绝用户的调用,以此来解决传统计数器方案的临界问题。
Why?Why?Why?
有些小伙伴可能到这里就有些晕了,第601s
是如何检测出调用超额的呐?因为目前的时间窗口范围是201~800s
,而将整个时间窗口内的计数器求和,就会得到调用总次数为3
,因而成功检测出了第601s
的调用上限。
当出现调用达到上限时,必须随着时间推移、窗口不断向后滑动,这样整个窗口的计数器总和才会下降,因此用户才能继续调用,通过这种方式就能控制一个时间段的绝对限流。
但滑动窗口限流方案就不存在临界问题吗?答案是No
,依旧存在,Why
?来看下图:
看上图中给出的案例,因为目前的时间窗口大小是600s
,而199s~203s
显然处于同一个时间窗口范围内,但随着窗口向后滑动,这里依旧会出现临界问题,也就是在一个窗口范围内,同样会出现打破调用次数上限的情况,那这种情况下又该如何解决呢?其实答案很简单,把一个窗口的格子单位调小即可。
比如直接将每一格的单位大小从
200s
调整为1s
,此时每过一秒钟,窗口就会向后滑动一格,等到100s
秒过后,窗口会向后滑动100
格,此时窗口的区间范围是101~700s
,这就将199~203s
这个范围包含了进去,因此上述情况自然就不会出现!
经过上述分析由此可以得出一条准则:当滑动窗口的格子划分的单位越小,整个窗口中的格子数量会越多,滑动窗口的向后移动就越平滑,限流的统计就会越精确。
四、令牌桶限流方案
前面简单聊完了时间窗口限流方案后,接着再来聊一聊大名鼎鼎的令牌桶限流方案,令牌桶算法是一种类似于“池化”思想的产物,算法的大体过程如下:
- ①初始化令牌桶并设置最大令牌数,当桶内的令牌达到阈值时,新添加的令牌会被拒绝或丢弃。
- ②根据限流大小,启动一条线程,并按照一定速率向令牌桶中不断添加新的令牌。
- ③任何处于「限流范围」内的请求,都需要先获取到一个可用令牌,然后才会被处理。
- ④当一个请求获取到可用令牌后,才会真正执行业务逻辑,执行完成后会将此令牌从桶内移除。
- ⑤令牌桶除开有最大令牌数外,也会有最小令牌数,当桶内令牌数小于最小阈值时,处理完请求并不会移除令牌,而是会将令牌还给令牌桶。
对于令牌桶限流算法,理解起来并没有前面的滑动时间窗口复杂,但唯一要注意的是:当桶内的令牌被一个请求获取后,此时并不会立马从桶内移除,该令牌会依旧停留在桶内,只不过该令牌的状态会从可用状态变为不可用状态,也就是其他请求无法再获取该令牌,真正移除令牌的工作,会在业务逻辑执行完成之后才触发。
五、漏桶限流方案
漏桶限流和令牌桶限流都属于桶类型的算法,但漏桶算法更类似于MQ
消息队列,其算法的执行示意图如下:
想要理解漏桶算法,咱们先来看看日常生活中的漏斗,比如现在我要用漏斗来给摩托车加油:
倒油时,我们可以用瓶子,也可以用桶子,也可以用加油枪…,这也就意味着:漏斗上方的进油速率并不固定,但不管上方的进油速率如何,下方的漏斗出口,其速率确实固定的,无论上方进油多快,都不能影响下方的出油速率。
理解了日常生活中的漏斗后,接着再来看看前面的漏桶限流算法,请求会从漏桶上方进入,而服务端则只会按照固定速率去处理请求。此时思考一个问题:当请求进入的速率大于请求处理的速率,会发生什么情况呢?
此时依旧回到用漏斗给摩托车加油的例子中,如果漏斗上方的倒油速度比较快,而由于漏斗的结构原因,下方的出口跟不上进油速度,此时漏斗中的油量会直线上升,直到超出漏斗的最大容量时,再进入漏斗的汽油会溢出。
而限流中的漏桶算法同样如此,请求进入的速率大于请求处理的速率时,多出来的请求会被放入桶中等待,当桶内阻塞等待的请求超过最大限制后,后续进入的请求会被丢弃或拒绝。
从上述的讲解中,诸位应该能够明显感受到漏桶算法的特点,即:宽进严出,该算法中不会限制请求进入的速率,但会限制请求处理的速率,一些对稳定性要求较高的系统,就可以采用该算法对系统进行限流。当然,如果熟悉MQ
的小伙伴也能感受出:漏桶算法和MQ
的削峰填谷有着异曲同工之妙,当系统峰值流量较高时,会将请求写入到MQ
中,然后再由具体的业务服务,按照固定的速率拉取MQ
中的消息进行处理。
六、高并发限流算法小结
计数器 VS 滑动窗口
计数器算法是最简单的算法,可以看成是滑动窗口的低精度实现。滑动窗口由于需要存储多份的计数器(每一个格子存一份),所以滑动窗口在实现上需要更多的存储空间。也就是说,如果滑动窗口的精度越高,需要的存储空间就越大。
漏桶算法 VS 令牌桶算法
漏桶算法和令牌桶算法最明显的区别是令牌桶算法允许流量一定程度的突发。因为默认的令牌桶算法,取走token是不需要耗费时间的,也就是说,假设桶内有100个token时,那么可以瞬间允许100个请求通过。
令牌桶算法由于实现简单,且允许某些流量的突发,对用户友好,所以被业界采用地较多。当然我们需要具体情况具体分析,只有最合适的算法,没有最优的算法。
在前面共计提到了计数器、滑动窗口、令牌桶、漏桶这四种常规的限流方案,但要记住:并不存在一种适用于任何场景的限流算法,根据业务的需求不同,系统的关注面不同,应当采用不同的限流方案,没有所谓的最好!最后简单说一些成熟的限流实现:
Guava
中的RateLimiter
工具类:基于令牌桶实现的限流组件,并且对其进行了预热拓展。Sentinel
中的匀速排队限流策略:基于漏桶思想的限流策略,内部采用队列进行实现。Nginx
的limit_req_zone
限流模块:基于漏桶思想的限流模块,实现网关层的限流控制。........