Rate limiting и кэширование: начните без Redis, растите когда нужно
Когда проект начинает расти, две проблемы приходят почти одновременно: кто-то заваливает ваш API запросами (намеренно или случайно), а база данных снова и снова выполняет один и тот же тяжёлый запрос. На первую отвечает rate limiting, на вторую — кэширование. Большинство, услышав эти два слова, сразу думает: «значит, нужен Redis». Я же скажу обратное: и то и другое можно начать на одном инстансе Node.js, без какой-либо внешней инфраструктуры — и для многих проектов этого этапа достаточно.
Почему эти двое ходят вместе
На первый взгляд rate limiting (защита) и кэширование (скорость) — разные темы. Но по сути оба отвечают на один вопрос: обязательно ли полностью обрабатывать каждый входящий запрос? Rate limiting спрашивает: «принимаем ли мы этот запрос вообще?». Кэширование говорит: «приняли, но считаем ли ответ заново или отдаём готовый?». Оба спасают самые дорогие ресурсы сервера — CPU и базу данных — от лишней работы.
Поэтому и архитектурные вопросы одинаковые: где хранить состояние (внутри процесса или в общем store), когда оно устаревает и что произойдёт, если инстансов станет больше. Поняв одно, вы автоматически понимаете и второе.
Алгоритмы rate limiting простым языком
Fixed window — самый простой: «60 запросов в минуту». На каждую минуту открывается счётчик, дошёл до 60 — получай 429. Недостаток на границе: если пользователь отправит 60 запросов в 11:00:59 и ещё 60 в 11:01:00, за две секунды пройдёт 120 запросов — лимит фактически нарушен вдвое.
Sliding window — исправляет эту проблему: считает по скользящему окну «последние 60 секунд». Точнее, но нужно хранить время каждого запроса или работать с приближённой формулой — чуть дороже.
Token bucket — мой любимый. Представьте: у каждого пользователя есть ведро, в которое капает 5 токенов в секунду, ёмкость ведра — 10. Каждый запрос съедает один токен. Есть токен — проходи, нет — жди. Эта модель лучше всего соответствует реальному трафику: пользователь может сделать короткий burst (8 параллельных запросов при открытии страницы — нормальная ситуация), но его средняя скорость всё равно ограничена. На практике для защиты API в большинстве случаев хватает token bucket — а знание двух остальных пригодится, чтобы понимать настройки готовых библиотек.
Начните с in-memory: token bucket на Map
Для одного инстанса Node.js достаточно обычного Map. Вот полностью рабочий Express middleware:
// rate-limit.js
const buckets = new Map();
const CAPACITY = 10; // ёмкость ведра (предел burst)
const REFILL_RATE = 5; // токенов в секунду
function isAllowed(key) {
const now = Date.now();
let bucket = buckets.get(key);
if (!bucket) {
bucket = { tokens: CAPACITY, updatedAt: now };
buckets.set(key, bucket);
}
// пополняем токены пропорционально прошедшему времени
const elapsedSec = (now - bucket.updatedAt) / 1000;
bucket.tokens = Math.min(CAPACITY, bucket.tokens + elapsedSec * REFILL_RATE);
bucket.updatedAt = now;
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
return true;
}
return false;
}
// чистим старые вёдра — иначе Map будет расти бесконечно
setInterval(() => {
const now = Date.now();
for (const [key, bucket] of buckets) {
if (now - bucket.updatedAt > 60_000) buckets.delete(key);
}
}, 30_000);
module.exports = function rateLimit(req, res, next) {
if (!isAllowed(req.ip)) {
res.set('Retry-After', '1');
return res.status(429).json({ error: 'Too many requests' });
}
next();
};Обратите внимание: отдельного таймера на каждое ведро нет — токены пополняются «лениво», в момент запроса, пропорционально прошедшему времени. Эти 40 строк спокойно работают в production; популярная библиотека express-rate-limit по умолчанию использует точно такой же in-memory store.
Когда in-memory перестаёт хватать
Пока инстанс один — всё в порядке. Теперь допустим, вы запустили 4 воркера в PM2 cluster mode: у каждого воркера свой Map, запросы распределяются между ними, и реальный лимит становится не 60, а примерно 240. Два сервера за load balancer — та же картина.
Здесь важный вопрос: насколько критична точность лимита? Если цель — просто защита («один IP не должен уронить сервер»), приблизительный лимит тоже справляется — каждый воркер контролирует свою долю, можно жить дальше. Но если лимит — это правило продукта («1000 запросов в день на Free-плане»), нужен точный общий счётчик, и именно в этот момент на сцену выходит Redis. Заметьте: Redis здесь нужен не как кэш, а как общий счётчик между инстансами. Библиотека rate-limiter-flexible поддерживает оба режима: начинаете с memory store, а когда понадобится, меняете один параметр конструктора на Redis — остальной код не меняется.
HTTP-кэширование: прежде чем писать код, напишите заголовок
Самый дешёвый запрос — тот, который вообще не дошёл до вашего сервера. Для этого нужен не код, а заголовок:
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');max-age=60 — браузер и CDN хранят ответ 60 секунд и в это время вообще не обращаются к серверу. stale-while-revalidate=300 — даже после истечения срока ещё 5 минут старый ответ отдаётся мгновенно, а новый загружается в фоне: пользователь никогда не ждёт.
ETag работает иначе: сервер отправляет вместе с ответом хэш контента, браузер в следующий раз спрашивает с If-None-Match. Если контент не изменился, сервер возвращает 304 — body не отправляется. Это экономит трафик, но сервер всё равно обрабатывает запрос — то есть ETag экономит канал, а не CPU. CDN вроде Cloudflare читают эти же заголовки: одна строка — и тысячи запросов получают ответ, не дойдя до сервера. Только осторожнее с персональными данными — ставьте private вместо public, иначе ответ одного пользователя через CDN может достаться другому.
Application cache: LRU + TTL
Результат запроса к базе, ответ внешнего API, тяжёлое вычисление — это не закэшируешь HTTP-заголовком, нужен кэш внутри приложения. Вот минимальный LRU + TTL кэш:
class LruCache {
constructor(maxSize = 500, ttlMs = 60_000) {
this.maxSize = maxSize;
this.ttlMs = ttlMs;
this.map = new Map();
}
get(key) {
const entry = this.map.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.map.delete(key);
return undefined;
}
// LRU: перемещаем использованную запись в конец
this.map.delete(key);
this.map.set(key, entry);
return entry.value;
}
set(key, value) {
if (this.map.size >= this.maxSize && !this.map.has(key)) {
// Map сохраняет порядок вставки — первая запись самая старая
this.map.delete(this.map.keys().next().value);
}
this.map.set(key, { value, expiresAt: Date.now() + this.ttlMs });
}
delete(key) {
this.map.delete(key);
}
}
const cache = new LruCache(1000, 30_000);
async function getProduct(id) {
const cached = cache.get(`product:${id}`);
if (cached) return cached;
const product = await db.products.findById(id);
cache.set(`product:${id}`, product);
return product;
}
async function updateProduct(id, data) {
const product = await db.products.update(id, data);
cache.delete(`product:${id}`); // инвалидация по событию
return product;
}Здесь две защиты: TTL ограничивает устаревание данных, а maxSize — память: при достижении предела вытесняется самая давно не использованная запись.
Теперь сложная часть. Инвалидация кэша — одна из двух знаменитых трудных проблем программирования, и я подхожу к ней с тремя практическими стратегиями. Первая — жить с TTL: если данные могут потерпеть 30–60 секунд устаревания (список товаров, статистика, посты блога), не делайте ничего — TTL сам всё решит. Большая часть данных на самом деле такая. Вторая — удаление по событию: как в updateProduct выше, при изменении записи удаляете её из кэша. Работает точно, но нельзя забыть ни одно место записи — поэтому собирайте записи в один сервисный слой. Третья — обе вместе: удаление по событию, но с TTL на всякий случай. Там, где вы забыли удалить, TTL сработает как страховка. В production я почти всегда выбираю третью.
Next.js ISR — член той же семьи
Если вы используете Next.js, вы всё это уже видели. revalidate: 60 — это stale-while-revalidate на уровне страницы: старый HTML отдаётся мгновенно, новый собирается в фоне. А revalidatePath — та самая инвалидация по событию: при изменении контента чистите кэш вручную. Мой сайт (Notion CMS + Next.js) работает ровно по этому принципу: пост меняется в Notion, сайт продолжает быстро отвечать из кэша, а в фоне обновляется. Для понимания ISR не нужна новая теория — это те же стратегии из этой статьи, поднятые на уровень фреймворка.
Вывод: сначала измерьте, потом Redis
Redis — отличный инструмент, но он должен быть ответом, а не значением по умолчанию. Мой порядок такой: сначала мониторинг — какой endpoint медленный, сколько одинаковых запросов уходит в базу, какой cache hit rate. Без измерений вы, добавив Redis, даже не узнаете, что выиграли. Затем in-memory решение: token bucket на Map, LRU + TTL кэш, правильные заголовки Cache-Control. И только при появлении чёткого сигнала — нужна точная квота на нескольких инстансах, кэш перестал помещаться в память одного процесса, потеря кэша при каждом деплое стала болезненной — добавляете Redis. И даже тогда код почти не меняется: меняется store, а логика остаётся тем же token bucket и тем же TTL, которые вы уже понимаете.