Rate limiting в Node без Redis: рабочие подходы и грабли
В половине случаев, когда мне нужен rate limiting, ставить Redis ради этого не хочется. Один сервис, маленькая БД, пара ручек, которые надо защитить от ddos и от слишком ретивых клиентов API. Расскажу, какие варианты у меня работают, в каких сценариях я что беру и где у каждого подхода закопана граната.
Когда rate limiting вообще нужен
На моих проектах список выглядит примерно так:
- Авторизация:
/login,/forgot-password,/verify-otp. Защита от brute force. - Регистрация: чтобы не залило ботами.
- API c платным внешним вызовом: уберечь себя от лишних расходов.
- Тяжёлые отчёты: чтобы пользователь случайно не повесил БД спамом.
Не везде нужен Redis. Чаще нужно «не больше 30 запросов в минуту с одного IP», а не «глобально по кластеру 1000 RPS».
Подход 1: in-memory на одном инстансе
Самый простой случай — один Node-процесс, один сервер. Тогда ничего лучше in-memory store не нужно. Использую express-rate-limit или его аналоги для других фреймворков:
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 10,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'Too many requests' },
});
app.post('/login', loginLimiter, loginHandler);10 попыток входа в минуту с одного IP — нормальный потолок для логина. standardHeaders: 'draft-7' добавляет современные заголовки RateLimit, по которым клиент понимает, сколько у него осталось.
Плюсы: нулевая инфраструктура, миллисекунда оверхеда. Минусы: при перезапуске процесса счётчики сбрасываются, и при горизонтальном масштабировании каждый инстанс считает свой бюджет.
Подход 2: token bucket вручную
Иногда хочется управлять алгоритмом тоньше: чтобы пользователь мог накопить несколько запросов, потом тратить их сразу. Это token bucket. Сам по себе алгоритм нехитрый.
type Bucket = { tokens: number; lastRefill: number };
const buckets = new Map<string, Bucket>();
const CAPACITY = 30;
const REFILL_PER_MS = 30 / 60_000; // 30 токенов в минуту
function takeToken(key: string, cost = 1): boolean {
const now = Date.now();
let b = buckets.get(key);
if (!b) {
b = { tokens: CAPACITY, lastRefill: now };
buckets.set(key, b);
}
const elapsed = now - b.lastRefill;
b.tokens = Math.min(CAPACITY, b.tokens + elapsed * REFILL_PER_MS);
b.lastRefill = now;
if (b.tokens < cost) return false;
b.tokens -= cost;
return true;
}Подход хорош, если у тебя ручки разной «цены». На лёгкий запрос тратим 1 токен, на тяжёлый отчёт — 5. Бюджет один, но нагрузка распределяется честно.
Чтобы Map не разрасталась, нужно периодически чистить старые ключи. Я делаю это просто: каждые пару минут проходим по записям и убираем те, у которых now - lastRefill > 5 * windowMs.
Подход 3: SQLite или Postgres как стор
Когда инстансов несколько, но Redis ставить не хочется, можно использовать БД, которая и так есть. Postgres со скромной таблицей вполне справляется с rate limit-ом, если запросов не миллионы в секунду. Я делаю так:
CREATE TABLE rate_limit (
key text PRIMARY KEY,
tokens double precision NOT NULL,
last_refill timestamptz NOT NULL
);async function takeToken(key: string, cost = 1): Promise<boolean> {
const result = await db.execute<{ ok: boolean }>(sql`
insert into rate_limit (key, tokens, last_refill)
values (${key}, ${CAPACITY - cost}, now())
on conflict (key) do update set
tokens = least(${CAPACITY},
rate_limit.tokens + extract(epoch from (now() - rate_limit.last_refill)) * ${REFILL_PER_SEC})
- case when (least(${CAPACITY},
rate_limit.tokens + extract(epoch from (now() - rate_limit.last_refill)) * ${REFILL_PER_SEC})
) >= ${cost} then ${cost} else 0 end,
last_refill = now()
returning (tokens >= 0) as ok
`);
return result.rows[0].ok;
}Этот upsert — одна команда, атомарная. Postgres сам решает блокировку по строке. На тысячах ключей в минуту работает уверенно. На сотнях тысяч — задумайся о выделенном Redis или в memory с шардированием по IP.
Подход 4: distributed без сетевого вызова
Если у тебя 2–3 инстанса и хочется честный счётчик, но Redis — слишком, можно склеить in-memory счётчики gossip-протоколом. Я использовала пакет rate-limiter-flexible с режимом RateLimiterCluster, который синхронизирует через IPC внутри pm2/Node-кластера.
import { RateLimiterClusterMaster } from 'rate-limiter-flexible';
// в master-процессе кластера:
new RateLimiterClusterMaster();
// в worker-процессе:
import { RateLimiterCluster } from 'rate-limiter-flexible';
const limiter = new RateLimiterCluster({
keyPrefix: 'login',
points: 10,
duration: 60,
});Подходит ровно для случая «один сервер, кластеризован Node-форками». Между серверами уже не работает — для этого нужен общий стор.
Что брать в ключе
Ключ — самое важное. От него зависит, что ты ограничиваешь.
- IP. Базовый вариант. Минус: NAT, корпоративные сети, мобильные операторы — много пользователей за одним IP.
- userId. После аутентификации. Хорошо для API.
- API key. Если есть.
- IP + endpoint. Логично для разных лимитов на разные ручки.
- email при регистрации/логине. Чтобы перебор паролей не делался по разным аккаунтам с одного IP.
Часто беру комбинацию: на login лимит и по IP, и по email отдельно.
За прокси и за CDN
Если приложение за nginx, Cloudflare или другим прокси, req.ip покажет адрес прокси, а не реальный клиентский. Лечится app.set('trust proxy', ...) в Express и аналогичными настройками в других фреймворках.
// если ровно 1 прокси перед Node:
app.set('trust proxy', 1);
// если за Cloudflare и потом nginx:
app.set('trust proxy', 2);Без этого req.ip для всех запросов одинаковый — лимит превращается в один общий счётчик на всех. Я ловила эту ошибку дважды, оба раза по логам было видно, что rate limit срабатывал слишком рано.
Правильные ответы
Когда лимит превышен, отдаёшь 429 и заголовки:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 10, 10;w=60
RateLimit-Remaining: 0
RateLimit-Reset: 30Retry-After — секунды, через которые можно повторить. RateLimit-* заголовки описывают политику. Многие клиенты (включая axios с perretry-плагином и большинство HTTP-клиентов) умеют их читать и ждать самостоятельно.
Особенные случаи
Логин: успехи не считаем
На /login я считаю только неудачные попытки. Иначе залогиненный пользователь, который активно работает, рискует получить 429 на легитимных запросах. express-rate-limit поддерживает skipSuccessfulRequests: true, и в кастомных лимитерах я делаю то же самое: take token только если статус ответа 4xx.
SSE и WebSocket
Долгое соединение не должно тратить токены раз в минуту. Подключение → один токен на handshake; внутри сессии лимит мы считаем по сообщениям, а не по байтам.
Подозрительные IP
Помимо обычного rate limit полезно держать список заблокированных IP отдельно. Если один IP сделал 100 ошибок 401 за сутки — добавляем его в blocklist на 24 часа. Это уже не «снижение нагрузки», а защита от брута.
Когда таки нужен Redis
Я ставлю Redis под rate limiting, если хотя бы одно из трёх:
- Больше 3 инстансов приложения, и нагрузка реально распределяется между ними равномерно.
- Нужны лимиты с горизонтом несколько суток (защита от подбора OTP по одному номеру).
- Уже есть Redis в инфраструктуре — нет смысла играться с БД.
В остальных случаях in-memory или Postgres-стор работают надёжно и без сетевого хопа.
Шпаргалка
- Один инстанс — in-memory с готовой библиотекой.
- Несколько инстансов и нет Redis — Postgres-таблица с upsert и token bucket.
- За прокси — обязательно
trust proxy. - Ключи: чаще IP + endpoint, на логине — IP + email.
- Считай неудачи, а не общие запросы, на чувствительных endpoint-ах.
- Возвращай 429 с заголовками
Retry-AfterиRateLimit-*.
Rate limiting — одна из тех вещей, которые лучше иметь сразу, чем добавлять под нагрузкой. Сам алгоритм простой, инфраструктура тоже не требуется. А вот без него можно очень обидно потерять и базу, и API-бюджет, и спокойный сон.