什么是限流
限流是对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机。 在高并发系统中,出于系统保护角度考虑,通常会对流量进行限流。 在分布式系统中,高并发场景下,为了防止系统因突然的流量激增而导致的崩溃,同时保证服务的高可用性和稳定性,限流是最常用的手段。
有哪些限流算法? 常见的四种限流算法,分别是:固定窗口算法、滑动窗口算法、漏桶算法、令牌桶算法。
固定窗口限流算法
固定窗口又称固定窗口(又称计数器算法,Fixed Window)限流算法,是最简单的限流算法。
实现原理:在指定周期内累加访问次数,当访问次数达到设定的阈值时,触发限流策略,当进入下一个时间周期时进行访问次数的清零。
假设单位时间是1秒,限流阀值为3。在单位时间1秒内,每来一个请求,计数器就加1,如果计数器累加的次数超过限流阀值3,后续的请求全部拒绝。等到1s结束后,计数器清0,重新开始计数。如下图:
优缺点
优点:实现简单,容易理解
缺点:
- 限流不够平滑。例如:限流是每秒 3 个,在第一毫秒发送了 3 个请求,达到限流,窗口剩余时间的请求都将会被拒绝,体验不好。
- 无法处理窗口边界问题。因为是在某个时间窗口内进行流量控制,所以可能会出现窗口边界效应,即在时间窗口的边界处可能会有大量的请求被允许通过,从而导致突发流量。
临界问题:假设限流阀值为5个请求,单位时间窗口是1s,如果我们在单位时间内的前0.8-1s和1-1.2s,分别并发5个请求。虽然都没有超过阀值,但是如果算0.8-1.2s,则并发数高达10,已经超过单位时间1s不超过5阀值的定义啦:
代码实现:
class Counter {
private $limit; // 请求限制阈值
private $interval; // 时间窗口,单位秒
private $count; // 当前计数器值
private $start_time; // 时间窗口开始时间
public function __construct($limit, $interval) {
$this->limit = $limit;
$this->interval = $interval;
$this->count = 0;
$this->start_time = time();
}
public function canPass() {
$now = time();
// 判断当前时间是否超过时间窗口,若超过则重置计数器
if ($now - $this->start_time > $this->interval) {
$this->count = 0;
$this->start_time = $now;
}
// 检查计数器值是否超过限制阈值
if ($this->count < $this->limit) {
$this->count++;
return true; // 请求被接受
} else {
return false; // 请求被拒绝
}
}
}
// 示例使用
$counter = new Counter(10, 60); // 限制每分钟最多处理 10 个请求
for ($i = 0; $i < 15; $i++) {
if ($counter->canPass()) {
echo "Request {$i}: Allowed\n";
} else {
echo "Request {$i}: Denied\n";
}
usleep(200000); // 模拟请求间隔 200 毫秒
}
滑动窗口限流算法
实现原理:滑动窗口在固定窗口的基础上,将时间窗口进行了更精细的分片,将一个窗口分为若干个等份的小窗口,每次仅滑动一小块的时间。每个小窗口对应不同的时间点,拥有独立的计数器,当请求的时间点大于当前窗口的最大时间点时,则将窗口向前平 移一个小窗口(将第一个小窗口的数据舍弃,第二个小窗口变成第一个小窗口,当前请求放在最后一个小窗口),整个窗口的所有请求数相加不能大于阈值。其中,Sentinel 就是采用滑动窗口算法来实现限流的。
一张图看看原理:
假设单位时间还是1s,滑动窗口算法把它划分为5个小周期,也就是滑动窗口(单位时间)被划分为5个小格子。每格表示0.2s。每过0.2s,时间窗口就会往右滑动一格。然后呢,每个小周期,都有自己独立的计数器,如果请求是0.83s到达的,0.8~1.0s对应的计数器就会加1。
我们来看下滑动窗口是如何解决临界问题的?
假设我们1s内的限流阀值还是5个请求,0.8到1.0s内(比如0.9s的时候)来了5个请求,落在黄色格子里。时间过了1.0s这个点之后,又来5个请求,落在紫色格子里。如果是固定窗口算法,是不会被限流的,但是滑动窗口的话,每过一个小周期,它会右移一个小格。过了1.0s这个点后,会右移一小格,当前的单位时间段是0.2到1.2s,这个区域的请求已经超过限定的5了,已触发限流啦,实际上,紫色格子的请求都被拒绝啦。
TIPS: 当滑动窗口的格子周期划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
优缺点
优点:解决了固定窗口算法的窗口边界问题,避免突发流量压垮服务器。
缺点:还是存在限流不够平滑的问题。例如:限流是每秒 3 个,在第一毫秒发送了 3 个请求,达到限流,剩余窗口时间的请求都将会被拒绝,体验不好。
代码实现
<?php
// 引入 Redis 扩展
require 'vendor/autoload.php';
use Predis\Client;
// 初始化 Redis 客户端
$redis = new Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
]);
// 滑动窗口时间间隔(秒)
define('WINDOW_SIZE', 10);
// 允许的最大请求数量
define('MAX_REQUESTS', 100);
function is_allowed($redis, $user_id) {
$current_time = microtime(true) * 1000; // 当前时间戳(毫秒)
// 计算当前时间窗口的起始时间
$window_start_time = $current_time - (WINDOW_SIZE * 1000);
// 添加当前请求的时间戳到有序集合,$user_id为key,score和member都是$current_time
$redis->zadd($user_id, [$current_time => $current_time]);
// 移除时间窗口之前的请求时间戳,'-inf' 是Redis中的特殊值,表示负无穷,即最小可能的分数值
$redis->zremrangebyscore($user_id, '-inf', $window_start_time);
// 获取当前时间窗口内的请求数量
$current_window_count = $redis->zcard($user_id);
// 如果请求数量超过阈值,则限流
if ($current_window_count > MAX_REQUESTS) {
return false;
} else {
return true;
}
}
// 使用示例
$user_id = 'user123';
for ($i = 0; $i < 120; $i++) { // 假设有120次请求
if (is_allowed($redis, $user_id)) {
echo "Allowed\n";
} else {
echo "Rate limit exceeded\n";
}
usleep(100000); // 模拟请求间隔(100ms)
}
?>
漏桶算法
实现原理:漏桶是一个很形象的比喻,外部请求就像是水一样不断注入水桶中,而水桶已经设置好了最大出水速率,漏桶会以这个速率匀速放行请求,而当水超过桶的最大容量后则被丢弃。不管上面的水流速度有多块,漏桶水滴的流出速度始终保持不变。消息中间件就采用的漏桶限流的思想。
-
流入的水滴,可以看作是访问系统的请求,这个流入速率是不确定的。
-
桶的容量一般表示系统所能处理的请求数。
-
如果桶的容量满了,就达到限流的阀值,就会丢弃水滴(拒绝请求)
-
流出的水滴,是恒定过滤的,对应服务按照固定的速率处理请求。
优缺点
优点:
- 平滑流量。由于漏桶算法以固定的速率处理请求,可以有效地平滑和整形流量,避免流量的突发和波动(类似于消息队列的削峰填谷的作用)。
- 防止过载。当流入的请求超过桶的容量时,可以直接丢弃请求,防止系统过载。
缺点:
- 无法处理突发流量:由于漏桶的出口速度是固定的,无法处理突发流量。例如,即使在流量较小的时候,也无法以更快的速度处理请求。
- 可能会丢失数据:如果入口流量过大,超过了桶的容量,那么就需要丢弃部分请求。在一些不能接受丢失请求的场景中,这可能是一个问题。
- 不适合速率变化大的场景:如果速率变化大,或者需要动态调整速率,那么漏桶算法就无法满足需求。
- 资源利用率:不管当前系统的负载压力如何,所有请求都得进行排队,即使此时服务器的负载处于相对空闲的状态,这样会造成系统资源的浪费。
由于漏桶的缺陷比较明显,所以在实际业务场景中,使用的比较少。
代码实现
class LeakyBucket {
private $capacity; // 桶的容量
private $rate; // 流出速率(每秒处理的请求数)
private $water; // 当前桶中水的量(当前的请求数)
private $lastTime; // 上次添加水的时间(时间戳)
public function __construct($capacity, $rate) {
$this->capacity = $capacity;
$this->rate = $rate;
$this->water = 0;
$this->lastTime = microtime(true);
}
public function addRequest() {
$currentTime = microtime(true);
$elapsedTime = $currentTime - $this->lastTime;
// 更新当前桶中的水量
$this->water = max(0, $this->water - $elapsedTime * $this->rate);
$this->lastTime = $currentTime;
if ($this->water < $this->capacity) {
// 如果桶未满,添加水(请求)
$this->water++;
return true; // 请求被接受
} else {
// 桶满了,拒绝请求
return false; // 请求被拒绝
}
}
}
// 示例使用
$bucket = new LeakyBucket(10, 1); // 创建一个容量为 10,每秒处理 1 个请求的桶
for ($i = 0; $i < 20; $i++) {
if ($bucket->addRequest()) {
echo "Request {$i} accepted\n";
} else {
echo "Request {$i} rejected\n";
}
usleep(200000); // 每 200 毫秒发送一个请求
}
令牌桶算法
令牌桶算法是基于漏桶算法的一种改进,主要在于令牌桶算法能够在限制服务调用的平均速率的同时,还能够允许一定程度内的突发调用。面对突发流量的时候,我们可以使用令牌桶算法限流。
- 系统以固定的速率向桶中添加令牌。
- 如果令牌数量满了,超过令牌桶容量的限制,那就丢弃。
- 系统在接受到一个用户请求时,都会先去令牌桶要一个令牌。如果拿到令牌,那么就处理这个请求的业务逻辑;
- 如果拿不到令牌,就直接拒绝这个请求。
优缺点
优点:
- 可以处理突发流量:令牌桶算法可以处理突发流量。当桶满时,能够以最大速度处理请求。这对于需要处理突发流量的应用场景非常有用。
- 限制平均速率:在长期运行中,数据的传输率会被限制在预定义的平均速率(即生成令牌的速率)。
- 灵活性:与漏桶算法相比,令牌桶算法提供了更大的灵活性。例如,可以动态地调整生成令牌的速率。
缺点:
- 可能导致过载:如果令牌产生的速度过快,可能会导致大量的突发流量,这可能会使网络或服务过载。
- 需要存储空间:令牌桶需要一定的存储空间来保存令牌,可能会导致内存资源的浪费。
代码实现
<?php
class TokenBucketRateLimiter {
private $redis; // Redis 连接对象
private $bucketKey; // 令牌桶的键
private $capacity; // 令牌桶最大容量
private $rate; // 令牌生成速率(每秒生成的令牌数量)
/**
* 构造函数,初始化令牌桶限流器
*
* @param Redis $redis Redis 连接对象
* @param string $bucketKey 令牌桶的键
* @param int $capacity 令牌桶最大容量
* @param float $rate 令牌生成速率(每秒生成的令牌数量)
*/
public function __construct($redis, $bucketKey, $capacity, $rate) {
$this->redis = $redis;
$this->bucketKey = $bucketKey;
$this->capacity = $capacity;
$this->rate = $rate;
}
/**
* 检查请求是否可以通过限流
*
* @return bool 请求是否被允许
*/
public function allowRequest() {
$current_time = microtime(true); // 获取当前时间的高精度时间戳
$last_refill_time_key = $this->bucketKey . ':last_refill_time'; // 上次填充时间键
$tokens_key = $this->bucketKey . ':tokens'; // 令牌数量键
// 获取上次填充时间和当前令牌数量
$last_refill_time = $this->redis->get($last_refill_time_key);
$tokens = $this->redis->get($tokens_key);
if ($last_refill_time === false || $tokens === false) {
// 初次访问,初始化令牌桶
$last_refill_time = $current_time;
$tokens = $this->capacity;
} else {
// 计算自上次填充后的时间间隔
$time_since_last_refill = $current_time - $last_refill_time;
// 根据时间间隔计算新增的令牌数
$new_tokens = floor($time_since_last_refill * $this->rate);
// 更新令牌数量(不能超过容量上限)
$tokens = min($this->capacity, $tokens + $new_tokens);
// 更新上次填充时间
$last_refill_time += $new_tokens / $this->rate;
}
if ($tokens < 1) {
// 令牌不足,拒绝请求
return false;
}
// 更新令牌桶状态
$tokens -= 1;
$this->redis->set($last_refill_time_key, $last_refill_time);
$this->redis->set($tokens_key, $tokens);
return true;
}
}
// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$bucketKey = 'user:123:rate_limiter'; // 令牌桶的唯一标识符
$capacity = 10; // 最大令牌数
$rate = 1; // 每秒生成的令牌数
$rateLimiter = new TokenBucketRateLimiter($redis, $bucketKey, $capacity, $rate);
if ($rateLimiter->allowRequest()) {
echo "请求通过";
} else {
echo "请求被拒绝";
}
?>