Ограничитель частоты считает, как часто что-то происходит, и отказывает, когда порог превышен. Подсчёт должен быть общим - счётчик внутри процесса умножает ваш лимит на число исполнителей и сбрасывается при каждом deploy, - и он должен быть атомарным, потому что два запроса, одновременно проверяющие «не пятый ли это?», не должны оба получить «да». Valkey хорош именно в этом: каждая команда выполняется по одной, INCR атомарен, ключи могут истекать сами, а Lua-скрипт выполняется как один непрерываемый шаг.
В ходу четыре алгоритма: фиксированное окно, скользящий журнал, скользящий оконный счётчик и token bucket. Каждый - это несколько строк поверх Valkey, и каждый по-своему ломается на краях. В этой статье мы построим все четыре, разберёмся, какой выбрать, и обсудим решения, которые важнее алгоритма: по какому ключу считать лимит, что делать, когда Valkey недоступен, и что отправлять клиенту.
Где место лимитам и при чём тут Valkey#
Не каждому лимиту нужно хранилище. Лимит в веб-сервере или обратном прокси - например, limit_req в nginx - останавливает трафик ещё до того, как он дойдёт до приложения, не требует Valkey и является правильным местом для грубой защиты от флуда по адресам. Этот уровень и то, что на нём считать, описаны в статье ограничение частоты: где ставить лимиты.
Valkey оправдывает себя для лимитов, которым нужны знания приложения:
- По аккаунту или ключу API, а не по адресу: «1 000 запросов в час на бесплатном тарифе».
- По действию: пять попыток входа на аккаунт за пятнадцать минут, три письма для сброса пароля в час, одна регистрация с адреса в минуту.
- На несколько процессов или серверов, где каждый должен видеть один и тот же счётчик.
- Квоты с бизнес-смыслом, например сообщения в сутки, которые должны переживать перезапуск.
Шаблон всегда один: построить ключ из того, что ограничиваете (rl:login:user:4182), выполнить над ним атомарную операцию, прочитать результат, решить.
Фиксированное окно: INCR и EXPIRE#
Простейший ограничитель считает запросы в окне, привязанном к часам, - эта минута, этот час - и сбрасывается, когда окно сменяется.
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, где оценкой служит временная метка.
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 секунд». Оценка такая:
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 выполняются атомарно: пока работает скрипт, никакая другая команда не выполняется.
-- 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 }Загрузите его один раз и вызывайте по хэшу:
$ 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.
Та же форма скрипта - прочитать состояние, решить, записать состояние - делает атомарным любой ограничитель, включая скользящий журнал, «считающий только принятые запросы».
Тестирование ограничителя, прежде чем ему доверять
Ограничитель - это код безопасности, и он заслуживает таких же тестов. Три из них стоит автоматизировать:
- Параллельность. Отправьте параллельно из нескольких процессов вдвое больше запросов, чем лимит, и посчитайте, сколько прошло. Если прошло больше лимита, что-то не атомарно - обычно чтение и запись в разных обращениях к серверу.
- Границы. Для окон отправьте весь лимит прямо перед границей окна и ещё раз сразу после. Вы должны увидеть ровно то поведение, которое выбрали: фиксированное окно пропускает оба всплеска, скользящий журнал - ни одного.
- Восстановление. Исчерпайте лимит, подождите объявленный
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.js | rate-limiter-flexible | Ограничители в стиле фиксированного окна и token bucket, хранилище на протоколе Redis |
| Node.js | express-rate-limit с rate-limit-redis | Middleware для Express с общим хранилищем |
| Python | limits, на нём построены Flask-Limiter и slowapi | Фиксированные и движущиеся окна на бэкенде с протоколом Redis |
| Django | django-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. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.