PHP 接口限流实现方法

565 阅读2分钟

因为现在动不动就说高并发,说到高并发 就不得不提并发下限流、熔断、降级。
为什么要进行接口限流呢?
个人认为其实目的都是为了保证线上系统的稳定性,防止因为高频访问服务器而导致服务器宕机。

下面来简单实现一下接口限流的常用算法

1.使用计数器进行限流

这应该是最简单也是最容易实现的,比如A接口1分钟内的访问次数不能超过100个。那么可以这么做:在一开始的时候,设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个请求的间隔时间还在1分钟之内,那么说明请求数过多;如果该请求与第一个请求的间隔时间大于1分钟,就重置计数器。为了保证高并发下的原子性使用redis的incr实现计数器限流。

public function __construct()
{
   $this->redis = new \Redis();
   $this->redis->connect('127.0.0.1', 6379);
}

   public function getApi()
   {
       //计数器限流
       //$res = $this->SpeedCounter();
       //滑动窗口算法
       //$res = $this->SlideTimeWindow();
       //漏斗算法
       $res = $this->leakBucket();
       $data = [
           'msg' => '获取成功',
           'code' => 200
       ];

       if (!$res) {
           $data['msg'] = '请稍后重试';
           $data['code'] = 400;
       }
       return $data;
   }

/**
 * @Notes: redis 使用计数器进行限流
 * @Author:如果,
 * @Date: 2022/9/16,
 * @Time: 20:05,
 * @return bool
 */
public function SpeedCounter()
{
    $redis = $this->redis;
    //时间
    $limitTime = 60;
    //最大请求次数
    $maxCount = 10;
    $redisKey = "userRequestNum";

    $count = $redis->incr($redisKey);
    print_r($count);
    //换行
    echo PHP_EOL;
    if ($count == 1) {
        $redis->expire($redisKey, $limitTime);
    }

    if ($count > $maxCount) {
        return false;
    }

    return true;
}
/**
 * @Notes: redis 使用计数器进行限流
 * @Author:如果,
 * @Date: 2022/9/16,
 * @Time: 20:05,
 * @return bool
 */
public function SpeedCounter()
{
    $redis = $this->redis;
    //时间
    $limitTime = 60;
    //最大请求次数
    $maxCount = 10;
    $redisKey = "userRequestNum";

    $count = $redis->incr($redisKey);
    print_r($count);
    //换行
    echo PHP_EOL;
    if ($count == 1) {
        $redis->expire($redisKey, $limitTime);
    }

    if ($count > $maxCount) {
        return false;
    }

    return true;
}

image.png

手动执行php文件 前面10次都可以访问后面10次我们主动拦截请求 粗略的看是实行了限流的方法。这方法存在的问题就是最后1秒内涌入所有请求,然后计数器过期重置后第一秒内又涌入大量请求 这样服务器还是可能会被高频访问搞挂。为了解决这种方法又出现了滑动窗口算法

2.滑动窗口算法

image.png 百度拿的图,滑动窗口个人认为其实就是多存了时间,每次请求进来后时间范围之外的数据将被动态删除。主要使用redis的zset结构来实现

/**
 * @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;
}

主要就是根据时间判断总体思路和计数器差不多
然后来执行一下查看效果

image.png 可以看到10次之后就拦截了请求。

3.漏斗算法

顾名思义漏斗,其实就是处理的速度是一定的。主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。漏斗算法提供了一种机制,通过它,突发流量可以被整形以便为网络提供一个稳定的流量。但是多余的请求将会被直接丢弃。 我更觉得是将流量控制在自己可以承受范围内,自己控制流量的速度。 处理请求的worker以固定的速度从桶中取出请求进行处理。 如果桶满了,直接返回请求频率超限的错误码或者页面 限流 体现在worker从桶中取请求的速度上

image.png 流量最均衡的限流实现方式。nginx的limit模块就是使用了漏斗算法


 
private $_water;    //漏斗的当前水量(也就是请求数)
private $_burst = 10;   //漏斗总量(超过将直接舍弃)
private $_rate = 1; //漏斗出水速率(限流速度)
private $_lastTime; //记录每次请求的时间(因为需要记录每次请求之间的出水量也就是请求数)
 
/**
 * @Notes:漏斗算法
 * @Author:如果,
 * @Date: 2022/9/16,
 * @Time: 21:03,
 */
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);
}

image.png

不足之处在于:
面对突发流量时会有大量请求失败,但是我们可以使用预先准备好的资源返回。