Представьте: вы запускаете публичный API. Первые недели всё идёт гладко. Потом один клиент начинает слать по 500 запросов в секунду, база начинает захлёбываться, latency растёт, и остальные пользователи получают таймауты. Вы не подверглись DDoS-атаке - просто чей-то скрипт пошёл вразнос. Именно для таких ситуаций и существует ограничение частоты запросов.
Проблема не только в злоумышленниках. Легитимный клиент с багом в retry-логике способен положить сервис не хуже атаки. Поэтому защита нужна не как барьер против плохих людей, а как инфраструктурный слой, который делает поведение системы предсказуемым при любой нагрузке.
В этой статье разберём два самых распространённых подхода - token bucket и sliding window - с точки зрения практики: как они работают изнутри, чем отличаются, как реализовать каждый и в каких сценариях какой выбрать.
Коротко:
- Token bucket позволяет накапливать «кредиты» на запросы и хорошо переносит кратковременные всплески трафика.
- Sliding window считает запросы в скользящем временном окне и даёт более равномерное распределение нагрузки.
- Выбор алгоритма зависит от характера трафика: burst-нагрузка против равномерного потока.
- Самая частая ошибка - слишком жёсткий порог, который блокирует легитимных пользователей в пиковые моменты.
- В production почти всегда нужен Redis или аналогичное хранилище, чтобы лимиты работали корректно на нескольких инстансах.
Зачем вообще ограничивать запросы
Без каких-либо ограничений API ведёт себя как буфет без очереди: кто успел, тот и съел. Один активный клиент занимает все ресурсы, остальные ждут или получают ошибки. Это несправедливо и технически опасно.
Ограничение частоты решает несколько задач одновременно. Во-первых, защищает базу данных и downstream-сервисы от перегрузки. Во-вторых, обеспечивает справедливое распределение ресурсов между клиентами. В-третьих, даёт возможность монетизировать доступ - разные тарифные планы с разными лимитами. Наконец, снижает риск от багов в клиентском коде: если retry-логика сломалась, сервер не упадёт.
Важный нюанс: rate limiting - это не замена авторизации и не защита от целенаправленных атак. Это инструмент управления нагрузкой. DDoS на уровне L3/L4 нужно отражать другими средствами.
Как работает token bucket
Представьте ведро с токенами. В него с постоянной скоростью добавляются токены - например, 10 штук в секунду. Максимальная ёмкость ведра ограничена, скажем, 100 токенами. Каждый входящий запрос забирает один токен. Если ведро пустое - запрос отклоняется.
Ключевое свойство: токены накапливаются, пока клиент молчит. Если пользователь не делал запросов 5 секунд, у него накопилось 50 токенов. Он может разом отправить 50 запросов - и все они пройдут. Это называется burst capacity, и именно она делает алгоритм удобным для реальных сценариев использования.
Пример реализации на Python (упрощённо):
import time
class TokenBucket:
def __init__(self, rate: float, capacity: float):
self.rate = rate # токенов в секунду
self.capacity = capacity # максимум токенов
self.tokens = capacity # начинаем с полным ведром
self.last_refill = time.monotonic()
def allow(self) -> bool:
now = time.monotonic()
elapsed = now - self.last_refill
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.rate
)
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
Этот код работает для одного процесса. В реальном сервисе состояние нужно хранить в Redis, иначе каждый инстанс будет считать лимиты независимо, и клиент сможет умножить квоту на количество реплик.
В Redis классическая реализация использует атомарный Lua-скрипт или команду SET с NX и EX. Библиотека redis-py позволяет выполнять Lua-скрипты через register_script().
Как работает sliding window
Фиксированное окно (fixed window) - самый простой подход: считаем запросы за текущую минуту, сбрасываем счётчик в начале следующей. Проблема в том, что клиент может отправить 100 запросов в конце одной минуты и 100 в начале следующей - итого 200 за 2 секунды, хотя лимит 100 в минуту.
Скользящее окно решает эту проблему. Вместо жёсткой границы «с начала минуты» оно смотрит на последние N секунд относительно текущего момента. Если сейчас 12:00:45, окно охватывает период с 12:00:45 до 12:01:45 - и так постоянно сдвигается.
Есть два варианта реализации. Первый - sliding window log: храним временные метки каждого запроса, при новом запросе удаляем устаревшие метки и считаем оставшиеся. Точно, но дорого по памяти при высоком трафике.
Второй - sliding window counter: комбинируем два фиксированных окна (текущее и предыдущее) с весовым коэффициентом. Это приближение, но оно занимает O(1) памяти и достаточно точно для большинства задач.
Sliding window counter на Redis (псевдокод):
# Ключи для текущей и предыдущей минуты
current_key = f"ratelimit:{user_id}:{current_minute}"
prev_key = f"ratelimit:{user_id}:{current_minute - 1}"
current_count = redis.get(current_key) or 0
prev_count = redis.get(prev_key) or 0
# Доля предыдущего окна, которая ещё «видна»
elapsed_fraction = seconds_into_current_minute / 60
weighted = prev_count * (1 - elapsed_fraction) + current_count
if weighted < limit:
redis.incr(current_key)
redis.expire(current_key, 120)
return True # запрос разрешён
return False
Сравнение алгоритмов: что выбрать и когда
Оба подхода решают одну задачу, но по-разному реагируют на характер трафика. Вот ключевые отличия:
| Критерий | Token bucket | Sliding window |
|---|---|---|
| Поддержка burst-трафика | Да, накопленные токены тратятся разом | Нет, нагрузка распределяется равномерно |
| Точность подсчёта | Средняя (зависит от реализации) | Высокая (log) или приближённая (counter) |
| Потребление памяти | O(1) на клиента | O(1) для counter, O(n) для log |
| Сложность реализации | Низкая | Средняя |
| Граничный эффект окна | Отсутствует | Минимален (counter), отсутствует (log) |
Token bucket подходит, когда клиенты работают пакетами: загрузка файлов, синхронизация данных, мобильные приложения, которые накапливают события офлайн и отправляют пачкой при появлении сети. Накопленные токены позволяют обработать всплеск без ошибок.
Sliding window лучше работает там, где нужна равномерность: стриминговые API, чаты, биржевые данные в реальном времени. Здесь важно, чтобы нагрузка не скапливалась в одном моменте.
Практическое правило: если ваши клиенты - мобильные приложения или сторонние интеграции с непредсказуемым паттерном запросов, начните с token bucket. Если строите внутренний API с предсказуемым трафиком - sliding window даст более чистую картину в метриках.
Где хранить состояние: in-memory vs Redis
Хранение счётчиков в памяти процесса работает только для однопоточных приложений или случаев, когда один инстанс обслуживает всех клиентов. Как только появляется горизонтальное масштабирование, каждый инстанс начинает считать независимо.
Допустим, лимит 100 запросов в минуту, а у вас 5 реплик. Клиент может отправить по 99 запросов на каждую реплику - итого почти 500, и ни один лимит не сработает. Это не гипотетический сценарий: балансировщик нагрузки распределяет запросы, и без общего хранилища защита не работает.
Redis - стандартное решение для этой задачи. Атомарные операции (INCR, EXPIRE, Lua-скрипты) позволяют безопасно обновлять счётчики из нескольких процессов без race condition. Latency Redis в локальной сети обычно меньше 1 мс, что приемлемо для большинства API.
Альтернативы: Memcached (проще, но без Lua), Hazelcast, встроенные решения в API-шлюзах (Kong, AWS API Gateway, Nginx). Если вы уже используете один из этих инструментов - возможно, встроенный rate limiter покроет потребности без дополнительной инфраструктуры.
Как выставить правильные пороги
Это самый практически важный вопрос, и универсального ответа нет. Слишком мягкий лимит не защищает от перегрузки. Слишком жёсткий - блокирует нормальных пользователей и создаёт поток жалоб.
Хороший процесс настройки выглядит так:
Вакансии для разработчиков
- Сначала измерьте, потом ограничивайте. Включите логирование частоты запросов по клиентам на 2-4 недели без блокировок. Посмотрите на 95-й и 99-й перцентили. Это даст реальную картину нормального поведения.
- Установите начальный лимит с запасом. Если 99% клиентов делают не более 60 запросов в минуту, начальный лимит в 100-120 даст буфер для пиков без риска заблокировать нормальных пользователей.
- Разделите лимиты по типам операций. Чтение обычно дешевле записи. Имеет смысл разрешить 1000 GET-запросов в минуту, но только 100 POST.
- Добавьте разные уровни для разных клиентов. Внутренние сервисы, партнёры и публичные пользователи должны иметь разные квоты.
Следите за метрикой rate_limit_hits в разрезе клиентов. Если один клиент регулярно упирается в лимит, это сигнал либо поднять его квоту, либо разобраться, что именно он делает.
Типичные ошибки при реализации
Считать лимиты в памяти при нескольких репликах. Уже разобрали выше - это делает защиту иллюзорной.
Не возвращать заголовки с информацией о лимите. Клиент должен знать, сколько запросов у него осталось и когда сбросится счётчик. Стандарт де-факто - заголовки X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset и Retry-After при ответе 429. Без этого клиенты не могут адаптировать своё поведение и будут продолжать долбить API.
Применять один лимит ко всем эндпоинтам. Тяжёлый поиск с агрегацией и лёгкий health-check не должны иметь одинаковую квоту.
Не учитывать временные зоны и синхронизацию часов. Если используете фиксированные окна, убедитесь, что все инстансы работают с UTC и NTP настроен корректно. Рассинхронизация на несколько секунд может приводить к неожиданным сбросам счётчиков.
Блокировать по IP без учёта NAT. За одним IP может сидеть целый офис или мобильные пользователи одного оператора. Лимитировать нужно по API-ключу или токену авторизации, а не по адресу.
Не тестировать поведение при достижении лимита. Убедитесь, что клиентский код корректно обрабатывает 429 и не уходит в бесконечный retry без экспоненциальной задержки.
Готовые инструменты и библиотеки
Писать алгоритм с нуля имеет смысл только для глубокого понимания или специфических требований. В большинстве случаев лучше взять проверенное решение.
| Инструмент | Язык / платформа | Когда подходит |
|---|---|---|
| ulule/limiter | Go | Гибкая библиотека с Redis-бэкендом, поддерживает sliding window |
| throttled | Go | GCRA-алгоритм (вариант token bucket), хорошо документирован |
| slowapi | Python / FastAPI | Декораторы на эндпоинты, Redis или in-memory |
| DRF Throttling | Python / Django | Встроено в Django REST Framework, sliding window из коробки |
| Kong Rate Limiting | API Gateway | Не нужно менять код сервиса, настраивается через конфиг |
| AWS API Gateway | Managed | Token bucket на уровне инфраструктуры, без кода |
Если у вас уже стоит API-шлюз - проверьте его возможности прежде, чем добавлять библиотеку в код сервиса. Дублировать логику на двух уровнях обычно не нужно.
Чеклист перед запуском в production
- Состояние счётчиков хранится в общем хранилище (Redis или аналог), а не в памяти процесса.
- Обновление счётчиков атомарно - нет race condition при параллельных запросах.
- При превышении лимита возвращается статус 429, а не 500 или 403.
- Ответ содержит заголовки
X-RateLimit-Remaining,X-RateLimit-ResetиRetry-After. - Лимиты разделены по типам клиентов (публичные, партнёры, внутренние сервисы).
- Идентификация клиента идёт по API-ключу или токену, не по IP.
- Есть мониторинг метрики
rate_limit_hitsс разбивкой по клиентам. - Пороги выставлены на основе реальных данных о трафике, а не «на глаз».
- Клиентский SDK или документация описывают, как обрабатывать 429 с экспоненциальным backoff.
- Поведение при недоступности Redis определено: fail open (пропускать) или fail closed (блокировать).
Отдельно про fail open vs fail closed: если Redis недоступен, нужно заранее решить, что делать. Fail open - пропускать все запросы, пока хранилище не восстановится. Fail closed - блокировать. Первый вариант безопаснее для пользователей, второй - для инфраструктуры. Большинство команд выбирают fail open с алертом на недоступность Redis.
FAQ
Чем token bucket отличается от leaky bucket?
Leaky bucket выравнивает исходящий поток: запросы ставятся в очередь и обрабатываются с постоянной скоростью. Token bucket контролирует входящий поток и позволяет burst. На практике для защиты API чаще используют token bucket - он лучше соответствует реальному поведению клиентов.
Что такое GCRA и зачем он нужен?
Generic Cell Rate Algorithm - это вариант token bucket с более предсказуемым поведением. Вместо дискретных токенов он работает с «виртуальным временем следующего разрешённого запроса». Используется в библиотеке throttled для Go и в некоторых API-шлюзах. Хорош тем, что не допускает резких всплесков даже при накопленных «кредитах».
Нужно ли ограничивать запросы к внутренним сервисам?
Да, особенно если один сервис может неожиданно увеличить нагрузку на другой - например, при retry-шторме или при запуске массовой фоновой задачи. Внутренние лимиты обычно выше публичных, но они защищают от каскадных сбоев.
Как тестировать корректность работы ограничений?
Напишите интеграционный тест, который отправляет N+1 запросов и проверяет, что последний получает 429. Проверьте заголовки в ответе. Отдельно протестируйте поведение при недоступном Redis - замокайте хранилище и убедитесь, что сервис не падает с 500.
Можно ли использовать Nginx для ограничения запросов без Redis?
Да, Nginx имеет встроенный модуль ngx_http_limit_req_module с алгоритмом leaky bucket. Это работает на уровне одного инстанса Nginx. Если у вас несколько узлов Nginx, состояние не синхронизируется между ними - нужен либо один Nginx как точка входа, либо внешнее хранилище.
Как не заблокировать легитимных пользователей в пиковые моменты?
Используйте token bucket с достаточной burst capacity, собирайте данные о реальном трафике перед установкой лимитов, и добавьте мониторинг с алертами на аномальный рост метрики rate_limit_hits по конкретным клиентам. Если один клиент регулярно упирается в лимит - это повод пересмотреть его квоту, а не ужесточать политику.
Итог
Token bucket и sliding window - не конкуренты, а инструменты под разные задачи. Первый удобен там, где трафик приходит пачками и важно не наказывать клиента за естественные всплески. Второй даёт более строгое и равномерное ограничение, что полезно для API с предсказуемым потоком запросов.
Технически оба алгоритма несложны, но дьявол в деталях: общее хранилище для нескольких инстансов, атомарность операций, правильные HTTP-заголовки и осознанный выбор порогов на основе реальных данных. Без этих деталей даже правильно выбранный алгоритм не защитит сервис так, как должен.
Начните с измерения реального трафика, выберите алгоритм под характер нагрузки, возьмите готовую библиотеку или встроенные возможности шлюза - и потратьте основное время на настройку порогов и мониторинг, а не на написание алгоритма с нуля.