高并发之限流的4种手段

限流总体来说需要确定3个参数:

  • 限流对象:用户、ip、api接口、设备id等等。
  • 限制数量和时间:在某个时间内最多允许多少流量

1. 合法性限流

直接拒绝掉不合法的用户,比如使用验证码、用户名密码登录的方式是用户变成合法用户。针对不合法的用户直接进行拒绝。

2. 容器限流

对运行的容器底层进行限流,比如可以设置tomcat中最大的线程数,或者在nginx转发配置中配置限流。

3. 服务端限流

3.1 固定时间窗口算法

限制在固定时间段内的请求次数。比如说:1秒中内,一个ip最多能请求10次。在1秒钟内,10次请求之后的请求会被直接拒绝。

优点:实现简单,易于理解。

缺点:可能在两个时间窗口的间隙突发接收 2 倍的流量,流量不够平滑。

固定时间窗口算法

实现:使用redis中的lua脚本来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
--lua 下标从 1 开始
-- 限流 key
local key = KEYS[1]
-- 限流大小
local limit = tonumber(ARGV[1])
local expire = tonumber(ARGV[2])

-- 获取当前流量大小
local currentLimit = tonumber(redis.call('get', key) or "0")

if currentLimit + 1 > limit then
-- 达到限流大小 返回
return 0;
else
-- 没有达到阈值 value + 1
redis.call("INCRBY", key, 1)
if currentLimit == 0 then
redis.call("EXPIRE", key, expire)
end
return currentLimit + 1
end;

3.2 滑动时间窗口

为了解决固定窗口存在的问题,滑动时间窗口把固定时间窗口进行进一步细分,分成多个小窗口,每个格子单独维护一个计数器。我们还是可以设置一个时间段内最多处理多少请求。但是这个时间是滚动的。比如1s划分为5个窗口,每个窗口就是200ms,每秒最多访问10次。假设1.1秒时来了一个新的请求,那么我们就通过计算“最近”1秒钟内的请求数是否超过10次(累加0.2秒-1.2秒小窗口中的请求次数)。如果超过就直接拒绝请求。这里的滑动就是值不断删除旧的时间小窗口,添加新的时间小窗口。

优点:解决固定时间窗口带来的突发流量2倍的问题,但是整体来说流量还是不够平滑。

缺点:增大了空间复杂度,算法实现也变复杂了。

滑动时间算法

使用redis实现时间窗口滑动限流算法可以利用zset数据类型,保存时以限流对象名称为key,随机数为value,当前时间戳为份数score。下面是一个redis使用事务方式的实现,不过这个实现有一个问题:

  • 时间戳是从客户端获取的,假设有多个客户端,则要求客户端之间时间的一致性非常高。否则可能会出现限流穿透的情况。这里也可以采用lua脚本的方法来实现这个逻辑,时间戳从redis中取。
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
public class SimpleSlidingWindowByZSet {

private Jedis jedis;

public SimpleSlidingWindowByZSet(Jedis jedis) {
this.jedis = jedis;
}

/**
* 判断行为是否被允许
*
* @param userId 用户id
* @param actionKey 行为key
* @param period 限流周期(秒)
* @param maxCount 最大请求次数(滑动窗口大小)
* @return
*/
public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) {
//构建限流对象
String key = this.key(userId, actionKey);
//获取当前时间(纳秒)
long ts = System.nanoTime();
Pipeline pipe = jedis.pipelined();
//开启事务
pipe.multi();
//将当前时间添加到set中去,得分为当前时间
//为了保证每个请求添加到zset中都能够保存,因此这里采用随机数。
pipe.zadd(key, ts, new Random().nextInt());
// 移除滑动窗口之外的数据
// 窗口起始时间 = ts - (period * 1000000000) = 当前时间 - (限流周期【秒】 * 1000)
pipe.zremrangeByScore(key, 0, ts - (period * 1000));
//获取有效时间内的总调用次数
Response<Long> count = pipe.zcard(key);
//设置行为的过期时间,如果数据为冷数据,zset将会删除以此节省内存空间
pipe.expire(key, period);
//执行所有命令
pipe.exec();
pipe.close();
//获取当前滑动时间范围内调用次数,如果大于小于最大请求数就返回true表示放行,否则返回false标识拒绝访问。
return count.get() <= maxCount;
}

/**
* 限流key
* @param userId
* @param actionKey
* @return
*/
public String key(String userId, String actionKey) {
return String.format("limit:%s:%s", userId, actionKey);
}

}

3.3 漏筒限流算法

漏筒限流算法用来限制数据的流出速率。核心思路是在流量的生产端和消费端中间加上一个容量固定的桶,消费者会以恒定的速率从桶中取数据进行消费。生成端则会不断把数据放入桶中,等待消费,如果桶容量已满,则会被直接拒绝。

优点:能使流量更加平滑。

缺点:应对突发流量的能力较弱,需要等待消费。

image-20220313175228212

可以使用mq队列来实现一个简单的漏桶限流算法

3.4 令牌桶限流算法

令牌桶算法用来限制数据的流入速率。核心思路是我们需要创建一个固定容量的桶,有一个线程会已固定的速率往桶中存放令牌(桶满则丢弃)。用户请求需要从桶中拿到令牌才能继续执行,否则直接拒绝执行。

优点:能使流量更加平滑,也可以应付一定量的突发流量。

令牌桶算法

Google的guava中已经实现了本地的令牌桶算法,他的核心类是RateLimiter。

4. 如何确定限流阈值

限流策略是微服务治理中的标配策略,只是你很难在实际中确认限流的阈值是多少,设置的小了容易误伤正常的请求,设置的大了则达不到限流的目的。所以,一般在实际项目中,我们会把阈值放置在配置中心中方便动态调整;同时,我们可以通过定期地压力测试得到整体系统以及每个微服务的实际承载能力,然后再依据这个压测出来的值设置合适的阈值。