
Итого: ограничение частоты — это не «досадная блокировка», а механизм экономии и стабилизации сервиса.
Стоит мыслить слоями:
Комбинируйте: «60 rps на ключ, но не более 10 rps на /search и 2 rps на /export».
Для API чаще всего подходит токен‑бакет: он прост, быстр и гибко настраивается.
Почему Redis:
Схема ключей: rate:{scope}:{id}:{route}
Где:
Значение — хэш с полями tokens (остаток) и ts (последнее обновление в мс).
Скрипт: пополняет ведро по прошедшему времени, списывает токены за запрос, выставляет TTL и возвращает остаток и время до следующего токена.
-- token_bucket.lua
-- KEYS[1] - ключ ведра
-- ARGV[1] - capacity (максимум токенов)
-- ARGV[2] - refill_rate (токенов в секунду)
-- ARGV[3] - cost (стоимость запроса в токенах)
-- ARGV[4] - ttl_seconds (время жизни ключа)
-- Возвращает: {allowed (0/1), tokens_after, retry_after_ms}
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
-- Текущее время Redis в мс
local now_data = redis.call('TIME')
local now = tonumber(now_data[1]) * 1000 + math.floor(tonumber(now_data[2]) / 1000)
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then
tokens = capacity
ts = now
else
if now > ts then
local elapsed_ms = now - ts
local refill = (elapsed_ms / 1000.0) * rate
tokens = math.min(capacity, tokens + refill)
end
end
local allowed = 0
local retry_after_ms = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
local deficit = cost - tokens
-- сколько мс нужно, чтобы накопить дефицит
if rate > 0 then
retry_after_ms = math.ceil((deficit / rate) * 1000)
else
retry_after_ms = 2^31 - 1
end
end
-- Сохраняем состояние
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, ttl)
return {allowed, tokens, retry_after_ms}
В примере ограничим пользователя до 60 rps с «вёдром» на 120 токенов (может «взорваться» до 120 сразу), стоимость запроса — 1 токен. Для тяжёлого маршрута можно ставить cost=5.
# requirements: redis>=5.0.0
import time
import math
from redis import Redis
redis_client = Redis(host='localhost', port=6379, decode_responses=True)
with open('token_bucket.lua', 'r', encoding='utf-8') as f:
LUA_SCRIPT = f.read()
script = redis_client.register_script(LUA_SCRIPT)
def allow(scope: str, ident: str, route: str,
capacity: int = 120,
refill_rate: float = 60.0,
cost: int = 1,
ttl_seconds: int = 3600):
key = f"rate:{scope}:{ident}:{route}"
allowed, tokens_after, retry_after_ms = script(keys=[key], args=[capacity, refill_rate, cost, ttl_seconds])
return int(allowed) == 1, float(tokens_after), int(retry_after_ms)
# Пример: проверка перед обработкой запроса
user_id = 'user_42'
route = 'GET:/search'
ok, tokens, retry_ms = allow('user', user_id, route, capacity=120, refill_rate=60.0, cost=1, ttl_seconds=900)
if not ok:
# Возвращаем 429 с корректными заголовками
retry_after = math.ceil(retry_ms / 1000)
print(f"429 Too Many Requests. Retry-After: {retry_after}s")
else:
# Обрабатываем запрос
print(f"OK, tokens left: {tokens:.2f}")
Альтернатива «на входе» — базовая защита на уровне NGINX/Ingress. Это не заменяет бизнес‑лимиты, но даёт дешёвый барьер против шумных IP.
# nginx.conf (фрагмент)
# 100 запросов в секунду на IP, с «взрывом» до 200 (burst)
limit_req_zone $binary_remote_addr zone=perip:10m rate=100r/s;
server {
location /api/ {
limit_req zone=perip burst=200 nodelay;
proxy_pass http://backend;
}
}
Отдаём 429 Too Many Requests и подсказываем клиенту, когда повторить. Используйте стандартные поля RateLimit из RFC 9333:
Пример ответа:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
RateLimit-Limit: 60;w=1
RateLimit-Remaining: 0
RateLimit-Reset: 0.8
Retry-After: 1
{"error":"rate_limited","message":"Превышен лимит. Повторите запрос через ~1 сек."}
Для квот (месячных лимитов) вместо секунд ставьте понятный текст в теле, а заголовками давайте оставшийся баланс «кредитов», например X-Quota-Remaining (если не готовы к полям из RFC, но лучше придерживаться стандарта, когда возможно).
Совет: не ломайте UX. Если операция безопасна для повтора — подсказывайте это. Для небезопасных — используйте очереди и асинхронные задачи, а клиенту возвращайте 202 Accepted.
Часто нужен не только «rps‑ограничитель», но и квоты: N запросов в сутки/месяц по тарифу.
Технически: храните агрегаты в БД (PostgreSQL) с индексом по (tenant_id, period). Пополнение/списание — транзакции с идемпотентностью (ид операции), чтобы не списать дважды. Для онлайновой проверки при каждом запросе — кэшируйте в Redis, но источником истины держите БД.
Если у вас несколько инстансов/регионов, есть три подхода:
Централизованный Redis/Upstash/MemoryStore с низкой латентностью. Просто, но добавляет межрегионную задержку и точку отказа (решается репликацией и отказоустойчивыми кластерами).
Локальные лимиты + «мягкая» консистентность. Каждый регион держит свой Redis и лимитирует до доли от глобального лимита (например, 50/50). Подходит при независимых потоках.
Алгоритмы без координации (CRDT‑счётчики/сквозное хэширование клиентов к «ведру‑шарду»). Сложнее, но надёжнее на больших масштабах.
Рекомендация для 95% случаев: шардируйте ключи по консистентному хэшу на Redis‑кластер, включите репликацию и автоматический фейловер. На случай деградации — локальный «предохранитель» в процессе (in‑memory) с консервативными ограничениями, чтобы не улететь в безлимит.
Метрики:
Логи/трассировки:
Тесты:
Итог: хорошо настроенный рейт‑лимитинг и квоты снижают риски инцидентов, улучшают предсказуемость ответов и экономят бюджет, при этом оставаясь прозрачными и честными для клиентов.