PHP实现限流算法

269 阅读6分钟

什么是限流 

限流是对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机。 在高并发系统中,出于系统保护角度考虑,通常会对流量进行限流。 在分布式系统中,高并发场景下,为了防止系统因突然的流量激增而导致的崩溃,同时保证服务的高可用性和稳定性,限流是最常用的手段。 

 有哪些限流算法? 常见的四种限流算法,分别是:固定窗口算法、滑动窗口算法、漏桶算法、令牌桶算法。

固定窗口限流算法

固定窗口又称固定窗口(又称计数器算法,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 "请求被拒绝";
}

?>