Ограничение частоты (rate limiting)

Как с помощью пары команд Redis не дать клиенту положить ваш сервис тысячей запросов в секунду — и при этом не наказать честных пользователей.

Ограничение частоты (rate limiting) — это политика «не больше N действий за интервал T для данного ключа» (пользователя, IP, токена API). Redis хранит счётчики этих действий в памяти и обновляет их атомарно, поэтому лимит соблюдается одинаково на всех инстансах приложения.

Зачем это нужно на практике

Лимиты защищают от перебора паролей, парсеров, случайных циклов в чужом коде и просто от неравномерной нагрузки. Они же — основа тарификации API («1000 запросов в час на бесплатном плане»). Делать это в памяти приложения нельзя: за балансировщиком запросы одного клиента попадут на разные инстансы, и каждый насчитает свой лимит. Redis выступает общим, быстрым и атомарным счётчиком для всего парка серверов.

Фиксированное окно: счётчик с TTL

Самый простой алгоритм. Делим время на фиксированные окна (например, поминутные) и для каждого окна держим счётчик с автосбросом. Ключ включает идентификатор и метку окна; первый запрос в окне выставляет срок жизни, равный длине окна.

INCR rate:user42:202606271530   # вернёт 1, 2, 3, ... — номер запроса в этом окне
EXPIRE rate:user42:202606271530 60   # ключ живёт 60 секунд и сам исчезнет
# если INCR вернул значение > лимита — запрос отклоняем (HTTP 429)

Здесь INCR атомарно создаёт ключ со значением 1 при первом обращении и наращивает его дальше. Когда счётчик превышает лимит, возвращаем клиенту ответ «слишком много запросов». Плюс подхода — предельная простота и дешевизна (один-два вызова). Минус — стык окон: если лимит 100/мин, клиент может сделать 100 запросов в последнюю секунду одной минуты и ещё 100 в первую секунду следующей — итого 200 за пару секунд, формально не нарушив лимит.

Скользящее окно: лог временных меток

Скользящее окно убирает проблему стыка: оно считает запросы не в календарной минуте, а за последние 60 секунд от текущего момента. Один из способов — хранить в sorted set временные метки запросов (score = время), при каждом обращении удалять устаревшие и считать, сколько осталось.

ZREMRANGEBYSCORE rate:user42 0 (now-60)   # выбросить метки старше окна
ZADD rate:user42 now now                  # добавить текущий запрос
ZCARD rate:user42                          # сколько запросов в окне сейчас
EXPIRE rate:user42 60                      # подчистить ключ, если клиент затих

Логику удобно увидеть на Python-модели с очередью меток: окно «скользит» вместе со временем.

# Скользящее окно через лог временных меток: считаем запросы за последние WINDOW секунд.
from collections import deque

WINDOW = 60     # окно 60 секунд
LIMIT = 5       # не больше 5 запросов в окне

hits = deque()  # сюда складываем моменты времени принятых запросов

def allow(now):
    # выбрасываем всё, что старше окна (вышло за левую границу)
    while hits and hits[0] <= now - WINDOW:
        hits.popleft()
    if len(hits) < LIMIT:
        hits.append(now)
        return True
    return False

# Шесть запросов на 10-й, 20-й, 30-й, 40-й, 50-й и 55-й секундах
for t in [10, 20, 30, 40, 50, 55]:
    print(f"t={t:>2}s -> {'разрешён' if allow(t) else 'отклонён'} (в окне: {len(hits)})")

# Прошло время: на 121-й секунде старые метки выпали из окна — снова можно
print(f"t=121s -> {'разрешён' if allow(121) else 'отклонён'} (в окне: {len(hits)})")

Вывод:

t=10s -> разрешён (в окне: 1)
t=20s -> разрешён (в окне: 2)
t=30s -> разрешён (в окне: 3)
t=40s -> разрешён (в окне: 4)
t=50s -> разрешён (в окне: 5)
t=55s -> отклонён (в окне: 5)
t=121s -> разрешён (в окне: 1)

На 55-й секунде в окне уже 5 запросов — отказ. К 121-й секунде все ранние метки вышли за границу окна, и запрос снова проходит. Точность платится памятью: храним метку на каждый запрос.

Token bucket: ровный поток с правом на всплеск

Token bucket (ведро с токенами) — самый гибкий алгоритм. Есть ведро ёмкостью на N токенов, которое пополняется с постоянной скоростью r токенов в секунду. Каждый запрос забирает один токен; нет токенов — отказ. Ёмкость задаёт допустимый всплеск (burst), а скорость пополнения — средний долгий темп.

# Token bucket: вёдро ёмкостью CAPACITY, пополняется RATE токенов в секунду.
# Каждый запрос «съедает» 1 токен; нет токена — отказ.
CAPACITY = 5     # вместимость ведра (burst)
RATE = 1.0       # 1 токен в секунду

tokens = CAPACITY      # стартуем с полным ведром
last = 0.0             # время последнего пополнения

def allow(now):
    global tokens, last
    # сначала доливаем токены за прошедшее время, но не выше ёмкости
    tokens = min(CAPACITY, tokens + (now - last) * RATE)
    last = now
    if tokens >= 1:
        tokens -= 1
        return True
    return False

# Всплеск: 7 запросов в одну и ту же секунду (now=0)
for i in range(1, 8):
    ok = allow(0)
    print(f"burst #{i}: {'ok' if ok else 'reject'} (токенов осталось: {tokens:.1f})")

# Подождали 3 секунды — натекло 3 токена
print(f"t=3s   : {'ok' if allow(3) else 'reject'} (токенов осталось: {tokens:.1f})")

Вывод:

burst #1: ok (токенов осталось: 4.0)
burst #2: ok (токенов осталось: 3.0)
burst #3: ok (токенов осталось: 2.0)
burst #4: ok (токенов осталось: 1.0)
burst #5: ok (токенов осталось: 0.0)
burst #6: reject (токенов осталось: 0.0)
burst #7: reject (токенов осталось: 0.0)
t=3s   : ok (токенов осталось: 2.0)

Первые 5 запросов прошли за счёт полного ведра (разрешённый всплеск), дальше — отказ, а через 3 секунды натекли новые токены. В Redis такое ведро держат хешем из двух полей (остаток токенов и время последнего пополнения) и пересчитывают атомарно одним Lua-скриптом.

Как это работает под капотом

Все три алгоритма опираются на два свойства Redis: атомарность операций и истечение ключей. INCR и операции над sorted set выполняются по одной в однопоточном сервере, поэтому два инстанса приложения не «потеряют» инкремент при гонке. TTL снимает с приложения заботу об очистке: фиксированное окно само исчезает по сроку, скользящее окно подчищается удалением старых меток. Когда между чтением и записью нужна сложная логика (как в token bucket: «долить, проверить, списать»), её заворачивают в Lua-скрипт, чтобы весь пересчёт прошёл атомарно на сервере и конкурирующие запросы не увидели промежуточного состояния ведра.

Частые ошибки

  • Лимит в памяти приложения. За балансировщиком каждый инстанс считает свой лимит — общий счётчик должен жить в Redis.
  • Фиксированное окно для строгого лимита. На стыке окон можно пропустить почти двойную норму; где это важно — скользящее окно или token bucket.
  • INCR без EXPIRE. Счётчик не сбросится, ключи накопятся и «съедят» память. Срок ставьте при первом инкременте окна.
  • Скользящее окно на огромном RPS. Хранить метку каждого запроса дорого; для высоких нагрузок дешевле token bucket с двумя числами.
  • Молчаливый отказ. Превышение лимита возвращайте честным кодом 429 и, по возможности, заголовком Retry-After, иначе клиент будет долбить ещё сильнее.

Итоги

  • Rate limiting в Redis = общий атомарный счётчик действий с автосбросом по TTL, одинаковый для всех инстансов.
  • Фиксированное окно (INCR + EXPIRE) — простейшее и дешёвое, но допускает всплеск на стыке окон.
  • Скользящее окно по логу меток точнее, но хранит метку на каждый запрос.
  • Token bucket задаёт средний темп и допустимый всплеск двумя числами и считается атомарно через Lua.
  • Выбор алгоритма — это компромисс между точностью, памятью и допустимостью коротких всплесков.
Проверьте себя
1. Какой недостаток у простого rate limiting на фиксированном окне (INCR + EXPIRE)?
AОн не работает на нескольких инстансах приложения
BНа стыке двух соседних окон клиент может уложить почти двойную норму запросов за короткое время
CОн требует хранить временную метку каждого запроса
DСчётчик нельзя сбросить автоматически
2. Что в алгоритме token bucket задаёт ёмкость ведра (CAPACITY)?
AСредний долгий темп запросов в секунду
BРазмер допустимого кратковременного всплеска (burst) запросов
CВремя жизни ключа в Redis
DКоличество инстансов приложения