Перейти к содержимому
Поиск
Разработка

Symfony RateLimiter: лимиты API в PHP — мастерская и 9 опытов

Ограничиваем частоту запросов в PHP: три алгоритма, два пользователя, стоимость операций, Retry-After и конкуренция процессов. Девять настоящих опытов, мастерская и проект с Redis для Windows и Linux.

Александр Некрасов
Symfony RateLimiter — квоты запросов PHP, токены и Redis

Лимиты API в PHP без полного Symfony: три алгоритма, стоимость операций, HTTP 429 и общая квота нескольких процессов. Девять настоящих опытов, мастерская и готовый проект для Windows, Linux и VPS.

Symfony RateLimiter: ограничение частоты с понятными правилами

Пользователь открыл отчёт десять раз подряд, интеграция отправила сотню одинаковых запросов, а внешний сервис разрешает только двадцать операций в минуту. Во всех этих случаях приложению нужен управляемый бюджет действий: кому ещё можно выполнить операцию, сколько она стоит и когда появится следующая возможность. Именно такую задачу решает Symfony RateLimiter.

Компонент работает и без полного фреймворка Symfony. Его можно подключить в существующий PHP API, административную панель, команду импорта или обработчик очереди через Composer. Вы задаёте политику, хранилище состояния и ключ клиента; библиотека возвращает решение о допуске, остаток бюджета и время следующей попытки. Само действие остаётся в вашем коде, поэтому правило можно поставить перед дорогим SQL, генерацией документа или обращением к стороннему API.

В этой статье есть девять настоящих записанных PHP-опытов, интерактивная модель и конструктор конфигурации. Исходники содержат рабочий HTTP API, клиент с Retry-After, конкурентные проверки отдельных процессов и полный Linux-стенд PHP-FPM, Nginx и Redis. Разберём не только запуск, но и причины, по которым один и тот же код ведёт себя по-разному в одиночном CLI и на нескольких серверах.

Где применять: API, формы, отчёты и внешние интеграции

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

Для интерфейса полезно превращать отказ в понятное состояние: сообщить, что операция временно недоступна, показать время ожидания и сохранить введённые данные. Это лучше, чем исчезающая кнопка или бесконечный спиннер. При этом блокировка кнопки в браузере остаётся удобством, а решение принимает сервер: клиентский JavaScript можно обойти.

Для исходящих интеграций лимит ставят перед обращением к поставщику. Несколько ваших worker должны использовать один ключ поставщика и общее хранилище, иначе каждый процесс считает, что имеет всю квоту. Для попыток входа дополнительно нужны корректная идентификация, отдельная политика ошибок и защита от блокировки чужого аккаунта. Учебный API ниже показывает механизм квоты, а не готовую систему безопасности для авторизации.

Частота, одновременность и защита сервера — разные ограничения

Лимит десять запросов в минуту не означает, что одновременно работают не больше десяти задач. Если каждая задача длится несколько минут, разрешённые в соседних окнах операции могут накопиться. Для ограничения текущего числа исполнителей нужен отдельный механизм: очередь с заданной concurrency, semaphore либо пул worker. RateLimiter управляет поступлением действий во времени.

Похожее разделение важно на границе инфраструктуры. PHP-компонент начинает работать после того, как запрос уже дошёл до приложения. Он удобен для пользовательской квоты, стоимости операции и тарифов, но не должен быть единственным барьером перед большим потоком сетевых запросов. Reverse proxy и балансировщик могут отклонять часть нагрузки раньше. Практику проксирования и повседневных настроек мы разбирали в материале о Nginx.

Наконец, квота не является гарантией выполнения. Разрешённый запрос может завершиться ошибкой базы или внешнего сервиса. Нужно заранее определить, расходует ли неуспешная попытка бюджет. Самовольное возвращение токена после любого исключения иногда позволяет бесконечно повторять дорогое действие. Решение зависит от смысла операции и должно быть одинаковым у всех экземпляров приложения.

Мини-мастерская: запись, учебная модель и готовый PHP

Начните с первого блока мастерской. Там находятся результаты настоящих запусков PHP: выберите опыт, просмотрите последовательность событий, сравните разрешённые и отклонённые действия. В таблице показаны фактическое относительное время, ключ клиента, стоимость, остаток и время повтора. Кнопка пошагового просмотра меняет только темп показа: она не запускает новый серверный тест.

Второй блок — самостоятельная учебная модель на JavaScript. Меняйте алгоритм, ёмкость и интервал, переключайте Алису и Бориса, двигайте часы и отправляйте условные запросы. Время здесь управляется кнопками. Модель помогает увидеть идею, но её округления намеренно не выдаются за реализацию Symfony: например, дробный вес скользящего окна отличается от целочисленного счётчика компонента.

После изменения настроек нажмите «Применить и начать заново». До этого скачивание нового исходника блокируется, чтобы в файл не попали незаметно старые значения. Конструктор создаёт PHP для запуска в корне скачанного проекта. Запись JSON и CSV сохраняет исходные результаты выбранного опыта. Исторические измерения, учебный эксперимент и ваш будущий запуск остаются различимыми.

Версии, зависимости и устройство проекта

Для воспроизводимого примера выбран Symfony RateLimiter 7.4.23 из LTS-ветки 7.4; минимальный PHP — 8.2. Cache и Lock закреплены той же версией, Predis — 3.7.1. Framework Bundle не устанавливается. Composer lock включает конкретные транзитивные зависимости. Это важно: на разных основных версиях библиотеки могут отличаться пограничные случаи, поэтому сравнивать результат стоит вместе с версией.

Папка src содержит небольшую фабрику, строгое Redis-хранилище и помощники опытов. Bin — исполняемые команды, public — готовую статическую мастерскую; server — выключенный по умолчанию учебный API. Шаблон полного окружения находится в compose.yml, конфигурации VPS — в deploy. Исходники интерфейса лежат отдельно в web; Bootstrap загружается локально, без обязательного внешнего CDN.

Для своего проекта пакет обычно добавляют через Composer, а затем выбирают совместимые хранилище и блокировки. В готовом архиве используйте install, чтобы получить версии из lock, а не незаметно обновить всё командой update. Если вы только знакомитесь с менеджером зависимостей, посмотрите наш практический материал о Composer. Официальные зависимости компонента доступны на Packagist.

composer require symfony/rate-limiter:"7.4.*" symfony/cache:"7.4.*" symfony/lock:"7.4.*"
# В скачанном проекте вместо require:
composer install --no-interaction --prefer-dist
composer check-platform-reqs

Первый пример без фреймворка: три допуска и два отказа

Самый короткий опыт использует InMemoryStorage. Фабрика получает имя правила, алгоритм, лимит и интервал. Метод create связывает правило с конкретным ключом, а consume пытается списать заданное число токенов. Если аргумент не передан, стоимость равна одному. Один экземпляр хранилища должен сохраняться на протяжении опыта; пересоздание пустого хранилища перед каждым вызовом выдаст свежую квоту.

В цикле ниже первые три быстрых действия разрешаются, следующие два отклоняются. IsAccepted — решение, getRemainingTokens — оставшийся бюджет. Результат не выполняет бизнес-операцию автоматически: ваш код должен проверить допуск и только затем продолжить. В проекте этот пример лежит в bin/example.php и действительно запускается обычным PHP CLI.

Память здесь удобна для объяснения и небольшого теста в одном процессе. Она не является общей базой для разных HTTP-запросов PHP-FPM. Если разные worker создают независимые хранилища в памяти, каждый получает собственный бюджет. Для лимита, который должен переживать запросы, понадобится постоянное общее состояние: ниже перейдём к файлам и Redis.

<?php
require __DIR__.'/vendor/autoload.php';
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;

$factory = new RateLimiterFactory([
    'id' => 'reports',
    'policy' => 'fixed_window',
    'limit' => 3,
    'interval' => '10 seconds',
], new InMemoryStorage());

foreach (range(1, 5) as $attempt) {
    $result = $factory->create('user-42')->consume();
    printf("%d: %s; left=%d\n", $attempt,
        $result->isAccepted() ? 'accepted' : 'rejected',
        $result->getRemainingTokens());
}

Опыт 1. Фиксированное окно и всплеск на границе

Фиксированное окно удобно, когда правило должно быть простым: определённое число действий за интервал. В реализации Symfony окно связано с первым обращением к ключу, а не обязательно с календарной минутой 12:00–12:01. Поэтому два пользователя могут иметь разные моменты обновления бюджета. Не подписывайте такой лимит «обнулится в начале часа», если приложение не добавляет собственную календарную логику.

В записанном опыте лимит равен трём, интервал — одной секунде. Один запрос идёт в начале окна, ещё два близко к его концу. После перехода границы снова появляется бюджет. Если смотреть на короткий участок вокруг границы, поток может оказаться плотнее, чем подсказывает фраза «три в секунду». Это ожидаемое свойство выбранной политики.

Такой алгоритм подходит для понятных пользовательских правил и нестрогих периодических квот. Для защиты дорогой операции от резких групп запросов полезнее посмотреть на скользящее окно или корзину токенов. Короткая секунда в мастерской выбрана ради наглядности; рабочий интервал выбирают по смыслу задачи, а поведение около границы проверяют отдельным тестом.

$config = [
    'id' => 'export',
    'policy' => 'fixed_window',
    'limit' => 3,
    'interval' => '1 second',
];
$factory = new \Symfony\Component\RateLimiter\RateLimiterFactory(
    $config,
    new \Symfony\Component\RateLimiter\Storage\InMemoryStorage()
);

Опыт 2. Скользящее окно: вес предыдущего интервала

Второй опыт подаёт практически тот же временной поток в sliding_window. Компонент учитывает текущий счётчик и постепенно уменьшающийся вклад предыдущего окна. Это сглаживает часть граничного всплеска, но не означает, что библиотека хранит точные отметки всех запросов за последние N секунд. Важно различать приближённый взвешенный счётчик и точный журнал событий.

Например, если в предыдущем окне было восемь действий, а от текущего прошло четверть, старый вклад составляет примерно шесть. К нему прибавляется число новых действий. Конкретное решение дополнительно зависит от округления и границ текущей версии. Именно поэтому в мастерской есть настоящая запись рядом с учебной моделью, а в исходниках зафиксирован Composer lock.

Практически это полезно для API, где резкие переходы между окнами нежелательны, но хранить полный список времён каждого действия слишком затратно. Смотрите на последовательность допусков целиком. Один разрешённый запрос рядом с границей сам по себе не означает ошибку алгоритма. Для финансовой или юридически значимой месячной квоты понадобится отдельный учёт, а не трактовка приблизительного rate limit как расчётного реестра.

$config = [
    'id' => 'search',
    'policy' => 'sliding_window',
    'limit' => 30,
    'interval' => '1 minute',
];

Опыт 3. Token bucket: ёмкость отдельно от скорости пополнения

У корзины токенов есть два параметра, которые легко смешать. Limit задаёт максимальный запас, доступный для всплеска. Rate описывает, как быстро этот запас восстанавливается. Если ёмкость равна двадцати, а пополнение — два токена в секунду, пользователь может сначала выполнить двадцать дешёвых действий, после чего доступ будет восстанавливаться постепенно.

Пауза не накапливает бесконечный кредит: запас ограничен ёмкостью. Это удобно для интерфейса, где человек некоторое время ничего не делает, затем открывает несколько карточек, а постоянный автоматический поток должен оставаться умеренным. При настройке смотрите не только на среднюю скорость, но и на разрешённый стартовый всплеск. Большая ёмкость может сразу пропустить достаточно запросов, чтобы перегрузить медленную операцию.

Наш опыт расходует несколько токенов разом, получает отказ и наблюдает восстановление через реальное ожидание PHP-процесса. Учебная модель отдельно показывает плавное пополнение. Для своего приложения выбирайте стоимость и скорость в одних единицах: «токены» — условный бюджет, не обязательный синоним количества HTTP-запросов.

$config = [
    'id' => 'partner-api',
    'policy' => 'token_bucket',
    'limit' => 20,
    'rate' => ['interval' => '1 second', 'amount' => 2],
];

Опыт 4. Два пользователя и правильное построение ключа

Фабрика описывает политику, ключ разделяет её состояние между клиентами. В опыте Алиса исчерпывает два разрешённых действия, но у Бориса остаётся собственный бюджет. Если обоим передать одинаковый ключ, они будут конкурировать за общую квоту. Это может быть как ошибкой, так и правильным решением: например, несколько пользователей одной организации должны делить тариф компании.

Ключ получают после проверки личности или API-ключа. Нельзя просто принять userId из JSON и считать его надёжной идентичностью: клиент сможет каждый раз присылать новое значение. В учебном API два фиксированных токена отображаются на два серверных имени. Это демонстрационная схема, которую при переносе заменяют своей аутентификацией.

Полезно включать в идентификатор смысл правила: tenant, класс операции и версию политики. При этом не помещайте туда пароль, полный bearer token или длинные персональные данные. Число уникальных ключей влияет на объём хранилища, поэтому бесконтрольное создание ключей по произвольному URL или вводу тоже требует внимания. Продумайте, какие сущности действительно должны получать независимый бюджет.

$tenantId = 'company-17';   // Получено после аутентификации.
$userId = 'user-42';       // Получено после аутентификации.
$userBudget = $factory->create('read:'.$tenantId.':'.$userId);
$tenantBudget = $factory->create('export:'.$tenantId);
// Разные ключи означают разные бюджеты. Выберите нужный смысл.

Опыт 5. Настоящий HTTP 429 и заголовок Retry-After

Лимитер возвращает объект результата, а HTTP-ответ формирует приложение. Если действие не разрешено, обработчик должен завершиться до дорогой операции, вернуть 429 и понятное тело. Retry-After сообщает клиенту, сколько ждать перед следующей попыткой. В нашем API используется форма с целым числом секунд; HTTP допускает и дату, поэтому универсальный клиент внешнего сервиса должен понимать его конкретный контракт.

Запись этого опыта получена настоящими запросами к временному PHP-серверу. Первый отчёт разрешён, второй получает 429, затем клиент ждёт указанное время и пробует снова. Helper сам поднимает собственный сервер на свободном loopback-порту и завершает его после опыта. В исходной JSON-записи сохраняются реальные HTTP-статусы и полученный заголовок; таблица показывает решение о допуске и рассчитанное ожидание.

Если после ожидания приходит ещё один отказ, это не обязательно ошибка: бюджет могли занять другие клиенты с тем же ключом. Retry-After не резервирует будущую возможность. Поэтому число повторов и общий срок ожидания ограничивают. В интерфейсе полезно сохранить параметры формы и показать текст причины, чтобы человек понимал, почему повтор сейчас не поможет.

$limit = $limiter->consume(1);
if (!$limit->isAccepted()) {
    $seconds = max(1, $limit->getRetryAfter()->getTimestamp() - time());
    http_response_code(429);
    header('Retry-After: '.$seconds);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['error' => 'Попробуйте позже', 'retryAfter' => $seconds]);
    exit;
}
// Только теперь выполнять дорогую операцию.

Повторы клиента: задержка, предел ожидания и идемпотентность

Клиент в архиве делает ограниченное число попыток и читает Retry-After только после 429. Это маленький пример для собственного локального API. В реальной интеграции добавляют максимальный общий срок, обработку сетевых исключений и разумную политику для временных ошибок сервера. Повторять любой ответ немедленно в бесконечном цикле нельзя: так клиент сам усиливает перегрузку.

Особенно внимательно относитесь к действиям с побочным эффектом. Если сервер создал заказ, но ответ потерялся, повтор может создать второй заказ. RateLimiter этой проблемы не решает. Для таких маршрутов нужен idempotency key или другой контракт дедупликации, согласованный с сервером. Ожидание квоты и защита от двойного выполнения относятся к разным уровням приложения.

При большом количестве клиентов одинаковый момент повтора может сформировать новую волну. Дополнительная случайная задержка после обязательного минимального ожидания помогает распределить попытки. Однако она не должна уменьшать значение Retry-After. В worker-процессе можно отложить задачу, освобождая исполнителя; в браузере — показать обратный отсчёт и дать пользователю отменить ожидание.

# Сначала запустите локальный API по инструкции ниже.
php bin/client.php
# Клиент обращается только к http://127.0.0.1:8401/api.php
# и выполняет не больше четырёх попыток.

Опыт 6. Стоимость операции: отчёт дороже чтения

Стоимость передаётся в consume целым числом. В опыте доступно пять токенов: отчёт стоит три, обычное чтение — один. После первого отчёта второй такой же запрос не проходит, но оставшиеся два токена позволяют выполнить два чтения. Отказ не должен незаметно списывать только часть требуемой стоимости; этот инвариант проверяется настоящими PHP-тестами для трёх политик.

У такой модели есть практический смысл: одинаковое количество запросов не означает одинаковую нагрузку. Чтение маленькой карточки и экспорт десятков тысяч строк расходуют разные ресурсы. Однако условные цены должны быть объяснимыми, а не случайными числами. Сначала оцените операции, затем определите классы стоимости и документируйте их для клиентов API.

Стоимость больше общей ёмкости — некорректная конфигурация, а не временная нехватка. Такой запрос никогда не дождётся достаточного бюджета. В конструкторе мастерской это отклоняется сразу; на сервере лучше выбирать стоимость по известной операции, а не доверять числу из пользовательского JSON. Учебный API принимает только read или report и сам назначает цену.

$costByOperation = ['read' => 1, 'report' => 3];
$operation = 'report';
$cost = $costByOperation[$operation];
$result = $limiter->consume($cost);
if (!$result->isAccepted()) {
    // 429: бюджет меньше полной стоимости операции.
}

Несколько правил: тариф, организация и внешний поставщик

Иногда нужно проверить больше одного бюджета: личный лимит пользователя, общий лимит организации и квоту внешнего поставщика. Их нельзя бездумно свести к одному ключу. Личный бюджет отвечает за справедливость между людьми, общий защищает доступный ресурс компании, а поставщик может вообще считать операции по одному аккаунту для всех ваших пользователей.

Если последовательно вызвать consume у двух независимых лимитеров, первая проверка может списать токен, а вторая отказать. Это важный эффект частичного расхода. Такой порядок иногда приемлем и даже желателен, но его нужно определить заранее. Не обещайте атомарную проверку нескольких квот только потому, что каждая отдельно защищена блокировкой.

У Symfony есть составные механизмы ограничения, однако их семантику резервирования и частичного результата нужно изучать под конкретную версию и задачу. В этой мастерской основной API использует один бюджет на пользователя, чтобы поведение было прозрачным. Для производственного тарифа начните с ясного договора: что считается попыткой, какие правила применяются вместе и когда считается израсходованным каждый из бюджетов.

// Два независимых списания не образуют общую транзакцию.
$personal = $userLimiter->consume(1);
if (!$personal->isAccepted()) { /* 429 */ }
// Если следующая проверка откажет, первый токен уже мог быть потрачен.
// Проектируйте порядок и учёт намеренно, а не как скрытый побочный эффект.

Состояние между HTTP-запросами: память, файлы и Redis

InMemoryStorage подходит для одного живущего PHP-процесса. FilesystemAdapter сохраняет данные на диске и помогает быстро проверить несколько запросов локально. Redis выносит состояние в сервис, доступный нескольким экземплярам приложения. Во всех вариантах важен срок хранения: если запись исчезла, новый запрос может получить заново полный бюджет.

Не смешивайте кеш лимитов с обычным кешем страниц. Команда очистки HTML-кеша во время deploy не должна неожиданно обнулять квоты всех клиентов. Используйте отдельный namespace, наблюдайте объём ключей и продумайте поведение после перезапуска. Эфемерный Redis в нашем Compose специально теряет состояние при пересоздании: это учебный стенд, а не готовая политика хранения рабочего сервиса.

Само наличие общего хранилища ещё не защищает от гонки. Два процесса могут прочитать одно состояние, оба разрешить действие и затем записать конфликтующие изменения. Для операции read-modify-write нужна координация, которую мы проверяем дальше. Подробнее о TTL, пулах и различии кеша с постоянными данными можно прочитать в нашей статье о Symfony Cache.

use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\RateLimiter\Storage\CacheStorage;

$pool = new FilesystemAdapter('rate_limits_v1', 0, __DIR__.'/var/cache');
$storage = new CacheStorage($pool);
// Это хранение состояния. Блокировка передаётся фабрике отдельно.

Опыт 7. Восемь PHP-процессов и FlockStore

Конкурентный тест запускает восемь отдельных PHP-процессов с одинаковым ключом и лимитом три. Они ждут общий момент старта, затем одновременно пытаются получить допуск. Это отличается от восьми вызовов в одном последовательном цикле: чтение и изменение состояния действительно могут пересекаться во времени.

Для наглядности тестовое хранилище задерживает чтение на 120 миллисекунд. Задержка не находится в обычном API; она специально расширяет участок, где возникает гонка. С FlockStore процессы по очереди выполняют короткую критическую операцию. Проверка требует ровно три разрешения, пять отказов и восемь разных PID. Мы измеряем корректность общего бюджета, а не скорость Redis или производительность PHP.

Файловая блокировка подходит для процессов одной машины, использующих общий доступный каталог. Две машины с одинаковым путём /var/locks не имеют общего файла только из-за совпадения имени. Для распределённого сервиса нужен общий механизм блокировки. Также не стоит переносить этот тест на произвольную сетевую файловую систему и автоматически считать её семантику эквивалентной локальному flock.

use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\FlockStore;
use Symfony\Component\RateLimiter\RateLimiterFactory;

$locks = new LockFactory(new FlockStore(__DIR__.'/var/locks'));
$factory = new RateLimiterFactory($config, $storage, $locks);
// Каталоги var/cache и var/locks должны быть доступны процессу PHP.

Опыт 8. Общий Redis и распределённая блокировка

В Redis-опыте те же восемь процессов используют один сервер и общий RedisStore для блокировок. В статье важно показать обе части: общее состояние квоты и общую координацию его изменения. Если хранить счётчик в Redis, а блокировать каждый экземпляр приложения своим локальным файлом, процессы на разных машинах всё равно смогут попасть в критическую секцию одновременно.

Наш проект подключается через Predis, поэтому на Windows не требуется устанавливать расширение phpredis. Внутри Docker Compose адрес сервиса — redis:6379; для native PHP с хоста опубликован loopback-порт 6385. Не путайте эти адреса: localhost внутри PHP-контейнера указывает на сам контейнер, а не на соседний Redis.

Блокировка защищает короткий цикл чтения и записи лимита. Она не удерживается на всё выполнение отчёта и не заменяет блокировку бизнес-ресурса. Для нескольких серверов также важны синхронизированные часы, задержки сети и поведение при сбое хранилища. Базовую архитектуру Redis мы разбирали в отдельном практическом материале; здесь фокус на общей квоте PHP-процессов.

use Predis\Client;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Aen\RateLab\RedisStateStorage;

$redis = new Client('tcp://127.0.0.1:6385');
$storage = new RedisStateStorage($redis, 'my_rate_limits_v1');
$locks = new LockFactory(new RedisStore($redis));
$factory = new RateLimiterFactory($config, $storage, $locks);

Опыт 9. Почему Redis без lock может пропустить лишнее

Последний опыт намеренно отключает блокировку, сохраняя общее Redis-хранилище и задержку чтения. Несколько процессов успевают увидеть один и тот же остаток. Каждый принимает решение на основе уже устаревающего состояния, поэтому общее число разрешений может превысить лимит. Отдельные операции Redis при этом работают корректно: проблема находится в последовательности нескольких действий клиента.

Этот опыт полезен тем, что воспроизводит ошибочное предположение «у нас же Redis, значит всё атомарно». Атомарность отдельного GET или SET не делает атомарным произвольный PHP-код между ними. Возможны разные архитектуры решения, включая серверные атомарные операции, но в нашем проекте используется предусмотренная RateLimiter интеграция с Lock.

Число лишних допусков зависит от планировщика, числа процессов и задержек. В записи показано то, что фактически произошло, а не обещание, что без блокировки всегда будет ровно восемь разрешений. В тестах отрицательный контроль должен выявить перепуск; если окружение неожиданно исполнило все процессы последовательно, такой результат требует исследования, а не вывода об отсутствии гонки.

php bin/concurrency.php redis locked
php bin/concurrency.php redis unlocked
# Первый опыт требует accepted=3 и rejected=5.
# Второй намеренно показывает ошибочную конфигурацию без блокировки.

В приложенной записи от 2026-11-10T08:34:20+00:00 получены следующие фактические результаты. Искусственная задержка чтения в конкурентных опытах — 120 мс.

Опыт Разрешено Отклонено
8 PHP-процессов + файловая блокировка 3 5
8 PHP-процессов + общий Redis 3 5
Контрольный опыт без блокировки 8 0

Что делать, когда Redis недоступен

Отсутствующий ключ и ошибка чтения хранилища — разные ситуации. Пустой ключ обычно означает начало нового бюджета. Сбой Redis означает, что приложение не знает, можно ли доверять состоянию. Если одинаково трактовать оба случая как полный запас, недоступность хранилища может фактически отключить ограничение. Иногда такое поведение выбирают осознанно ради доступности, но его нельзя оставлять неявным.

В нашем Redis API используется небольшое собственное хранилище, реализующее StorageInterface. Ошибки соединения и записи распространяются наружу; HTTP-обработчик возвращает 503 и не выполняет действие. Десериализация ограничена классами состояния компонента, а Redis считается доверенным служебным хранилищем. Публичный ввод не записывается туда как произвольный объект.

Стандартная связка CacheStorage и RedisAdapter удобна, но кеш-адаптеры могут иметь собственную стратегию ошибок. Нельзя автоматически приписывать любому cache pool строгий отказ при сбое. Именно поэтому пример явно отделяет файловый учебный путь от строгого Redis-хранилища. В рабочем сервисе добавьте наблюдение за отказами и выберите политику отдельно для разных операций: поиск и дорогой платный экспорт могут требовать разных решений.

try {
    $limit = $factory->create($verifiedUserKey)->consume($cost);
} catch (\Throwable $error) {
    error_log('Rate limiter backend unavailable');
    http_response_code(503);
    // Операция не выполняется. В ответ не выводим DSN и stack trace.
    exit;
}

Windows: мастерская и первый сервер без Docker

Распакуйте архив и откройте PowerShell в его корне. Проверьте версию PHP, выполните composer install и тесты. Затем встроенный PHP-сервер раздаст public на loopback. Готовый интерфейс уже находится в архиве, поэтому Node.js не обязателен для просмотра: он нужен, если вы меняете исходники мастерской и пересобираете локальные ресурсы.

В этом первом запуске учебный API выключен, а записи и модель полностью доступны. Это намеренное разделение: читателю не требуется сразу поднимать Redis, чтобы разобраться с политиками. Открывать index.html через file:// не стоит, поскольку приложение получает JSON через fetch. Если порт занят, выберите другой порт для просмотра, не завершайте посторонний процесс.

Для настоящего файлового API остановите сервер и включите AEN_LAB_API в том терминале, где будете запускать PHP. Переменная AEN_BACKEND выбирает files. Встроенный сервер удобен для локальных опытов, но не предназначен для публикации на VPS. Конкуренцию в нашем тесте создают отдельные PHP-процессы, поэтому однопоточный локальный HTTP-сервер не выдаётся за нагрузочный стенд.

php --version
composer install --no-interaction --prefer-dist
composer test
composer verify
php -S 127.0.0.1:8401 -t public server/router.php
# После Ctrl+C, для настоящего учебного API:
$env:AEN_LAB_API='1'
$env:AEN_BACKEND='files'
php -S 127.0.0.1:8401 -t public server/router.php

Windows: запросы, Redis и повторная запись опытов

Во втором терминале отправьте запрос к API с учебным токеном. В проекте сервер сам отображает demo-alice и demo-bob на известные ключи; JSON содержит только операцию. Быстрый повтор дорогого отчёта исчерпывает доступный запас, после чего вы увидите 429. Обычные команды PowerShell могут показывать этот ответ как исключение, поэтому для сценария ожидания удобнее запустить готовый bin/client.php.

Redis на Windows в нашем варианте работает через Linux-контейнер Docker Desktop. Команда compose up поднимает только собственный сервис проекта. После этого TEST_REDIS=1 включает дополнительные реальные проверки, а composer capture переснимает все девять опытов. Capture использует отдельный порт 8411 для временного HTTP-сервера; перед запуском он должен быть свободен.

Сохранение записи ещё не меняет опубликованную мастерскую: npm run build копирует данные в public. Это полезная дисциплина при разработке демонстраций. Сначала изучите свежий результат и пройдите проверки, затем собирайте интерфейс. Не заменяйте запись автоматически только ради совпадения с ожидаемой картинкой.

$headers=@{'X-Demo-Token'='demo-alice'}
Invoke-RestMethod http://127.0.0.1:8401/api.php -Method Post -Headers $headers -ContentType 'application/json' -Body '{"operation":"report"}'
docker compose up -d redis
$env:TEST_REDIS='1'
composer test
composer capture
composer verify
npm.cmd ci --ignore-scripts
npm.cmd run build

Linux: CLI, зависимости и проверка окружения

На Linux используется тот же проект и Composer lock. Сначала убедитесь, что установлен PHP не ниже 8.2, а команда php указывает на нужный бинарник. У системы может быть несколько версий: CLI, PHP-FPM и PHP внутри контейнера не обязаны совпадать. Команда check-platform-reqs помогает увидеть требования зависимостей в текущем окружении, но проверку FPM при развёртывании всё равно нужно выполнить отдельно.

Пакеты дистрибутива и способ установки Composer выбирают по своей версии Linux. В README приведён пример для Ubuntu с подходящим PHP; он не является универсальным списком команд для любой старой системы. Composer запускайте от пользователя проекта. Каталог зависимостей не должен принадлежать root только потому, что одна установка выполнялась через sudo.

Для локального Redis можно использовать Compose или системный сервис. Если Redis слушает стандартный 6379 вместо нашего 6385, явно задайте REDIS_URL. Проверяйте соединение из того процесса и окружения, где работает приложение. Ошибку маршрута между WSL, хостом и контейнером нельзя исправить изменением алгоритма лимитера.

php --version
composer install --no-interaction --prefer-dist
composer check-platform-reqs
composer test
AEN_LAB_API=1 AEN_BACKEND=files php -S 127.0.0.1:8401 -t public server/router.php
# В другом терминале, с запущенным Redis:
REDIS_URL=tcp://127.0.0.1:6385 TEST_REDIS=1 composer test

Полный Docker Compose: Nginx, PHP-FPM и Redis

Полный стенд в архиве состоит из трёх сервисов. Nginx обслуживает public и передаёт только известный api.php в PHP-FPM. PHP выполняет компонент и обращается к Redis по внутреннему имени сервиса. На хосте интерфейс доступен через loopback 8401, а Redis — через loopback 6385 для native PHP-проверок. База не публикуется на всех сетевых интерфейсах.

Проверьте свободные порты и подсеть перед запуском. В примере выделена отдельная сеть 10.254.242.0/24; если она пересекается с вашей сетью или VPN, измените compose.yml. Имя Compose project отделяет ресурсы мастерской от других контейнеров. Команда down в каталоге этого проекта убирает его сервисы и сеть, а не является поводом останавливать весь Docker.

В контейнере можно повторить настоящие PHP-тесты и конкурентный опыт. Это полезно для сравнения Windows CLI и Linux, но не заменяет испытание вашей инфраструктуры. Redis стенда эфемерный и ограничен по памяти; рабочую стратегию сохранения и восстановления квот нужно выбрать отдельно. Сам принцип Compose и управление несколькими сервисами подробно разобраны в нашем материале о Docker Compose.

docker compose up -d --build
docker compose exec -e TEST_REDIS=1 php php tests/run.php
docker compose exec php php bin/concurrency.php redis locked
docker compose logs --tail=50 php web redis
# После завершения именно этого стенда:
docker compose down

VPS: document root, PHP-FPM и переменные окружения

На сервере разместите проект, например, в /srv/rate-lab, но document root направьте только в public. Vendor, Composer lock, src и служебные каталоги не должны становиться файлами, доступными по HTTP. Конфигурации Nginx и Caddy в архиве показывают этот принцип; домен, socket PHP-FPM и TLS нужно привести к реальному окружению.

PHP-FPM часто очищает окружение worker. Поэтому export REDIS_URL в вашей SSH-сессии не гарантирует, что обработчик запроса получит значение. В deploy/php-fpm-env.conf есть пример параметров отдельного pool. Меняйте именно свой pool и затем проверяйте конфигурацию перед перезагрузкой сервиса. Не включайте учебные фиксированные токены в открытом рабочем API: доступ к лаборатории ограничивают, а в продукте используют настоящую аутентификацию.

Для файлового режима каталог var должен быть доступен пользователю FPM, а исходники могут оставаться только для чтения. Права 777 не являются нормальным способом исправления владельца. Для нескольких серверов выбирайте общее Redis-хранилище и блокировки. Если сервис управляется панелью хостинга, согласуйте изменения с её способом генерации конфигураций, иначе следующее сохранение сайта может перезаписать ручные настройки.

; Фрагмент отдельного PHP-FPM pool:
env[AEN_LAB_API] = 1
env[AEN_BACKEND] = redis
env[REDIS_URL] = tcp://127.0.0.1:6379
env[AEN_NAMESPACE] = aen_rate_lab_v1
; Включает именно закрытый учебный API, не production-auth.

IP, reverse proxy и ключи после аутентификации

IP-адрес может быть полезным признаком для анонимного ограничения, но он не равен пользователю. Несколько людей могут выходить через один NAT, а один клиент может менять адрес. За reverse proxy приложение часто видит адрес прокси; чтобы получить исходный, нужна настроенная доверенная цепочка. Нельзя просто взять первый X-Forwarded-For от любого отправителя.

Для авторизованного API обычно надёжнее строить ключ из проверенной учётной записи, организации или API key. IP можно оставить дополнительным уровнем, но не смешивать его смысл с тарифом клиента. Особенно осторожно проектируйте ограничение попыток входа: злоумышленник не должен легко блокировать другого человека, просто называя его логин в запросах.

В нашей мастерской эти вопросы намеренно видны в коде: публичного поля userId нет, фиксированный token map находится на сервере. Такой пример проще проверить и сложнее неправильно прочитать как разрешение доверять клиентскому имени. При переносе замените именно источник идентичности, сохранив идею отдельного стабильного ключа для понятного бюджета.

// Значения должны приходить из проверенного контекста приложения.
$verifiedTenantId = $authContext->tenantId;
$verifiedUserId = $authContext->userId;
$key = 'api:v1:'.$verifiedTenantId.':'.$verifiedUserId;
$limit = $factory->create($key)->consume(1);

Consume, reserve и ожидание в HTTP-worker

Consume удобно использовать, когда запрос должен сразу получить решение: действие разрешено либо клиенту нужно подождать. Reserve предназначен для сценария, где процесс действительно планирует дождаться своей возможности. Резервирование влияет на будущий бюджет; его нельзя использовать как безобидную проверку «есть ли свободное место», а затем забыть о зарезервированном действии.

В управляемой CLI-команде ожидание может быть уместно: например, экспорт последовательно обращается к внешнему API. В большом количестве HTTP-worker длительное ожидание занимает исполнителей и может ухудшить доступность остальных маршрутов. Часто лучше вернуть 429 или отложить работу в очередь, чем держать соединение открытым ради будущего токена.

Разделяйте лимит квоты и механизм планирования. Если нужен надёжный повтор фоновой задачи, сохраняемый прогресс и управление числом worker, используйте соответствующую очередь. У нас есть практическая статья о BullMQ, которая показывает эти обязанности на другом runtime. RateLimiter остаётся проверкой допустимого темпа, а не полноценным планировщиком задач.

// Пример для управляемого CLI-процесса:
try {
    $reservation = $limiter->reserve(1, 2.0); // максимум 2 секунды ожидания
    $reservation->wait();
    // Выполнить именно зарезервированную операцию.
} catch (\Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException $e) {
    // Отложить задачу или завершить ограниченный опыт.
}

Наблюдение: отказы, задержки и реальные причины

После внедрения полезно наблюдать число разрешений, число 429, ошибки хранилища и время ожидания блокировки. Эти показатели отвечают на разные вопросы. Большая доля 429 может означать слишком жёсткий тариф, ошибочный клиентский цикл или нормальное ограничение автоматизации. Рост 503 при недоступном Redis требует совсем другой реакции.

Не используйте полный пользовательский идентификатор или токен как метку метрики. Это создаёт множество рядов и может раскрыть лишние данные. Обычно достаточно класса операции, политики и результата, а подробный контекст ограниченно хранится в логах. Для измерения общей картины пригодятся подходы из нашей статьи о Prometheus.

Проверяйте поведение после deploy, рестарта Redis и изменения тарифа. Новое имя правила может создать свежий бюджет, а очищенный namespace — обнулить существующие лимиты. Такие события полезно журналировать отдельно от обычных отказов. Числа из короткой мастерской не показывают пропускную способность вашего сервера: задержка хранилища здесь специально искусственная, а количество процессов выбрано для проверки инварианта.

Что проверяют тесты и как повторять результаты

PHP-тесты проверяют основные свойства на реальном компоненте: независимость ключей, целостное списание стоимости, отказ при пустой квоте, сброс, сохранение между фабриками и ограничение при конкуренции. Redis-набор дополнительно проверяет недоступность backend и намеренно ошибочный опыт без lock. Отдельная команда verify сверяет запись с SHA256 исходников, чтобы старое измерение не выдавалось за результат изменённого кода.

JavaScript-тесты проверяют учебную модель, границы конструктора и поведение интерфейса. Некорректная запись отключает скачивание результата, повторная загрузка восстанавливает работу, устаревший ответ не заменяет свежий. Это проверки логики, а не замена адаптивному браузерному просмотру: расположение элементов, ширина таблицы и удобство кнопок проверяются отдельно.

При пересъёмке даты, PID и миллисекунды изменяются. Поэтому сравнивайте инварианты и смысл событий, а не требуйте побайтового совпадения времени. Если меняется алгоритм, зависимость или хранилище, сначала исследуйте новое поведение, затем обновляйте запись. Полезный артефакт должен объяснять, каким кодом, на какой версии и в каком окружении он был получен.

composer test
# Redis уже должен работать:
TEST_REDIS=1 composer test
composer capture
composer verify
npm test
npm run build

Как перенести механизм в свой сервис

Начните с одной конкретной дорогой операции и понятного бюджета. Определите, кто является владельцем квоты, какова стоимость действия, какой всплеск допустим и что пользователь увидит при отказе. Затем выберите алгоритм и запустите маленький опыт с границами окна, повтором после ожидания и двумя разными ключами.

После этого добавьте постоянное хранилище и проверьте несколько настоящих процессов. Отдельно отключите backend в тестовом окружении и убедитесь, что поведение соответствует выбранной политике. Проверьте, что отклонённый запрос не успевает выполнить дорогую часть. Только затем подключайте правило в реальный маршрут, наблюдайте отказы и корректируйте параметры по фактическому использованию.

Не превращайте готовый пример в неизменяемый шаблон. Файловое хранилище, общий Redis, тариф организации и внешний поставщик требуют разных решений. Архив даёт рабочую основу и проверяемые опыты, а статья помогает выбрать их место в системе. Главное — связать числа в конфигурации с понятным пользовательским сценарием и сохранить возможность объяснить каждое решение лимитера.

Исходники и официальные материалы

В архив входят готовая мастерская, девять записей, PHP API, CLI-примеры, Composer lock, тесты, полный Compose и конфигурации Linux/VPS. Собственный код распространяется по MIT; полные тексты лицензий сторонних пакетов приложены отдельно. Для просмотра не требуется устанавливать весь Symfony или поднимать удалённый сервис.

Официальное описание политик и API находится в документации Rate Limiter 7.4, устройство блокировок — в документации Lock. Пограничное поведение конкретной закреплённой версии можно изучить в исходниках Symfony RateLimiter 7.4.23. Для подключения Redis используем Predis.

Возвращайтесь к мастерской, когда выбираете политику: сравните одинаковый поток для fixed и sliding, измените стоимость, переключите пользователя, затем запустите скачанный PHP в своём окружении. Так остаётся видна связь между удобной интерактивной иллюстрацией и настоящим серверным решением, на которое будет опираться приложение.