Ограничение частоты (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.
- Выбор алгоритма — это компромисс между точностью, памятью и допустимостью коротких всплесков.