0%

限流

转自:

人人都能看懂的 6 种限流实现方案!(纯干货) - 掘金
面试必备:4 种经典限流算法讲解

概述

计算机网络中,限流就是通过一些手段去限制发送或者接收请求的速率,可以防止 DOS 攻击和限制 web 爬虫。
在我们的系统中,限流是避免因高并发、大流量请求拖垮系统,导致系统崩溃的一种手段。
eg:景区限流、疫情商场限流

时间窗口算法

单位时间内允许的最大请求量。

固定窗口限流算法

首先维护一个计数器,将单位时间段当做一个窗口,计数器记录这个窗口接收请求的次数。

  • 当次数少于限流阀值,就允许访问,并且计数器+1
  • 当次数大于限流阀值,就拒绝访问。
  • 当前的时间窗口过去之后,计数器清零。

假设单位时间是 1 秒,限流阀值为 3。在单位时间 1 秒内,每来一个请求,计数器就加 1,如果计数器累加的次数超过限流阀值 3,后续的请求全部拒绝。等到 1s 结束后,计数器清 0,重新开始计数。如下图:
image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 固定窗口时间算法
* @return
*/
boolean fixedWindowsTryAcquire() {
long currentTime = System.currentTimeMillis(); //获取系统当前时间
if (currentTime - lastRequestTime > windowUnit) { //检查是否在时间窗口内
counter = 0; // 计数器清0
lastRequestTime = currentTime; //开启新的时间窗口
}
if (counter < threshold) { // 小于阀值
counter++; //计数器加1
return true;
}

return false;
}

但是,这种算法有一个很明显的临界问题:假设限流阀值为 5 个请求,单位时间窗口是 1s,如果我们在单位时间内的前 0.8-1s 和 1-1.2s,分别并发 5 个请求。虽然都没有超过阀值,但是如果算 0.8-1.2s,则并发数高达 10,已经超过单位时间 1s 不超过 5 阀值的定义啦。
image.png

滑动窗口限流算法

滑动窗口限流解决固定窗口临界值的问题。它将单位时间周期分为 n 个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。
一张图解释滑动窗口算法,如下:
image.png
假设单位时间还是 1s,滑动窗口算法把它划分为 5 个小周期,也就是滑动窗口(单位时间)被划分为 5 个小格子。每格表示 0.2s。每过 0.2s,时间窗口就会往右滑动一格。然后呢,每个小周期,都有自己独立的计数器,如果请求是 0.83s 到达的,0.81.0s 对应的计数器就会加 1。
我们来看下滑动窗口是如何解决临界问题的?
假设我们 1s 内的限流阀值还是 5 个请求,0.8
1.0s 内(比如 0.9s 的时候)来了 5 个请求,落在黄色格子里。时间过了 1.0s 这个点之后,又来 5 个请求,落在紫色格子里。如果是固定窗口算法,是不会被限流的,但是滑动窗口的话,每过一个小周期,它会右移一个小格。过了 1.0s 这个点后,会右移一小格,当前的单位时间段是 0.2~1.2s,这个区域的请求已经超过限定的 5 了,已触发限流啦,实际上,紫色格子的请求都被拒绝啦。
TIPS: 当滑动窗口的格子周期划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
滑动窗口算法伪代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/
**
* 单位时间划分的小周期(单位时间是1分钟,10s一个小格子窗口,一共6个格子)
*/
private int SUB_CYCLE = 10;

/**
* 每分钟限流请求数
*/
private int thresholdPerMin = 100;

/**
* 计数器, k-为当前窗口的开始时间值秒,value为当前窗口的计数
*/
private final TreeMap<Long, Integer> counters = new TreeMap<>();

/**
* 滑动窗口时间算法实现
*/
boolean slidingWindowsTryAcquire() {
long currentWindowTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) / SUB_CYCLE * SUB_CYCLE; //获取当前时间在哪个小周期窗口
int currentWindowNum = countCurrentWindow(currentWindowTime); //当前窗口总请求数

//超过阀值限流
if (currentWindowNum >= thresholdPerMin) {
return false;
}

//计数器+1
counters.get(currentWindowTime)++;
return true;
}

/**
* 统计当前窗口的请求数
*/
private int countCurrentWindow(long currentWindowTime) {
//计算窗口开始位置
long startTime = currentWindowTime - SUB_CYCLE* (60s/SUB_CYCLE-1);
int count = 0;

//遍历存储的计数器
Iterator<Map.Entry<Long, Integer>> iterator = counters.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Long, Integer> entry = iterator.next();
// 删除无效过期的子窗口计数器
if (entry.getKey() < startTime) {
iterator.remove();
} else {
//累加当前窗口的所有计数器之和
count =count + entry.getValue();
}
}
return count;
}

滑动窗口算法虽然解决了固定窗口的临界问题,但是一旦到达限流后,请求都会直接暴力被拒绝。酱紫我们会损失一部分请求,这其实对于产品来说,并不太友好。

漏桶算法

漏桶算法类似于生活中的漏斗,无论上面的水流倒入漏斗有多大,也就是无论请求有多少,它都是以均匀的速度慢慢流出的。当上面的水流速度大于下面的流出速度时,漏斗会慢慢变满,当漏斗满了之后就会丢弃新来的请求;当上面的水流速度小于下面流出的速度的话,漏斗永远不会被装满,并且可以一直流出。
漏桶算法的实现步骤是,先声明一个队列用来保存请求,这个队列相当于漏斗,当队列容量满了之后就放弃新来的请求,然后重新声明一个线程定期从任务队列中获取一个或多个任务进行执行,这样就实现了漏桶算法。
image.png

令牌桶算法

在令牌桶算法中有一个程序以某种恒定的速度生成令牌,并存入令牌桶中,而每个请求需要先获取令牌才能执行,如果没有获取到令牌的请求可以选择等待或者放弃执行,如下图所示:
image.png
我们可以使用 Google 开源的 guava 包,很方便的实现令牌桶算法,首先在 pom.xml 添加 guava 引用,配置如下:

1
2
3
4
5
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
mport com.google.common.util.concurrent.RateLimiter;

import java.time.Instant;

/**
* Guava 实现限流
*/
public class RateLimiterExample {
public static void main(String[] args) {
// 每秒产生 10 个令牌(每 100 ms 产生一个)
RateLimiter rt = RateLimiter.create(10);
for (int i = 0; i < 11; i++) {
new Thread(() -> {
// 获取 1 个令牌
rt.acquire();
System.out.println("正常执行方法,ts:" + Instant.now());
}).start();
}
}
}


正常执行方法,ts:2020-05-15T14:46:37.175Z
正常执行方法,ts:2020-05-15T14:46:37.237Z
正常执行方法,ts:2020-05-15T14:46:37.339Z
正常执行方法,ts:2020-05-15T14:46:37.442Z
正常执行方法,ts:2020-05-15T14:46:37.542Z
正常执行方法,ts:2020-05-15T14:46:37.640Z
正常执行方法,ts:2020-05-15T14:46:37.741Z
正常执行方法,ts:2020-05-15T14:46:37.840Z
正常执行方法,ts:2020-05-15T14:46:37.942Z
正常执行方法,ts:2020-05-15T14:46:38.042Z
正常执行方法,ts:2020-05-15T14:46:38.142Z

从以上结果可以看出令牌确实是每 100ms 产生一个,而 acquire() 方法为阻塞等待获取令牌,它可以传递一个 int 类型的参数,用于指定获取令牌的个数。它的替代方法还有 tryAcquire(),此方法在没有可用令牌时就会返回 false 这样就不会阻塞等待了。当然 tryAcquire() 方法也可以设置超时时间,未超过最大等待时间会阻塞等待获取令牌,如果超过了最大等待时间,还没有可用的令牌就会返回 false。
注意:使用 guava 实现的令牌算法属于程序级别的单机限流方案,而上面使用 Redis-Cell 的是分布式的限流方案。