RE:NODE

Базы данных11 мин чтения

Ограничение частоты запросов на Valkey: окна, ведра и Lua

Строим лимиты на Valkey: фиксированные и скользящие окна, token bucket, атомарные Lua-скрипты, по какому ключу считать и какие библиотеки уже делают это правильно.

0 прочтений

Ограничитель частоты считает, как часто что-то происходит, и отказывает, когда порог превышен. Подсчёт должен быть общим - счётчик внутри процесса умножает ваш лимит на число исполнителей и сбрасывается при каждом deploy, - и он должен быть атомарным, потому что два запроса, одновременно проверяющие «не пятый ли это?», не должны оба получить «да». Valkey хорош именно в этом: каждая команда выполняется по одной, INCR атомарен, ключи могут истекать сами, а Lua-скрипт выполняется как один непрерываемый шаг.

В ходу четыре алгоритма: фиксированное окно, скользящий журнал, скользящий оконный счётчик и token bucket. Каждый - это несколько строк поверх Valkey, и каждый по-своему ломается на краях. В этой статье мы построим все четыре, разберёмся, какой выбрать, и обсудим решения, которые важнее алгоритма: по какому ключу считать лимит, что делать, когда Valkey недоступен, и что отправлять клиенту.

Где место лимитам и при чём тут Valkey#

Не каждому лимиту нужно хранилище. Лимит в веб-сервере или обратном прокси - например, limit_req в nginx - останавливает трафик ещё до того, как он дойдёт до приложения, не требует Valkey и является правильным местом для грубой защиты от флуда по адресам. Этот уровень и то, что на нём считать, описаны в статье ограничение частоты: где ставить лимиты.

Valkey оправдывает себя для лимитов, которым нужны знания приложения:

  • По аккаунту или ключу API, а не по адресу: «1 000 запросов в час на бесплатном тарифе».
  • По действию: пять попыток входа на аккаунт за пятнадцать минут, три письма для сброса пароля в час, одна регистрация с адреса в минуту.
  • На несколько процессов или серверов, где каждый должен видеть один и тот же счётчик.
  • Квоты с бизнес-смыслом, например сообщения в сутки, которые должны переживать перезапуск.

Шаблон всегда один: построить ключ из того, что ограничиваете (rl:login:user:4182), выполнить над ним атомарную операцию, прочитать результат, решить.

Фиксированное окно: INCR и EXPIRE#

Простейший ограничитель считает запросы в окне, привязанном к часам, - эта минута, этот час - и сбрасывается, когда окно сменяется.

code
INCR  rl:api:key_abc:202610081412EXPIRE rl:api:key_abc:202610081412 60 NX

Номер окна входит в ключ (здесь это минута 14:12), так что новое окно - это просто новый ключ. INCR создаёт ключ со значением 1, если его нет, и возвращает новое значение счётчика; если оно выше лимита, отказывайте. EXPIRE ... NX ставит срок жизни, только если он ещё не задан, так что ключ сам убирается после окончания окна. Параметр NX у EXPIRE доступен начиная с набора команд 7.0, который Valkey унаследовал; на более старых серверах ставьте срок жизни, только когда INCR вернул 1.

Отправляйте обе команды в pipeline или в транзакции MULTI/EXEC, чтобы падение между ними не оставило счётчик без срока жизни - ключ, который никогда не истекает, означает пользователя, ограниченного навсегда.

Слабое место - граница. При лимите 100 в минуту клиент может отправить 100 запросов в 14:12:59 и ещё 100 в 14:13:00: 200 запросов за две секунды, и все разрешены. Для защиты входа это почти неважно. Для защиты дорогого endpoint - может быть важно.

АлгоритмПамять на клиентаВсплески на границеТочностьСложность
Фиксированное окноОдин маленький ключДо 2x лимитаНизкаяТривиальная
Скользящий журналОдна запись на запросНетТочнаяСредняя
Скользящий оконный счётчикДва маленьких ключаСглаженыПриблизительнаяНизкая
Token bucketОдин маленький hashРазрешены до размера ведра, так задуманоТочная скоростьСредняя

Скользящий журнал на sorted set#

Скользящий журнал записывает временную метку каждого запроса и считает, сколько из них попадает в последние N секунд. В Valkey это sorted set, где оценкой служит временная метка.

code
ZREMRANGEBYSCORE rl:api:key_abc 0 (now - 60000)ZADD             rl:api:key_abc now now:randomZCARD            rl:api:key_abcPEXPIRE          rl:api:key_abc 60000

Удалите всё старше окна, добавьте этот запрос, посчитайте, что осталось, и обновите срок жизни, чтобы ключи бездействующих клиентов исчезали. Элемент должен быть уникальным - два запроса в одну миллисекунду с одинаковым элементом схлопнутся в один, - поэтому добавляйте случайный суффикс или id запроса.

Это точно: никакой границы, которую можно эксплуатировать. Цена - память, пропорциональная лимиту. Лимит 10 в минуту хранит не больше 10 записей на клиента; лимит 10 000 в час - до 10 000. Для низких лимитов на чувствительные действия - входы, сброс пароля, одноразовые коды - скользящий журнал идеален. Для лимитов API с большим объёмом он расточителен.

Есть и тонкий выбор: считается ли отклонённый запрос? В последовательности выше - да, потому что он добавляется до подсчёта. Значит, клиент, долбящий endpoint, остаётся заблокированным, пока не остановится, а при переборе обычно именно это и нужно. Если вы хотите считать только принятые запросы, сначала проверьте счётчик и добавляйте, только если он ниже лимита, - но тогда проверка и добавление должны выполняться атомарно, для чего и нужен раздел о Lua ниже.

Скользящий оконный счётчик#

Скользящий оконный счётчик сохраняет два маленьких ключа фиксированного окна и приближает скользящее окно, взвешивая предыдущее.

Допустим, лимит - 100 в минуту, сейчас 14:13:15, в предыдущей минуте было 80 запросов, а в этой пока 30. Через пятнадцать секунд после начала текущего окна 45 секунд предыдущего всё ещё попадают в «последние 60 секунд». Оценка такая:

code
estimate = current + previous * (60 - 15) / 60         = 30 + 80 * 0.75         = 90      (allowed, under 100)

Метод предполагает, что запросы в предыдущем окне распределялись равномерно, поэтому он не точен, но убирает проблему двойного всплеска фиксированного окна и использует два счётчика независимо от лимита. Именно поэтому его используют многие крупные API-шлюзы. Со стороны Valkey это два GET и INCR со сроком жизни в два окна, в идеале в одном скрипте.

Token bucket в атомарном Lua-скрипте#

Token bucket допускает устойчивую скорость с запасом на всплеск. В ведре помещается до capacity токенов, и оно пополняется со скоростью rate токенов в секунду; каждый запрос забирает один. Клиент, который какое-то время молчал, может выдать всплеск до ёмкости ведра, а затем удерживается на скорости пополнения. Это самая естественная модель для API и та, которой атомарность нужнее всего: прочитать ведро, посчитать пополнение, забрать токен, записать обратно - всё одним шагом.

Lua-скрипты в Valkey выполняются атомарно: пока работает скрипт, никакая другая команда не выполняется.

token_bucket.lua
-- KEYS[1] bucket key; ARGV: capacity, refill per second, now in ms, costlocal capacity = tonumber(ARGV[1])local rate     = tonumber(ARGV[2])local now      = tonumber(ARGV[3])local cost     = tonumber(ARGV[4])local state  = redis.call("HMGET", KEYS[1], "tokens", "ts")local tokens = tonumber(state[1]) or capacitylocal ts     = tonumber(state[2]) or nowlocal elapsed = math.max(0, now - ts) / 1000tokens = math.min(capacity, tokens + elapsed * rate)local allowed = 0if tokens >= cost then  tokens = tokens - cost  allowed = 1endredis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)redis.call("PEXPIRE", KEYS[1], math.ceil(capacity / rate * 1000) + 1000)local retry_ms = 0if allowed == 0 then retry_ms = math.ceil((cost - tokens) / rate * 1000) endreturn { allowed, math.floor(tokens), retry_ms }

Загрузите его один раз и вызывайте по хэшу:

bash
$ valkey-cli --askpass -h 203.0.113.20 -p 6380 SCRIPT LOAD "$(cat token_bucket.lua)""3f1a..."$ valkey-cli --askpass -h 203.0.113.20 -p 6380 EVALSHA 3f1a... 1 rl:tb:key_abc 20 5 1791468000000 1

Большинство клиентских библиотек оборачивают это: они отправляют EVALSHA и откатываются на EVAL с полным скриптом, если сервер отвечает NOSCRIPT, - а это случается после перезапуска, потому что загруженные скрипты не сохраняются. Внутри скриптов Valkey принимает server.call как синоним redis.call; здесь используется redis.call, потому что он работает и в Valkey, и в Redis.

Замечания о скрипте:

  • Текущее время передаётся с клиента. Это сохраняет детерминированность скрипта, но означает, что каждому серверу приложения нужны точные часы. Если часы ваших серверов расходятся на секунды, ведра пополняются неправильно. Запустите NTP или вызывайте TIME внутри скрипта.
  • Срок жизни равен времени, за которое ведро полностью пополняется, плюс запас, так что бездействующие клиенты ничего не стоят.
  • `retry_ms` возвращается, чтобы вызывающий код мог отправить заголовок Retry-After.

Та же форма скрипта - прочитать состояние, решить, записать состояние - делает атомарным любой ограничитель, включая скользящий журнал, «считающий только принятые запросы».

Тестирование ограничителя, прежде чем ему доверять

Ограничитель - это код безопасности, и он заслуживает таких же тестов. Три из них стоит автоматизировать:

  1. Параллельность. Отправьте параллельно из нескольких процессов вдвое больше запросов, чем лимит, и посчитайте, сколько прошло. Если прошло больше лимита, что-то не атомарно - обычно чтение и запись в разных обращениях к серверу.
  2. Границы. Для окон отправьте весь лимит прямо перед границей окна и ещё раз сразу после. Вы должны увидеть ровно то поведение, которое выбрали: фиксированное окно пропускает оба всплеска, скользящий журнал - ни одного.
  3. Восстановление. Исчерпайте лимит, подождите объявленный Retry-After и убедитесь, что следующий запрос разрешён. Ошибка на единицу в арифметике сроков жизни проявится здесь в виде клиентов, заблокированных на одно окно дольше, чем вы им сказали.

Проверьте и путь отказа, направив ограничитель на порт, где никто не слушает: запрос должен быть разрешён или отклонён согласно вашему решению fail-open или fail-closed и в пределах вашего таймаута, а не через тридцать секунд. Запускайте эти тесты на настоящем Valkey, а не на заглушке: смысл в том, чтобы проверить атомарность, которую даёт сервер, а у заглушки её нет.

Выбор ключа: что вы на самом деле ограничиваете#

Алгоритм - это простая часть. Ключ решает, остановит ли лимит злоупотребления или будет раздражать клиентов.

  • По IP-адресу - единственный вариант для анонимного трафика и самый слабый. За обратным прокси каждый запрос приходит с адреса прокси, поэтому адрес клиента нужно читать из X-Forwarded-For - и доверять этому заголовку, только если его поставил ваш собственный прокси, иначе подделать его может кто угодно. Многие пользователи делят один адрес (офисы, мобильные операторы с CGNAT), поэтому лимиты по адресу должны быть щедрыми. Заголовок объясняется в статье что делает обратный прокси.
  • По аккаунту или ключу API - точно и честно. Используйте это для всего, что за аутентификацией.
  • По цели, для входа: ограничивайте попытки на имя пользователя так же, как на адрес, чтобы распределённую атаку на один аккаунт поймать даже тогда, когда каждый адрес пробует лишь раз.
  • По действию: отдельные ключи для разной стоимости. Endpoint поиска и чтение профиля не должны делить один бюджет.

Включайте в каждый ключ версию и назначение - rl:v1:login:user:4182, - чтобы смена алгоритма была новым префиксом, а не миграцией и чтобы --scan --pattern 'rl:*' находил их все.

Fail-open или fail-closed и что сообщать клиенту#

Решите, что происходит, когда Valkey недоступен, потому что рано или поздно это случится.

  • Fail-open (разрешить запрос) для общих лимитов API. Ограничитель, который роняет весь сайт, когда сам недоступен, превратил защиту в аварию.
  • Fail-closed (отказать) для лимитов, защищающих что-то ценное: попытки входа, сброс пароля, проверка одноразовых кодов, дорогие платные вызовы API.

В любом случае ставьте короткий клиентский таймаут - десятки миллисекунд, - чтобы медленный Valkey не добавлял секунды к каждому запросу.

Отказывая, возвращайте HTTP 429 Too Many Requests с заголовком Retry-After в секундах. Корректно написанные клиенты и SDK автоматически отступают, когда его видят. Многие API также отправляют оставшийся бюджет в каждом ответе, обычно как X-RateLimit-Limit, X-RateLimit-Remaining и X-RateLimit-Reset, или как стандартизированные заголовки RateLimit, черновик которых готовится в IETF. Что бы вы ни выбрали, задокументируйте это.

Библиотеки, которые уже это умеют#

Написать ограничитель полезно для понимания. Выпускать в продакшен собственный обычно не нужно.

СредаБиблиотекаПримечания
Node.jsrate-limiter-flexibleОграничители в стиле фиксированного окна и token bucket, хранилище на протоколе Redis
Node.jsexpress-rate-limit с rate-limit-redisMiddleware для Express с общим хранилищем
Pythonlimits, на нём построены Flask-Limiter и slowapiФиксированные и движущиеся окна на бэкенде с протоколом Redis
Djangodjango-ratelimitИспользует настроенный кэш, так что направьте кэш на Valkey
LaravelВстроенные RateLimiter и middleware throttleИспользует хранилище кэша; задайте драйвер кэша redis
ASP.NET CoreВстроенное middleware ограничения частотыВ памяти каждого процесса; для общего хранилища нужен сторонний пакет

Строка ASP.NET Core - это ловушка: AddRateLimiter начиная с .NET 7 работает на уровне процесса, так что два экземпляра применяют каждый полный лимит. Для защиты одного экземпляра от перегрузки это нормально, а для соблюдения квоты клиента - неправильно.

Настройки памяти и сохранения Valkey здесь важны меньше, чем для сессий или очередей. Ключи лимитов крошечные и короткоживущие; их потеря при перезапуске просто сбрасывает всем счётчики. Политика с вытеснением допустима для Valkey, который держит только лимиты. Если тот же экземпляр держит сессии или задачи, прежде чем выбирать, прочитайте статью память и политики вытеснения Valkey.

FAQ#

Какой алгоритм выбрать?

Скользящий журнал - для низких лимитов на чувствительные действия вроде входа, потому что он точен. Token bucket - для API, потому что он допускает разумные всплески и держит устойчивую скорость. Фиксированное окно - когда что-то нужно уже сегодня, а всплески на границе неважны. Скользящий оконный счётчик - хороший универсальный вариант по умолчанию, когда важна память на клиента.

Хватит ли транзакции MULTI/EXEC вместо Lua?

Для фиксированных окон - да: INCR и EXPIRE не зависят от результатов друг друга. Для всего, что читает значение и решает, что записать, - нет: MULTI ставит команды в очередь, но не может ветвиться по тому, что они возвращают. Для этого и нужен Lua-скрипт.

Сколько памяти занимают ключи лимитов?

Очень мало. Счётчик фиксированного окна или hash token bucket занимает гораздо меньше сотни байт плюс накладные расходы на ключ и истекает сам. Сто тысяч активных клиентов помещаются в несколько десятков мегабайт. Исключение - скользящие журналы: они хранят по записи на каждый запрос в окне.

Можно ли ограничивать по IP за Cloudflare или другим прокси?

Да, используя адрес клиента, который передаёт прокси, и только убедившись, что заголовок пришёл от вашего прокси. Читайте адрес из заголовка, который документирует ваш прокси, и игнорируйте его в запросах, пришедших не через прокси.

Сбрасывает ли перезапуск Valkey все лимиты?

Без сохранения данных - да. С включённым AOF счётчики переживают перезапуск, теряя не больше секунды инкрементов. Для лимитов частоты обычно годится любой вариант; для суточных квот с бизнес-смыслом держите сохранение включённым.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000