Как написать Sliding Window Rate Limiter на Yii 1.1 с использованием Redis Sorted Sets

от автора

Ограничение скорости запросов (Rate Limiting) помогает защищать API от злоупотреблений, обеспечивая справедливое использование ресурсов. В этой статье мы разберем, как создать RateLimiter на Yii 1.1 (PHP 7.3) с использованием Redis (^6.2.0).

Стоит отметить, что существуют платные коммерческие решения, такие как Qrator, которые предоставляют комплексные инструменты для защиты API и фильтрации запросов. Однако в этой статье мы сосредоточимся на самостоятельной реализации RateLimiter с использованием открытых инструментов. Также, для простоты и читаемости кода, мы не используем Lua-скрипты и вычисления на стороне Redis.

Что такое Sliding Window Rate Limiter?

Шаблон, который мы реализуем здесь, называется sliding window rate limiter (ограничение скорости с «скользящим окном»). В отличие от fixed window rate limiter (ограничение скорости с фиксированным окном), он накладывает ограничения на запросы в дискретном временном окне, предшествующем текущему запросу.

Разница между Sliding Window и Fixed Window

  • Fixed Window Rate Limiter группирует запросы в строго определённое временное окно. Например, если вы установили ограничение 10 запросов в минуту, то в фиксированном окне может возникнуть ситуация, когда лимитер позволит пройти 20 запросам за одну минуту. Это может произойти, если 10 запросов были отправлены в последние 5 секунд предыдущего окна, а ещё 10 — в первые 5 секунд следующего окна.

  • Sliding Window Rate Limiter, напротив, анализирует запросы, отправленные за последние 60 секунд. Если все 20 запросов были отправлены в пределах 60 секунд друг от друга, лимитер пропустит только 10 из них.

Используя сортированные множества Redis, реализация такого лимитера становится очень простой.

Ключевые возможности

  1. Гибкость Sliding Window: Учитываем запросы в скользящем временном окне для точного ограничения скорости.

  2. Лимитирование запросов: Устанавливаем максимальное количество запросов на определённое время.

  3. Отслеживание запросов с помощью Redis: Используем сортированные множества для хранения временных меток.

    Реализация

    Вот полный пример реализации RateLimiter на PHP.

    <?php  declare(strict_types=1);  namespace app\modules\api\filters;  use common\helpers\App; use app\Cache\Adapter\PredisAdapter; use app\modules\api\providers\Interfaces\ClientIdProviderInterface; use app\modules\api\providers\Interfaces\ControllerInfoProviderInterface; use Yii;  /**  * Class RateLimiter  */ class RateLimiter extends \CFilter {     const MICROSECONDS_FACTOR = 1000000;     /**      * @var bool      */     public $enableHeaders = true;     /**      * @var int количество запросов      */     public $limit;     /**      * @var int количество секунд      */     public $time;      /** @var int[] Белый список клиентов */     public $whitelist = [];      /**      * @var PredisAdapter      */     private $redis;      /**      * @var ClientIdProviderInterface      */     private $clientIdProvider;      /**      * @var ControllerInfoProviderInterface      */     private $controllerInfoProvider;      public function __construct()     {         $this->redis = Yii::app()->cache->getClient();         $this->clientIdProvider = Yii::app()->container->get(ClientIdProviderInterface::class);         $this->controllerInfoProvider = Yii::app()->container->get(ControllerInfoProviderInterface::class);     }      /**      * {@inheritdoc}      * @throws \CHttpException      */     public function preFilter($filterChain): bool     {         $clientId = $this->clientIdProvider->getCurrentClientId();         $action = $this->controllerInfoProvider->getActionId($filterChain);         $key = $this->getRedisKey($clientId, $action);         $totalMicroseconds = intval(microtime(true) * self::MICROSECONDS_FACTOR)         // Удаление старых запросов         $this->redis->zremrangebyscore($key, '-inf', $totalMicroseconds - $this->time * self::MICROSECONDS_FACTOR);          // Проверяем whitelist         if (in_array($clientId, $this->whitelist)) {             $this->saveRequest($key, $totalMicroseconds);             return true;         }          $requestCount = $this->redis->zcard($key);         $allowance = $requestCount + 1;          if ($allowance > $this->limit) {             $this->saveRequest($key, $totalMicroseconds);             $this->addHeaders($this->limit, 0, $this->time);             throw new \CHttpException(429, 'Слишком много запросов');         }          $this->saveRequest($key, $totalMicroseconds);         $this->addHeaders($this->limit, $this->limit - $allowance, (int)((($this->limit - $allowance + 1) * $this->time) / $this->limit));          return true;     }      private function saveRequest(string $key, int $timestamp): void     {         $this->redis->zadd($key, [$timestamp => $timestamp]);         $this->redis->expire($key, $this->time);     }      public function getRedisKey(int $clientId, string $action): string     {         return "rate_limit:{$clientId}:{$action}";     }      private function addHeaders(int $limit, int $remaining, int $reset): void     {         if ($this->enableHeaders && !App::isTest()) {             header('X-Rate-Limit-Limit: ' . $limit);             header('X-Rate-Limit-Remaining: ' . $remaining);             header('X-Rate-Limit-Reset: ' . $reset);         }     } }

    Подключение фильтра к контроллеру

    В контроллере подключите фильтр:

    <?php  declare(strict_types=1);  namespace app\controllers;  use yii\web\Controller; use app\modules\api\filters\RateLimiter;  class ApiController extends Controller {     /**      * {@inheritdoc}      */     public function filters()     {         return array_merge(             parent::filters(),             [                 [                     RateLimiter::class,                     'limit' => 10,                     'time' => 60,                     'whitelist' => Yii::app()->params['api_rate_limit_whitelist'],                 ],             ]         );     }      public function actionIndex()     {         return ['message' => 'Welcome to the API'];     } } 

    Как это работает

    1. Определение пользователя: Лимитер определяет текущего пользователя через getCurrentClientId и выполняемое действие API.

    2. Хранение запросов в Redis: Запросы сохраняются в сортированном множестве, где временные метки выступают в роли ключей и значений.

    3. Очистка старых запросов: Старые записи за пределами временного окна удаляются с помощью команды zremrangebyscore.

    4. Проверка лимита: Количество запросов (zcard) сравнивается с заданным лимитом.

    5. Добавление заголовков: Заголовки возвращаются с информацией о текущем лимите.

      Данные в Redis
      Ключ для каждого пользователя и действия выглядит так:

      rate_limit:{clientId}:{action}

      Пример хранимых данных:

      [ 1726823063987469 => 1726823063987469, 1726823063987479 => 1726823063987479 ]

Возможные улучшения

  1. Динамические лимиты: Устанавливайте индивидуальные лимиты для пользователей в зависимости от их уровня доступа.

  2. Глобальные ограничения: Добавьте глобальный лимит для всего API.

  3. Аналитика: Используйте данные запросов для анализа активности или выявления злоупотреблений.

Заключение

Этот RateLimiter реализован просто, эффективно и легко расширяется. Использование Redis обеспечивает высокую производительность и надежную обработку конкурентных запросов. Независимо от того, создаёте ли вы публичное API или внутренний инструмент, такой подход поможет защитить ресурсы и обеспечить их справедливое использование.

Как вы используете RateLimiter в своих проектах? Делитесь своими мыслями и опытом в комментариях! 😊


ссылка на оригинал статьи https://habr.com/ru/articles/871936/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *