六种限流实现方案
为什么要限流
车辆限行就是一种生活中很常见的限流策略,他除了给我带来了以上的好处之外,还给我们美好的生活环境带来了一丝改善,并且快速增长的私家车已经给我们的交通带来了巨大的“负担”,如果再不限行,可能所有的车都要被堵在路上,这就是限流给我们的生活带来的巨大好处。
从生活回到程序中,假设一个系统只能为 10W 人提供服务,突然有一天因为某个热点事件,造成了系统短时间内的访问量迅速增加到了 50W,那么导致的直接结果是系统崩溃,任何人都不能用系统了,显然只有少人数能用远比所有人都不能用更符合我们的预期,因此这个时候我们要使用「限流」了。
限流分类
限流的分类:
- 合法性验证限流:比如验证码、IP 黑名单等,这些手段可以有效的防止恶意攻击和爬虫采集;
- 容器限流:比如 Tomcat、Nginx 等限流手段,其中 Tomcat 可以设置最大线程数(maxThreads),当并发超过最大线程数会排队等待执行;而 Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数;
- 服务端限流:比如我们在服务器端通过限流算法实现限流,此项也是我们本文介绍的重点。
合法性验证限流为最常规的业务代码,就是普通的验证码和 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_zone
和 limit_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. 时间窗口算法
所谓的滑动时间算法指的是以当前时间为截止时间,往前取一定的时间,比如往前取 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
|
public function SlideTimeWindow() {
$redis = $this->redis; $limitTime = 60; $maxCount = 10; $redisKey = "slide_api"; $nowTime = time();
$pipe = $redis->multi();
$pipe->zAdd($redisKey, $nowTime, $nowTime);
$pipe->zRemRangeByScore($redisKey, 0, $nowTime - $limitTime); $pipe->zCard($redisKey);
$pipe->expire($redisKey, 60 + 1);
$replies = $pipe->exec();
return $replies[2] <= $maxCount; }
|
此实现方式存在的缺点有两个:
- 使用 ZSet 存储有每次的访问记录,如果数据量比较大时会占用大量的空间,比如 60s 允许 100W 访问时;
- 此代码的执行非原子操作,先判断后增加,中间空隙可穿插其他业务逻辑的执行,最终导致结果不准确。
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); $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 ';
private $key;
private $timestampKey;
private $rate;
private $capacity;
private $redis;
private $tokenFormat = '%s.token';
private $timestampFormat = '%s.ts';
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; }
private function buildArgv(int $now_time, int $n): array { return array($this->key, $this->timestampKey, $this->rate, $this->capacity, $now_time, $n); }
private function reserve(int $now_time, int $n): bool { return $this->redis->eval($this->script, $this->buildArgv($now_time, $n), 2); }
public function allowN(int $n): bool { $now_time = time(); return $this->reserve($now_time, $n); }
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_zone
和 burst
来实现速率限流,二是通过 limit_conn_zone
和 limit_conn
两个指令控制并发连接的总数。最后我们讲了时间窗口算法借助 Redis
的有序集合可以实现,还有漏桶算法。