Кеш в Node.js: разбор на живом проекте под нагрузкой
In-memory кеш в одном процессе Node.js держался до 40 rps, а на четырёх воркерах кластера начал отдавать устаревшие данные. Разбираем, почему так вышло и чем платить за каждое решение.

Сервис отдачи каталога на Node.js 20 держал каталог товаров в обычном объекте в памяти процесса. При 40 rps на одном инстансе всё работало: первый запрос читал из PostgreSQL, остальные шли из Map. Проблема пришла с ростом трафика. Мы запустили четыре воркера через cluster, и клиенты начали жаловаться, что цена товара то старая, то новая — от запроса к запросу. Кеш был у каждого воркера свой, и инвалидация в одном не долетала до трёх остальных.
Разбираем на этом примере, как ведёт себя кеш в Node.js при переходе от одного процесса к нескольким и к нескольким машинам, где именно ломается in-memory подход и во что обходится Redis. Инвалидация по событиям и прогрев холодного кеша — отдельные темы, здесь только хранилище и согласованность между процессами.
Что именно сломалось
Node.js исполняет JavaScript в одном потоке на процесс. Объект-кеш живёт в куче V8 этого процесса и невидим для соседей. Пока инстанс один, это удобно: доступ за наносекунды, никакой сериализации. Как только вы масштабируетесь горизонтально — через cluster, PM2 в режиме cluster или несколько подов в Kubernetes — у вас появляется N независимых копий кеша.
Симптом на нашем проекте выглядел так: администратор менял цену, бэкенд сбрасывал ключ товара в памяти воркера, обработавшего запрос на сохранение. Остальные три воркера продолжали отдавать старое значение до истечения TTL. Балансировщик раскидывал запросы по round-robin, поэтому пользователь видел то новую цену, то старую — в зависимости от того, какой воркер ответил.
In-memory кеш в кластере — это не один кеш на приложение, а столько кешей, сколько у вас процессов. Согласованности между ними нет по определению.
Четыре подхода и цена каждого
1. Объект или Map в памяти процесса
Самое быстрое чтение и запись, ноль зависимостей. Работает идеально ровно до тех пор, пока процесс один. Второй минус — рост кучи: без ограничения размера объект копит записи, пока V8 не упрётся в лимит heap и процесс не упадёт с JavaScript heap out of memory. Ручной Map не умеет вытеснять записи, придётся писать логику самому.
2. LRU-кеш в памяти с ограничением
Библиотека lru-cache (версия 10.x) решает проблему неограниченного роста: задаёте максимум записей или максимальный размер в байтах, старые вытесняются. TTL встроен. Но проблему рассинхронизации между процессами это не решает — кеш по-прежнему локальный.
// lru-cache 10.x, Node.js 20
import { LRUCache } from 'lru-cache';
const productCache = new LRUCache({
max: 5000, // не больше 5000 товаров в памяти
ttl: 1000 * 60 * 5, // 5 минут жизни записи
updateAgeOnGet: false // чтение не продлевает TTL
});
async function getProduct(id, db) {
const cached = productCache.get(id);
if (cached) return cached;
const product = await db.query(
'SELECT * FROM products WHERE id = $1',
[id]
);
productCache.set(id, product.rows[0]);
return product.rows[0];
}3. Redis как общий кеш
Вынос кеша во внешний Redis (мы взяли клиент ioredis 5.x) делает хранилище общим для всех процессов и машин. Инвалидация в одном воркере видна всем. Цена: каждый доступ к кешу теперь сетевой вызов с сериализацией в JSON, плюс новый компонент, который надо мониторить и который сам может стать точкой отказа.
// ioredis 5.x, Node.js 20
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379
});
async function getProduct(id, db) {
const key = `product:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const product = (await db.query(
'SELECT * FROM products WHERE id = $1',
[id]
)).rows[0];
// EX задаёт TTL в секундах
await redis.set(key, JSON.stringify(product), 'EX', 300);
return product;
}
// сброс при изменении товара — виден всем процессам
async function invalidateProduct(id) {
await redis.del(`product:${id}`);
}4. Двухуровневый кеш: LRU в памяти + Redis
Комбинация. Горячие данные лежат локально в LRU (доступ без сети), Redis служит общим уровнем и источником инвалидации. Читаем сначала из памяти, при промахе — из Redis, при промахе там — из базы. Это быстрее чистого Redis на горячих ключах, но возвращает проблему рассинхронизации локального уровня: чтобы сбросить память во всех процессах, нужен механизм рассылки, например Redis Pub/Sub. Сложность растёт заметно, берите этот вариант, только когда сетевые задержки Redis реально стали узким местом по замерам.
Сравнение
| Подход | Общий для процессов | Задержка чтения | Зависимости | Риск |
|---|---|---|---|---|
| Map в памяти | Нет | Наносекунды | Нет | Рост кучи, рассинхрон |
| lru-cache | Нет | Наносекунды | 1 пакет | Рассинхрон между процессами |
| Redis | Да | Сетевой вызов (0,2–1 мс в локальной сети) | Redis + клиент | Точка отказа, сериализация |
| LRU + Redis | Частично | Наносекунды на попадании в память | Redis + 2 пакета + Pub/Sub | Сложность инвалидации памяти |
Цифры задержки Redis приблизительные и зависят от вашей сети и нагрузки на инстанс. Мерьте redis.ping() под своим трафиком, а не берите числа из таблицы как данность.
Как мерить, что кеш вообще помогает
Прежде чем менять архитектуру, снимите метрики на текущей. Минимум, что стоит считать:
- hit rate — доля попаданий в кеш от всех обращений. Ниже 70–80% на горячих данных — повод пересмотреть TTL или ключи;
- задержку источника — сколько стоит промах: время запроса в PostgreSQL. Если база отвечает за 2 мс, а Redis добавляет 1 мс сети, выигрыш от общего кеша сомнителен;
- размер кучи процесса —
process.memoryUsage().heapUsedв динамике. Растущий без остановки график на in-memory кеше без лимита — предвестник падения.
Где всё это ломается
Граничные случаи, на которых мы обожглись или чуть не обожглись:
- Cache stampede. Ключ протух, и сто одновременных запросов дружно пошли в базу за одним и тем же значением. При in-memory это бьёт по каждому процессу отдельно, при Redis — по общей базе разом. Лечится блокировкой на перестроение (single-flight) или probabilistic early expiration;
- Хранение больших объектов в Redis. JSON на сотни килобайт — это сериализация на запись, десериализация на чтение и трафик по сети на каждом обращении. Иногда дешевле держать такое локально в LRU;
- TTL как единственная стратегия. Пять минут устаревания приемлемы для описания товара и недопустимы для остатка на складе. Разные данные — разные TTL, а критичные — сброс по событию, а не по таймеру;
- Падение Redis. Если код при недоступности Redis не умеет ходить в базу напрямую, отказ кеша превращается в отказ сервиса. Оборачивайте вызовы Redis так, чтобы при ошибке был фоллбэк на источник.
Что мы выбрали и что делать дальше
На нашем каталоге при четырёх воркерах и 300+ rps выбор пал на чистый Redis: рассинхронизация исчезла, задержка чтения выросла с наносекунд до долей миллисекунды, что на фоне запроса в PostgreSQL незаметно. Двухуровневую схему отложили — по замерам сетевой оверхед Redis не был узким местом.
Следующий шаг для вашего проекта: снимите hit rate и задержку источника на том, что есть сейчас. Если инстанс один и куча не растёт — оставайтесь на lru-cache с лимитом, это дёшево и быстро. Если процессов несколько и вы ловите рассинхрон — выносите кеш в Redis и сразу закладывайте фоллбэк на базу при его недоступности. Инвалидацию по событиям и защиту от stampede настраивайте отдельно, когда базовое хранилище устоялось.
Контент-конвейер: Из одной идеи — текст, изображение, озвучка и ролик. Один заказ вместо четырёх.
Спросить за 60 ₽Источники: Node.js Cluster — официальная документация, ioredis — репозиторий и документация
Частые вопросы
У меня один инстанс Node.js без кластера. Нужен ли мне Redis?
А если у меня Node.js 18, а не 20 — код сработает?
Почему кеш в кластере отдаёт разные значения на один запрос?
Не сработало: ioredis не подключается, пишет ECONNREFUSED. Что смотреть?
Как выбрать TTL, чтобы не отдавать устаревшие данные?
Что такое cache stampede и грозит ли он в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.