Rate Limiting на Проде: От Алгоритмов до Распределённых Реализаций

Недавно в одном стартапе команда развернула новый API без rate limiting. За три часа бот-скрипт сломался и прислал 12 миллионов запросов. Сервисы легли, база данных загорелась (образно), трафик клиентов ушёл. История банальна, повторяется еженедельно в разных командах. Почему? Потому что rate limiting — штука, которую замечают только когда всё падает. Давайте разбираться без лирики.

Что Нам Нужно На Самом Деле

Цели rate limiter:

  • Защита инфраструктуры: чтобы один клиент не съел все ресурсы
  • Предсказуемость latency: отсутствие внезапных 99-й перцентилей
  • Контроль бизнес-логики: платные API, защита от спама
  • Fairness: честный боевые условия для всех клиентов

Распространённая ошибка: считать rate limiting тривиальным if/else. Начнём с выбора алгоритма.

Алгоритмы: Не Только Token Bucket

Token Bucket

python
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

redis
INCR user:1234
EXPIRE user:1234 60  # TTL = 60 seconds

Косяк границы окна:
Если лимит 1000 запросов в минуту:

  • 10:59:30 – 500 запросов
  • 11:00:10 – 600 запросов
    Итог: 1100 запросов за 40 секунд. Леджер вычислительных ресурсов не спит.

Sliding Window Log

Сложнее, но точнее:

redis
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 (хрупкий):

lua
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
  • Отсутствие плавающего окна
  • Нет обработки ошибок типов данных

Промышленный вариант:

lua
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: Договоримся с Клиентом

Сломанные клиенты бомбят сервер в ретраях — классика.

Отправляем в ответе:

text
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? Устарело. Решение:

javascript
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-скрипты (они выполняются на одном узле)
  • Мониторинг:
bash
127.0.0.1:6379> INFO STATS
# instantaneous_ops_per_sec: 12000

Если ops_per_sec растёт экспоненциально точно создаёте условий для гибели.

Чек-лист для Code Review

  1. Всегда добавляет параметр clientID в механизмы ограничений?
  2. Лимитируется ли три разных сценария (IP, пользователь, endpoint)?
  3. Тесты на граничные случаи:
python
def test_burst_no_throttle(bucket):
    assert bucket.consume(1000) == True  # capacity=1000
    
def test_window_edge_attack():
    # ... 
  1. Модульные тесты для Redis Lua?
  2. Метрики Prometheus:
text
rate_limited_requests_total{status="429"}  
  1. Circuit Breaker для вашего rate limiter. Да, он тоже падает.

Заключение

Rate limiting — не "фича", а обязательный иудаизм инфраструктуры. Выбор алгоритма зависит от жесткости требований:

  • Token bucket для бурст-трафика
  • Sliding window для идеальной точности
  • Fixed window когда можно играть границами
    Вы пишете код, который сохраняет контроль над системой даже на скорости 100k RPM. У вас должно быть остаться понимание: это не колдующая библиотека, а инженерное решение с компромиссами.

Прямо завтра:

  1. Проверить expire ключей в Redis для вашего лимитера
  2. Протестировать сценарий с 4XX ответами в API
  3. Прислушаться к логгированию WARN от ваших middleware
    Иначе следующий постбиржовой расследования будет о вас. Специально, не баг история.