浅谈Api限流

限流 :对某段时间内访问次数限制,保证系统的可用性和稳定性。防止突然访问暴增导致系统响应缓慢或者宕机。
场景:在php-fpm中,fpm开启的子进程数是有限的,当并发请求大于可用子进程数时,进程池分配不了多余的子进程处理http请求,服务就会开始阻塞。导致nginx抛出502。

知道了大概的概念,现在我们主要讲限流在单体架构里面的使用。

1.服务代理层限流

nginx 限流

nginx的 HttpLimitRequest模块

该模块可以指定会话请求数量,可以通过指定ip进行请求频率限制。使用漏桶算法进行请求频率限制。

示例:


http { 
//会话状态存储在了10m的名称为"one"这个区域。该区域平均查询限制在每秒1个请求
  limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; 

 ... server { ... location /search/ { 
 // 每秒平均请求不超过1个请求 突发不超过5个查询 如果不需要限制突发延迟内的超额请求,则应使用
 nodelay  limit_req zone=one burst= 5 nodelay;
 } 
具体可以参考nginx文档 HttpLimitReqest模块

这是摘抄nginx文档中的一段关于限流的小例子。nginx使用的漏桶算法对用户访问频率进行限制。
通过百度、google 我们知道了。原来限流是基于算法来实现的。下面是限流的两种算法:

实现限流的算法
  • 漏桶算法
  • 令牌桶算法

当然我们不仅要知其然,还要知其所以然。

1.漏桶算法

漏桶算法:漏桶有一定的容量,且漏桶会漏水。
当单位时间内注入的水大于单位时间内流出的水。漏桶积攒的水越来越多。直到溢出,如果溢出,则需要限流。
算法描述:
当前水量: 上次容量-流出容量+注入水量
流出容量:(当前注水时间-上次注水时间)*流出速率
当 「当前水量」> 「桶子容量」 则溢出。否则正常,记录本次水量和注水时间。

通过图片描述漏桶算法

2. php+redis 实现漏桶算法限流类

新增BucketLimit.php

  protected $capacity  = 60; //桶子总容量
  protected $addNum    = 20; //每次注入水的容量
  protected $rate      = 2;  //漏水速率
  protected $water_key = "water_capacity"; //缓存key
  public $redis;        //使用redis 缓存当前桶水量和上次注水时间

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

具体实现方法

 /**
     * @param $api [string 指定接口限流]
     * @param $addNum [int 注水量 ]
     * @return bool
     */
    public function bucket($addNum,$api='')
    {
        $this->addNum = $addNum;
        // 获取上次 桶内水量 注水时间
        list($waterCapacity,$waterTime,$lastTime) = $this->getLastWater();
        //计算出时间内流出的水量
        $lastWater = ($lastTime-$waterTime)*$this->rate;
        //本次水量
        $waterCapacity = $waterCapacity-$lastWater;
        //水量不能小于0
        $waterCapacity = ( $waterCapacity>=0 ) ? $waterCapacity : 0 ;
        $waterTime = $lastTime;
        //当前水量大于桶子容量 溢出返回 false 存储水量和注水时间
        if( ($waterCapacity+$addNum) <= $this->capacity ){
            $waterCapacity += $addNum;
            $this->setWater($waterCapacity,$waterTime);
            return true;
        }else{
           $this->setWater($waterCapacity,$waterTime);
            return false;
        }
  }

 /**
 * @return array [$waterCapacity,$waterTime,$lastTime] *  当前容量 上次漏水时间 当前时间
 */
 private function getLastWater()
{
    $water = $this->redis->get($this->water_key);

    if($water) {
        $water = json_decode($water,true);
        $waterCapacity =$water['water_capacity'];  //上一次容量
        $waterTime =$water['time']; //上一次注水时间
        $lastTime = time(); //本次注水时间
  } else{
        $this->redis->set($this->water_key,json_encode([
            'water_capacity'=>0,
            'time'=>time()
        ]));
        $waterCapacity =0;  //上一次容量
        $waterTime =time(); //上一次注水时间
        $lastTime = time(); //本次注水时间
  }
    return [$waterCapacity,$waterTime,$lastTime];
}

/**
 * @param $waterCapacity [int 本次剩余容量]
 * @param $waterTime [int 本次注水时间]
 */
 private function setWater($waterCapacity,$waterTime)
{
    $this->redis->set($this->water_key,json_encode([
        'water_capacity'=>$waterCapacity,
        'time'=>$waterTime
  ]));
}

开始测试

使用 for + sleep函数模拟请求 正常2s请求一次 方法正常不限流 小于2秒 请求到大概到第四次会进行限流

require_once 'BucketLimit.php';

$bucket = new BucketLimit();

for($i=1;$i<=100;$i++) {
 //根据for + sleep函数模拟请求 正常2s请求一次 方法正常不限流 sleep(1);
 $data =  $bucket->bucket(10);
  var_dump($data)."\n";
}

2. 令牌桶算法

令牌桶算法和漏桶算法刚好相反,指定速率向桶子里面投放令牌。每次请求都会想桶里面拿走一枚令牌,当桶子里面的令牌消费完毕,则限流。优点:可以方便改变投递令牌的速率。

使用案例

hyperf 令牌桶算法实现限流代码

3.laravel框架中对api限流 app/Http/Kernel.php

    protected $middlewareGroups = [
           'api' => [
               'throttle:60,1', //执行中间件 每分钟请求限制在60次
           ],
       ];
源码分析
  • 判断是否设置api请求速率限制
  • 执行判断限制速率方法
  • 根据缓存key 判断api 设置时间单位内请求次数到达了阀值
  • 到达了请求阀值,进行速率限制
注入缓存实例
 protected $limiter;

    /**
     * Create a new request throttler.
     *
     * @param  \Illuminate\Cache\RateLimiter  $limiter
     * @return void
     */
    public function __construct(RateLimiter $limiter)
    {
        $this->limiter = $limiter;
    }
判断是否配置了速率限制
 /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  int|string  $maxAttempts
     * @param  float|int  $decayMinutes
     * @param  string  $prefix
     * @return \Symfony\Component\HttpFoundation\Response
     *
     * @throws \Illuminate\Http\Exceptions\ThrottleRequestsException
     */
    public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
    {

        //判断用户是否限制频率
        if (is_string($maxAttempts)
            && func_num_args() === 3
            && ! is_null($limiter = $this->limiter->limiter($maxAttempts))) {

            return $this->handleRequestUsingNamedLimiter($request, $next, $maxAttempts, $limiter);
        }
       //执行频率限制判断 参数分别是:
        return $this->handleRequest(
            $request, //请求类
            $next,    //中间件基类
            [
                (object) [
                    'key' => $prefix.$this->resolveRequestSignature($request), //缓存key
                    'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts), //获取频繁阀值
                    'decayMinutes' => $decayMinutes,
                    'responseCallback' => null, //存放回调响应
                ],
            ]
        );
    }
判断是否到达阀值。
/**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  array  $limits
     * @return \Symfony\Component\HttpFoundation\Response
     *
     * @throws \Illuminate\Http\Exceptions\ThrottleRequestsException
     */
    protected function handleRequest($request, Closure $next, array $limits)
    {
        foreach ($limits as $limit) {
            //判断速率是否达到阀值 返回 true false 该方法使用缓存实例取出缓存的key
            if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) {
                throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback);
            }
            //类似于redis数值自增 并且设置过期时间
            $this->limiter->hit($limit->key, $limit->decayMinutes * 60);
        }

        $response = $next($request);
        //将响应放入响应回调函数中
        foreach ($limits as $limit) {
            $response = $this->addHeaders(
                $response,
                $limit->maxAttempts,
                $this->calculateRemainingAttempts($limit->key, $limit->maxAttempts)
            );
        }
        //返回响应
        return $response;
    }
获取频率 $this->limiter->tooManyAttempts方法

    /**
     * Determine if the given key has been "accessed" too many times.
     *
     * @param  string  $key
     * @param  int  $maxAttempts
     * @return bool
     */
    public function tooManyAttempts($key, $maxAttempts)
    {
        if ($this->attempts($key) >= $maxAttempts) {
            if ($this->cache->has($key.':timer')) {
                return true;
            }

            $this->resetAttempts($key);
        }

        return false;
    }

该方法实现的原理:周期性限流。通过次数/时间来限制请求频率。

下面是我基于上面的逻辑实现一个这样的类,仅供参考。
class CurrentLimiting
{

    protected $limit;
    protected $minutes;
    protected $redis;
    protected $key;

    /**
     * CurrentLimiting constructor.
     * @param string $api 接口
     * @param string $ip ip
     * @param int $limit 限制频率
     * @param int $minutes 分钟
     */
    public function __construct(string  $api,string $ip,int $limit,int $minutes)
    {
        $redis = new \Redis();
        $redis->connect('127.0.0.1','6379',3);
        $this->redis = $redis;
        $this->limit = $limit;
        $this->minutes = $minutes;
        $this->key = $ip.$api;

    }
    //获取请求次数
    public function attempts()
    {
      $count =  $this->redis->get($this->key);
      return is_null($count) ? 0 : $count;
    }

    /**
     *
     * @return bool
     */
    public function CurrentLimit()
    {
        $count = $this->attempts();
       if($count >= $this->limit) {
           return false;
       }
       if($count==0){
           $this->redis->set($this->key,0,$this->minutes*60);
       }
      // 局域网内同一ip访问 并发问题
       $this->redis->watch($this->key);
       $this->redis->multi();
       $this->redis->incr($this->key);
       $this->redis->exec();

       return true;
    }
}

ddos攻击

恶意攻击者一般会模拟大量的虚拟ip来请求我们的服务,这时候就应该在代理层限流+黑名单机制
大概策略和原理:单位时间内请求频率过多的ip直接动态加入黑名单。
参考文章:blog.csdn.net/weixin_43112000/arti...

本作品采用《CC 协议》,转载必须注明作者和本文链接
不成大牛,不改個簽
本帖由系统于 2年前 自动加精
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 25

原来小学应用题 游泳池一个水龙头放水一个水龙头注水 就是我们学的第一个算法 :joy: :joy: :joy:

2年前 评论
Latent (楼主) 2年前

Nginx 限流和 Laravel 限流配合起来会比较快不?先过滤基础的,比如一分钟120次,Laravel 再根据路由、用户等限制,比如限制 60 次。

2年前 评论
Latent (楼主) 2年前

总结:
http 请求限流按层次分为: 服务器层(nginx)、代码层(laravel)
http 请求限流算法分为: 漏桶 Leaky Bucket、令牌桶 Token Bucket

nginx 的 ngx_http_limit_req_module 和 laravel 的 throttle 使用的都是 leaky bucket 算法

2年前 评论
Latent (楼主) 2年前
wuchenge

redis watch 少了exec吧

2年前 评论
Latent (楼主) 2年前
Latent (楼主) 2年前
wuchenge (作者) 2年前
Latent (楼主) 2年前

推荐个 symfony 的包 github.com/symfony/rate-limiter

2年前 评论
Latent (楼主) 2年前

我们目前在应用层做的是 用redis/scan命令去实现的

2年前 评论

不错,限流的还没有用过,但是api有接口做了,接口幕等心。作用是同一个用户,同样的请求参数多触发。

2年前 评论

一般设置限流的值,设置的依据是怎么来的呢,是自己预估一个还是其他方式?

2年前 评论

@奕鹏 压测吧,计算大概的并发量,根据系统到达负荷阀值和判断限制的请求限制速率

2年前 评论
奕鹏 2年前
pndx

Mark

2年前 评论

生产环境遇到一个问题【too many attempts】,laravel路由里单独修改了频率限制为1000次每分钟,实际上请求量在100多就触发了too many attempts,偶发性的;发现也有其他人遇到这个问题,但是网上没能找到对应的解释和问题解决,不知道你清不清楚这个。\app\Http\Kernel.php文件里的默认throttle已经是注释掉的了

2年前 评论
MArtian 2年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!