Недавно в одном стартапе команда развернула новый API без rate limiting. За три часа бот-скрипт сломался и прислал 12 миллионов запросов. Сервисы легли, база данных загорелась (образно), трафик клиентов ушёл. История банальна, повторяется еженедельно в разных командах. Почему? Потому что rate limiting — штука, которую замечают только когда всё падает. Давайте разбираться без лирики.
Что Нам Нужно На Самом Деле
Цели rate limiter:
- Защита инфраструктуры: чтобы один клиент не съел все ресурсы
- Предсказуемость latency: отсутствие внезапных 99-й перцентилей
- Контроль бизнес-логики: платные API, защита от спама
- Fairness: честный боевые условия для всех клиентов
Распространённая ошибка: считать rate limiting тривиальным if/else
. Начнём с выбора алгоритма.
Алгоритмы: Не Только Token Bucket
Token Bucket
class TokenBucket:
def __init__(self, capacity, refill_rate):
self.capacity = capacity
self.tokens = capacity
self.last_refill = time.time()
self.refill_rate = refill_rate # токенов в секунду
def _refill(self):
now = time.time()
elapsed = now - self.last_refill
new_tokens = elapsed * self.refill_rate
self.tokens = min(self.capacity, self.tokens + new_tokens)
self.last_refill = now
def consume(self, tokens=1):
self._refill()
if self.tokens >= tokens:
self.tokens -= tokens
return True
return False
Проблемы на продэ:
- Бурст-трафик создает очередь запросов в секунду 0
- Неточность при высокой нагрузке из-за рассинхронизации
last_refill
- Распределённая реализация требует атомарных операций
Fixed Window Counter
INCR user:1234
EXPIRE user:1234 60 # TTL = 60 seconds
Косяк границы окна:
Если лимит 1000 запросов в минуту:
- 10:59:30 – 500 запросов
- 11:00:10 – 600 запросов
Итог: 1100 запросов за 40 секунд. Леджер вычислительных ресурсов не спит.
Sliding Window Log
Сложнее, но точнее:
ZADD requests:user1234 * timestamp_micros timestamp_micros
ZREMRANGEBYSCORE requests:user1234 0 (now - window_size)
ZCARD requests:user1234
Атомарность в Redis? Только через Lua.
Реализация в Распределённом Мире: Redis + Lua
Одиночный сервер — миф для 2024 года. Варианты синхронизации:
- Redis
- Cassandra с lightweight transactions
- Memcached с
CAS
- Ваш оркестратор Kubernetes с distributed locks
Инвалидный Lua-скрипт для Redis (хрупкий):
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local current = tonumber(redis.call('GET', key) or "0")
if current + 1 > limit then
return 0
else
redis.call('INCR', key)
if current == 0 then
redis.call('EXPIRE', key, window)
end
return 1
end
Чем опасно:
- Гонки между
INCR
иEXPIRE
- Отсутствие плавающего окна
- Нет обработки ошибок типов данных
Промышленный вариант:
local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local fill_time = capacity / rate
local ttl = math.floor(fill_time * 2)
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
last_tokens = capacity
end
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
last_refreshed = now
end
local delta = math.max(0, now - last_refreshed)
local filled_tokens = math.min(capacity, last_tokens + (delta * rate))
local allowed = filled_tokens >= requested
if allowed then
filled_tokens = filled_tokens - requested
end
redis.call("setex", tokens_key, ttl, filled_tokens)
redis.call("setex", timestamp_key, ttl, now)
return allowed and 1 or 0
Здесь:
- Атомарные операции Redis гарантируют консистентность
- Вычисление токенов с учётом времени
- Оптимистичный TTL в
fill_time * 2
HTTP Headers: Договоримся с Клиентом
Сломанные клиенты бомбят сервер в ретраях — классика.
Отправляем в ответе:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700000000
Важно:
Retry-After
в секундах или по RFC 7231 (дата)Reset
— Unix timestamp! Не оставляйте разработчика парсить "через N секунд"- 429 vs 503? 429 — клиент виноват, 503 — проблемы сервера. Не путать.
Где Прячутся Чёрные Лебеди
Трафик Ботов: Не всё JSON
Фильтрация по User-Agent? Устарело. Решение:
import { Ratelimit } from '@upstash/ratelimit'
const customLimiter = new Ratelimit({
redis: redisClient,
limiter: Ratelimit.slidingWindow(30, '10 s'),
analytics: true,
prefix: 'custom',
timeout: 10000,
concurrency: 5,
});
Стратегии:
- Токены API vs. IP vs. Organization ID
- Tiered buckets:
/login
vs./api/data
- Падение пропускной способности для DDoS - кейса
Распределенный Redis и HA
Один инстанс Redis поражен? Устанавливайте кластер. Сценарий:
- Cross-slot keys — используйте hash tags:
{user123}
- Атомарность в кластере = Lua-скрипты (они выполняются на одном узле)
- Мониторинг:
127.0.0.1:6379> INFO STATS
# instantaneous_ops_per_sec: 12000
Если ops_per_sec
растёт экспоненциально точно создаёте условий для гибели.
Чек-лист для Code Review
- Всегда добавляет параметр
clientID
в механизмы ограничений? - Лимитируется ли три разных сценария (IP, пользователь, endpoint)?
- Тесты на граничные случаи:
def test_burst_no_throttle(bucket):
assert bucket.consume(1000) == True # capacity=1000
def test_window_edge_attack():
# ...
- Модульные тесты для Redis Lua?
- Метрики Prometheus:
rate_limited_requests_total{status="429"}
- Circuit Breaker для вашего rate limiter. Да, он тоже падает.
Заключение
Rate limiting — не "фича", а обязательный иудаизм инфраструктуры. Выбор алгоритма зависит от жесткости требований:
- Token bucket для бурст-трафика
- Sliding window для идеальной точности
- Fixed window когда можно играть границами
Вы пишете код, который сохраняет контроль над системой даже на скорости 100k RPM. У вас должно быть остаться понимание: это не колдующая библиотека, а инженерное решение с компромиссами.
Прямо завтра:
- Проверить expire ключей в Redis для вашего лимитера
- Протестировать сценарий с
4XX
ответами в API - Прислушаться к логгированию
WARN
от ваших middleware
Иначе следующий постбиржовой расследования будет о вас. Специально, не баг история.