淺談Api限流

Latent發表於2021-08-28

限流 :對某段時間內訪問次數限制,保證系統的可用性和穩定性。防止突然訪問暴增導致系統響應緩慢或者當機。
場景:在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);
       }
       //設定鎖
       $this->redis->multi();
       $this->redis->watch();
       $this->redis->incr($this->key);

       return true;
    }
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結
不成大牛,不改個簽

相關文章