lenec ru

← все посты

Rate limiting в Node без Redis: рабочие подходы и грабли

18K

В половине случаев, когда мне нужен 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: 30

Retry-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-бюджет, и спокойный сон.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.