Rate limiting в API: алгоритмы token bucket и sliding window на практике

Rate limiting в API: алгоритмы token bucket и sliding window на практике

Представьте: вы запускаете публичный 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 bucketSliding 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 покроет потребности без дополнительной инфраструктуры.

Как выставить правильные пороги

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

Хороший процесс настройки выглядит так:

  1. Сначала измерьте, потом ограничивайте. Включите логирование частоты запросов по клиентам на 2-4 недели без блокировок. Посмотрите на 95-й и 99-й перцентили. Это даст реальную картину нормального поведения.
  2. Установите начальный лимит с запасом. Если 99% клиентов делают не более 60 запросов в минуту, начальный лимит в 100-120 даст буфер для пиков без риска заблокировать нормальных пользователей.
  3. Разделите лимиты по типам операций. Чтение обычно дешевле записи. Имеет смысл разрешить 1000 GET-запросов в минуту, но только 100 POST.
  4. Добавьте разные уровни для разных клиентов. Внутренние сервисы, партнёры и публичные пользователи должны иметь разные квоты.

Следите за метрикой 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/limiterGoГибкая библиотека с Redis-бэкендом, поддерживает sliding window
throttledGoGCRA-алгоритм (вариант token bucket), хорошо документирован
slowapiPython / FastAPIДекораторы на эндпоинты, Redis или in-memory
DRF ThrottlingPython / DjangoВстроено в Django REST Framework, sliding window из коробки
Kong Rate LimitingAPI GatewayНе нужно менять код сервиса, настраивается через конфиг
AWS API GatewayManagedToken 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-заголовки и осознанный выбор порогов на основе реальных данных. Без этих деталей даже правильно выбранный алгоритм не защитит сервис так, как должен.

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