常用限流方案

六种限流实现方案

为什么要限流

车辆限行就是一种生活中很常见的限流策略,他除了给我带来了以上的好处之外,还给我们美好的生活环境带来了一丝改善,并且快速增长的私家车已经给我们的交通带来了巨大的“负担”,如果再不限行,可能所有的车都要被堵在路上,这就是限流给我们的生活带来的巨大好处。

从生活回到程序中,假设一个系统只能为 10W 人提供服务,突然有一天因为某个热点事件,造成了系统短时间内的访问量迅速增加到了 50W,那么导致的直接结果是系统崩溃,任何人都不能用系统了,显然只有少人数能用远比所有人都不能用更符合我们的预期,因此这个时候我们要使用「限流」了。

限流分类

限流的分类:

  1. 合法性验证限流:比如验证码、IP 黑名单等,这些手段可以有效的防止恶意攻击和爬虫采集;
  2. 容器限流:比如 Tomcat、Nginx 等限流手段,其中 Tomcat 可以设置最大线程数(maxThreads),当并发超过最大线程数会排队等待执行;而 Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数;
  3. 服务端限流:比如我们在服务器端通过限流算法实现限流,此项也是我们本文介绍的重点。

合法性验证限流为最常规的业务代码,就是普通的验证码和 IP 黑名单系统,本文就不做过多的叙述了,我们重点来看下后两种限流的实现方案:容器限流和服务端限流。

容器限流

Tomcat 限流

Tomcat 8.5 版本的最大线程数在 conf/server.xml 配置中,如下所示:

1
2
3
4
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
maxThreads="150"
redirectPort="8443" />

其中 maxThreads 就是 Tomcat 的最大线程数,当请求的并发大于此值(maxThreads)时,请求就会排队执行,这样就完成了限流的目的。

小贴士:maxThreads 的值可以适当的调大一些,此值默认为 150(Tomcat 版本 8.5.42),但这个值也不是越大越好,要看具体的硬件配置,需要注意的是每开启一个线程需要耗用 1MB 的 JVM 内存空间用于作为线程栈之用,并且线程越多 GC 的负担也越重。最后需要注意一下,操作系统对于进程中的线程数有一定的限制,Windows 每个进程中的线程数不允许超过 2000,Linux 每个进程中的线程数不允许超过 1000。

Nginx 限流

Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数。

控制速率

我们需要使用 limit_req_zone 用来限制单位时间内的请求数,即速率限制,示例配置如下:

1
2
3
4
5
6
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit;
}
}

以上配置表示,限制每个 IP 访问的速度为 2r/s,因为 Nginx 的限流统计是基于毫秒的,我们设置的速度是 2r/s,转换一下就是 500ms 内单个 IP 只允许通过 1 个请求,从 501ms 开始才允许通过第 2 个请求。

速率限制升级版

上面的速率控制虽然很精准但是应用于真实环境未免太苛刻了,真实情况下我们应该控制一个 IP 单位总时间内的总访问次数,而不是像上面那么精确但毫秒,我们可以使用 burst 关键字开启此设置,示例配置如下:

1
2
3
4
5
6
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit burst=4;
}
}

burst=4 表示每个 IP 最多允许4个突发请求,如果单个 IP 在 10ms 内发送 6 次请求。

控制并发数

利用 limit_conn_zonelimit_conn 两个指令即可控制并发数,示例配置如下:

1
2
3
4
5
6
7
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
server {
...
limit_conn perip 10;
limit_conn perserver 100;
}

其中 limit_conn perip 10 表示限制单个 IP 同时最多能持有 10 个连接;limit_conn perserver 100 表示 server 同时能处理并发连接的总数为 100 个。

小贴士:只有当 request header 被后端处理后,这个连接才进行计数。

服务端限流

服务端限流需要配合限流的算法来执行,而算法相当于执行限流的“大脑”,用于指导限制方案的实现。

限流的常见算法有以下三种:

  1. 时间窗口算法
  2. 漏桶算法
  3. 令牌算法

1. 时间窗口算法

所谓的滑动时间算法指的是以当前时间为截止时间,往前取一定的时间,比如往前取 60s 的时间,在这 60s 之内运行最大的访问数为 100,此时算法的执行逻辑为,先清除 60s 之前的所有请求记录,再计算当前集合内请求数量是否大于设定的最大请求数 100,如果大于则执行限流拒绝策略,否则插入本次请求记录并返回可以正常执行的标识给客户端。

滑动时间窗口如下图所示:

其中每一小个表示 10s,被红色虚线包围的时间段则为需要判断的时间间隔,比如 60s 秒允许 100 次请求,那么红色虚线部分则为 60s。

可以借助 Redis 的有序集合 ZSet 来实现时间窗口算法限流,实现的过程是先使用 ZSet 的 key 存储限流的 ID,score 用来存储请求的时间,每次有请求访问来了之后,先清空之前时间窗口的访问量,统计现在时间窗口的个数和最大允许访问量对比,如果大于等于最大访问量则返回 false 执行限流操作,负责允许执行业务逻辑,并且在 ZSet 中添加一条有效的访问记录,具体实现代码如下。

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
/**
* @Notes:滑动窗口算法
* @Author:如果,
* @Date: 2022/9/16,
* @Time: 20:20,
*/
public function SlideTimeWindow()
{
/**
* 2.redis 滑动窗口实现方式
* 限制1分钟内最大只能请求10次
* 使用redis事务保证redis原子性
*/
$redis = $this->redis;
$limitTime = 60;
$maxCount = 10;
$redisKey = "slide_api";
$nowTime = time();

//使用管道提升性能 pipe
$pipe = $redis->multi();

//value 和 score 都使用时间戳,因为相同的元素会覆盖
$pipe->zAdd($redisKey, $nowTime, $nowTime);

//移除时间窗口之前的行为记录,剩下的都是时间窗口内的
$pipe->zRemRangeByScore($redisKey, 0, $nowTime - $limitTime);
//查看redis里还有多少次
$pipe->zCard($redisKey);

$pipe->expire($redisKey, 60 + 1);

$replies = $pipe->exec();

//有限时间窗口内的数量超过限制返回 true:false
return $replies[2] <= $maxCount;
}

此实现方式存在的缺点有两个:

  1. 使用 ZSet 存储有每次的访问记录,如果数据量比较大时会占用大量的空间,比如 60s 允许 100W 访问时;
  2. 此代码的执行非原子操作,先判断后增加,中间空隙可穿插其他业务逻辑的执行,最终导致结果不准确。

2. 漏桶算法

漏桶算法的灵感源于漏斗,如下图所示:

滑动时间算法有一个问题就是在一定范围内,比如 60s 内只能有 10 个请求,当第一秒时就到达了 10 个请求,那么剩下的 59s 只能把所有的请求都给拒绝掉,而漏桶算法可以解决这个问题。

漏桶算法类似于生活中的漏斗,无论上面的水流倒入漏斗有多大,也就是无论请求有多少,它都是以均匀的速度慢慢流出的。当上面的水流速度大于下面的流出速度时,漏斗会慢慢变满,当漏斗满了之后就会丢弃新来的请求;当上面的水流速度小于下面流出的速度的话,漏斗永远不会被装满,并且可以一直流出。

上面我们演示 Nginx 的控制速率其实使用的就是漏桶算法,当然我们也可以借助 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
private $_water;    //漏斗的当前水量(也就是请求数)
private $_burst = 10; //漏斗总量(超过将直接舍弃)
private $_rate = 1; //漏斗出水速率(限流速度)
private $_lastTime; //记录每次请求的时间(因为需要记录每次请求之间的出水量也就是请求数)

public function leakBucket()
{

$nowTime = time();
$redisKey = "leakBucket_api";

if (!empty($time = $this->redis->get($redisKey))) {
$this->_lastTime = $time; //获取上一次访问时间
}

if (!empty($water = $this->redis->get('water'))) {
$this->_water = $water;//获取当前剩余量
}

$s = $nowTime - $this->_lastTime; //请求间隔

$outCount = $s * $this->_rate;//请求间隔 * 出水速度 (着段时间应该出多少滴水)

//桶里的水去 - 这段时间要出的水 = 桶里剩余的水
$this->_water = ($this->_water - $outCount);
//桶里剩余的水为 -数
if ($this->_water <= 0) {
$this->_water = 0; //漏斗重新赋值为空
}

if ($this->_water > $this->_burst) {
echo "超出桶限制" . PHP_EOL;
return false;
}

print_r($this->_water);
//重新赋值时间 下次请求这个时间 就为上次时间
$this->redis->set($redisKey, $nowTime);
//把桶里剩余的水 重新赋值到redis里 并+1 本次也算请求
$this->redis->set('water', $this->_water + 1);
}

3. 令牌算法

在令牌桶算法中有一个程序以某种恒定的速度生成令牌,并存入令牌桶中,而每个请求需要先获取令牌才能执行,如果没有获取到令牌的请求可以选择等待或者放弃执行,如下图所示:


具体实现代码如下:

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
<?php

class TokenLimit
{
private $script = '
-- 生成token的速率
local rate = tonumber(ARGV[1])
-- 令牌桶最大容量
local capacity = tonumber(ARGV[2])
-- 当前时间
local now = tonumber(ARGV[3])
-- 获取token的数量
local requested = tonumber(ARGV[4])
-- 填满整个桶需要多久时间
local fill_time = capacity / rate
-- 时间取整
local ttl = math.floor(fill_time*2)

-- 获取目前桶中剩余令牌的数量
-- 如果第一次进入,则设置桶内令牌的数量为最大值
local last_tokens = tonumber(redis.call("get", KEYS[1]))
if last_tokens == nil then
last_tokens = capacity
end

-- 上次更新 桶的时间
local last_refreshed = tonumber(redis.call("get", KEYS[2]))
if last_refreshed == nil then
last_refreshed = 0
end

-- 上次从桶中获取令牌的时间距离现在的时间
local delta = math.max(0, now - last_refreshed)
-- 上次从桶中获取令牌的时间距离现在的时间内总共生成了令牌的数量
-- 如果超过了最大数量则丢弃多余的令牌
local filled_tokens = math.min(capacity, last_tokens + (rate * delta))
-- 本次请求令牌数量是否足够
local allowed = filled_tokens >= requested
-- 令牌桶剩余数量
local new_tokens = filled_tokens
if allowed then
new_tokens = filled_tokens - requested
end

-- 更新桶中剩余令牌的数量
redis.call("setex", KEYS[1], ttl, new_tokens)
-- 更新获取令牌的时间
redis.call("setex", KEYS[2], ttl, now)
return allowed
';

/**
* @var string $key
*/
private $key;

/**
* @var string $timestampKey
*/
private $timestampKey;

/**
* @var int $rate
*/
private $rate;

/**
* @var int $capacity
*/
private $capacity;

/**
* @var Redis $redis
*/
private $redis;

/**
* @var string $tokenFormat
*/
private $tokenFormat = '%s.token';

/**
* @var string $timestampFormat
*/
private $timestampFormat = '%s.ts';

/**
* TokenLimit constructor.
* @param string $key
* @param int $rate
* @param int $burst
* @param Redis $redis
*/
public function __construct(string $key, int $rate, int $burst, Redis $redis)
{
$this->key = sprintf($this->tokenFormat, $key);
$this->timestampKey = sprintf($this->timestampFormat, $key);
$this->rate = $rate;
$this->capacity = $burst;
$this->redis = $redis;
}

/**
* build argv
* @param int $now_time
* @param int $n
* @return array
*/
private function buildArgv(int $now_time, int $n): array
{
return array($this->key, $this->timestampKey, $this->rate, $this->capacity, $now_time, $n);
}

/**
* execute script
* @param int $now_time
* @param int $n
* @return bool
*/
private function reserve(int $now_time, int $n): bool
{
return $this->redis->eval($this->script, $this->buildArgv($now_time, $n), 2);
}

/**
* reserve multi token
* @param int $n
* @return bool
*/
public function allowN(int $n): bool
{
$now_time = time();
return $this->reserve($now_time, $n);
}

/**
* reserve one token
* @return bool
*/
public function allow(): bool
{
return $this->allowN(1);
}
}

$redis = new Redis();
$redis->connect("192.168.4.61", 6379);

$tokenKey = 'token:test';
$tokenLimit = new TokenLimit($tokenKey, 1, 10, $redis);
$allowed = $tokenLimit->allow();
$allowed = $tokenLimit->allow();
$allowed = $tokenLimit->allow();
$allowed = $tokenLimit->allow();
$allowed = $tokenLimit->allow();
$allowed = $tokenLimit->allow();
var_dump($allowed);

总结

本文提供了 6 种具体的实现限流的手段,他们分别是:Tomcat 使用 maxThreads 来实现限流;Nginx 提供了两种限流方式,一是通过 limit_req_zoneburst 来实现速率限流,二是通过 limit_conn_zonelimit_conn 两个指令控制并发连接的总数。最后我们讲了时间窗口算法借助 Redis 的有序集合可以实现,还有漏桶算法。