Skip to content
PПромтбук
RUEN
04Архитектура

Дизайн rate limiting

Алгоритмы (token bucket, sliding window), хранилище, ключи, ответы 429 и реализация на проде.

Спроектируй rate limiting для {{service}}. Масштаб: {{scale}}.

1. Что защищаем и от чего

Прежде чем выбирать алгоритм — назови угрозы:

  • Abuse: brute force, credential stuffing, scraping
  • Fairness: один тяжёлый клиент не должен забирать ресурс у всех
  • Cost: внешний LLM/API на $/токен, нужен жёсткий потолок
  • Stability: защита backend от перегрузки upstream-сервиса

Разные угрозы — разные лимиты. Скан и API один лимит не покроют.

2. Алгоритмы и trade-offs

Fixed window

Счётчик: "X запросов в текущей минуте".

  • Плюс: тривиально (один INCR в Redis)
  • Минус: всплеск на границе окна — за 2 секунды можно сделать 2x лимит (конец минуты + начало следующей)
  • Когда: грубая защита, не точная

Sliding window log

Хранит timestamp каждого запроса, считает попавшие в окно.

  • Плюс: точно
  • Минус: память O(N) на ключ
  • Когда: малый объём, дорогие операции

Sliding window counter

Аппроксимация: текущее окно + взвешенный остаток предыдущего.

  • Плюс: точность близка к log, память O(1)
  • Минус: чуть сложнее реализация
  • Когда: общий случай для HTTP API

Token bucket

Bucket ёмкостью burst, пополняется со скоростью rate/sec. Запрос берёт 1 токен.

  • Плюс: позволяет короткие burst'ы выше среднего rate
  • Минус: чуть больше state
  • Когда: API где burst — нормальное поведение (загрузка, batch)

Leaky bucket

Очередь фиксированной длины, дренируется с постоянной скоростью.

  • Плюс: сглаживает поток к downstream
  • Минус: добавляет latency (буферизация)
  • Когда: защита медленного downstream от всплесков

Дефолт для HTTP API: sliding window counter или token bucket. Берёшь burst-friendly если нагрузка пиковая, sliding если важна предсказуемость для downstream.

3. Хранилище состояния

Single instance — in-memory (Map с TTL, бесплатно).

Multi-instance — нужен distributed store:

  • Redis — стандарт. INCR + EXPIRE или Lua-скрипт для atomic check-and-increment
  • Memcached — есть INCR, но нет atomic SET-if-not-exists с TTL → менее удобно
  • DynamoDB / встроенные: только если уже в стеке, иначе overkill

Lua-скрипт для sliding window в Redis:

-- KEYS[1] = bucket key
-- ARGV[1] = window seconds, ARGV[2] = limit, ARGV[3] = now (ms)
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[3] - ARGV[1] * 1000)
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[2]) then
  redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3])
  redis.call('EXPIRE', KEYS[1], ARGV[1])
  return {1, ARGV[2] - count - 1}
end
return {0, 0}

Atomic, без race между check и increment.

4. Ключ лимита (кого считаем)

От грубого к точному:

  • IP — простой, но прокси/NAT/мобильные сети дают false positive
  • User ID (если авторизован) — самый честный
  • API key — для машин
  • Tenant / организация — для shared плана
  • Combinationtenant + user + endpoint для fine-grained

Анонимные эндпойнты (signup, login) — IP + endpoint, плюс CAPTCHA после X неудач.

5. Уровни лимитов

Несколько слоёв одновременно:

  • Global: общий потолок на инстанс/cluster — защита backend
  • Per tenant: SLA-based (free: 100/min, pro: 10k/min)
  • Per user: защита от единичного злоупотребления внутри tenant
  • Per endpoint: дорогие операции (POST /search) жёстче чем дешёвые (GET /me)

Принимаешь min из всех применимых.

6. Ответ 429

Что отдавать:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1700000000

{ "error": "rate_limited", "retry_after_seconds": 30 }
  • Retry-After — обязательно. Клиенты на нём строят backoff
  • RateLimit-* заголовки — IETF draft, помогают клиенту планировать
  • Никогда не 200 с "limited" в теле — ломаешь semantics

Возвращай 429 и на разрешённые запросы тоже отправляй RateLimit-Remaining — клиенты видят, когда близко к пределу, и саморегулируются.

7. Graceful degradation

Если Redis упал — не паникуй:

  • Fail open: пропускать запросы (риск abuse, но сервис жив)
  • Fail closed: блокировать (безопасно, но downtime)

Выбор зависит от угрозы. Для billing API — closed, для публичного content API — open. Логируй и алёрти на режим.

Локальный fallback: token bucket в памяти инстанса с консервативным лимитом — лучше чем ничего.

8. Distributed ловушки

  • Часы инстансов — sliding window зависит от now(). Используй Redis TIME, не local clock
  • Network round-trip в Redis на каждый запрос — добавь local cache на 100-500ms (особенно для разрешённых)
  • Hot keys — если все запросы идут на один tenant, его ключ становится горячим. Шарди tenant:<id>:<shard> где shard = hash(req_id) % N

9. Whitelist / bypass

  • Internal services — bypass header с подписью (HMAC)
  • Health checks — exclude path
  • Admin / on-call — отдельные ключи без лимита
  • Whitelist всегда логируется, чтобы заметить злоупотребление

10. Метрики

  • 429 rate per endpoint / tenant
  • Distribution: какие ключи чаще всего упираются (топ-10)
  • p99 latency limiter'а самого по себе
  • Redis errors (fallback rate)
  • "Близко к лимиту" alerts (>80% использования)

Анти-паттерны

  • Rate limit на nginx без коммуникации с приложением — приложение не знает почему 429
  • Один лимит на всё ("1000 req/min на user") без учёта стоимости операции
  • Не возвращать Retry-After — клиенты будут долбить
  • Hash IP-адреса как ключ "ради приватности" → лимит работает, но дебажить невозможно
  • Использовать sliding window log на high-RPS endpoint — память взорвётся
  • Использовать только in-memory limiter за load balancer'ом — фактический лимит = ваш × число инстансов

В конце

  • Выбранный алгоритм и почему именно он
  • Схема ключей и уровней лимитов с конкретными числами
  • Реализация (псевдокод или Lua-скрипт)
  • Поведение при отказе Redis
  • Формат 429 ответа
  • Метрики и алерты
К подразделу «Архитектура»
Похожие промты