По умолчанию Next.js сохраняет кэш функций и компонентов в локальной файловой системе и оперативной памяти текущего процесса Node.js. В локальной среде разработки или на одиночном виртуальном сервере этого достаточно. Однако в современных производственных средах (Kubernetes, Docker Swarm, Nomad) веб-приложение развертывается в виде десятков горизонтально масштабируемых подов (реплик).
Ограничения стандартного локального кэша в кластере:
revalidateTag('catalog') на поде #1 кэш сбрасывается только в его локальной памяти. Поды со #2 по #20 продолжают отдавать устаревшие данные до собственного перезапуска.emptyDir или overlay-FS ограничен. Сохранение гигабайтов кэшированных RSC Flight Payload приводит к исчерпанию диска (Disk Pressure) и выселению подов (Pod Eviction).В Next.js 16 архитектура кэширования предоставляет нативный интерфейс cacheHandlers, позволяющий полностью делегировать хранение, поиск и инвалидацию внешним распределенным системам: Redis (для метаданных, тегов и быстрых записей) и S3 / MinIO (для тяжелых сериализованных деревьев компонентов).
Для достижения максимальной пропускной способности распределенный кэш организуется по трехуровневой схеме:
АРХИТЕКТУРА ТРЕХУРОВНЕВОГО КЭША В KUBERNETES:
┌────────────────────────────────────────────────────────────────────────┐
│ Kubernetes Кластер (40 подов Next.js) │
│ ┌───────────────────────┐ ┌───────────────────────────┐ │
│ │ Next.js Pod #1 │ │ Next.js Pod #2 │ │
│ │ ┌───────────────────┐ │ │ ┌──────────────────────┐ │ │
│ │ │ L1: In-Memory LRU │ │ │ │ L1: In-Memory LRU │ │ │
│ │ │ (latency < 0.1ms) │ │ │ │ (latency < 0.1ms) │ │ │
│ │ └─────────┬─────────┘ │ │ └──────────┬───────────┘ │ │
│ └───────────┼───────────┘ └─────────────┼─────────────┘ │
└──────────────┼────────────────────────────────────────┼────────────────┘
▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ L2: РАСПРЕДЕЛЕННЫЙ РАСПРЕДЕЛИТЕЛЬ МЕТАДАННЫХ (Redis Cluster / Sentinel)│
│ - Хранение тегов (Redis Sets) и TTL │
│ - Распределенные Mutex-блокировки (Защита от Thundering Herd) │
│ - Шина синхронизации инвалидации L1 через Redis Pub/Sub │
│ (latency: 1 - 2 ms) │
└───────────────────────────────────┬────────────────────────────────────┘
│ (для объектов > 64 КБ)
▼
┌────────────────────────────────────────────────────────────────────────┐
│ L3: BLOB STORAGE (S3 / MinIO / Ceph Object Store) │
│ - Неограниченное долговременное хранение тяжелых RSC Flight Payloads │
│ (latency: 15 - 35 ms) │
└────────────────────────────────────────────────────────────────────────┘lru-cache). Время отклика составляет менее 0.1 мс.cacheTag), компактные ответы и распределенные блокировки.Когда популярная запись кэша устаревает, тысячи одновременных клиентских запросов могут одновременно попытаться ее пересчитать. Распределенный CacheHandler использует Distributed Mutex:
ПРЕДОТВРАЩЕНИЕ THUNDERING HERD ЧЕРЕЗ REDIS LOCK:
Запрос 1 ──┐
Запрос 2 ──┼──► Cache Miss! ──► Попытка захвата замка: SET lock:key NX PX 5000
Запрос 3 ──┘ │
├─► Запрос 1: Захватил замок! ──► Выполняет I/O к БД
│
└─► Запросы 2, 3: Замок занят ──► Ожидают (или отдают Stale)
│
Запрос 1 сохраняет результат в кэш и освобождает замок ◄──────────────────┘
Запросы 2 и 3 мгновенно получают свежий результат из кэша!Рантайм запрашивает запись из локального In-Memory LRU текущего пода (менее 0.1 мс).
При промахе L1 выполняется MGET в Redis. Проверяются статус свежести и соответствие тегов.
При промахе под пытается установить распределенный лок в Redis с коротким TTL (5 сек).
Победивший под рендерит компонент, сохраняет Payload в L2/L3 и оповещает кластер через Redis Pub/Sub.
В кластере работает 30 подов Next.js. На поде #5 администратор изменил цену товара и вызвал updateTag('product:42'). Как гарантировать, что остальные 29 подов не продолжат отдавать устаревший товар из своего локального L1 памяти?
50 подов Next.js обслуживают до 150 000 RPS. L1 In-Memory отсекает 92% обращений прямо в оперативной памяти подов. Оставшиеся запросы разрешаются через Redis Cluster. База данных PostgreSQL испытывает стабильную нагрузку менее 5% от предельной мощности.
Аналитические финансовые отчеты компаний содержат тысячи строк и сериализуются в RSC Flight Payload объемом по 3–8 МБ. Хранение таких объемов в Redis быстро привело бы к переполнению RAM (OOM). Двухуровневый Handler сохраняет метаданные в Redis, а тело отчета стримит напрямую в MinIO S3.
При публикации экстренной новости на главную страницу одномоментно заходят 50 000 пользователей. Распределенный Mutex гарантирует, что тяжелое дерево главной страницы скомпилирует строго один рабочий процесс, а остальные 49 999 запросов получат готовый результат через 30 мс.
Кластеры в регионах EU и US обращаются к независимым репликам Redis с асинхронной кросс-региональной репликацией CRDT. Инвалидация тега в европейском дата-центре распространяется в американский кластер за доли секунды.
next.config.ts#В Next.js 16 кастомные обработчики кэша регистрируются через конфигурационную опцию cacheHandlers:
import type { NextConfig } from 'next';
import path from 'path';
const nextConfig: NextConfig = {
experimental: {
dynamicIO: true,
// Регистрация кастомных провайдеров кэша
cacheHandlers: {
default: path.resolve(__dirname, 'lib/cache/redis-cache-handler.js'),
remote: path.resolve(__dirname, 'lib/cache/tiered-s3-cache-handler.js'),
},
},
};
export default nextConfig;RedisCacheHandler с поддержкой тегов и L1 памяти#const { Redis } = require('ioredis');
const { LRUCache } = require('lru-cache');
// 1. Инициализация соединения с Redis Cluster / Standalone
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: 2,
enableReadyCheck: true,
lazyConnect: false,
});
const redisSub = redis.duplicate();
// 2. Инициализация локального L1 кэша в памяти процесса (макс. 500 элементов)
const localL1 = new LRUCache({
max: 500,
ttl: 1000 * 60 * 5, // 5 минут локальной жизни
});
// 3. Подписка на шину инвалидации L1 через Redis Pub/Sub
const INVALIDATION_CHANNEL = 'nextjs:cache:invalidate';
redisSub.subscribe(INVALIDATION_CHANNEL, (err) => {
if (err) console.error('Ошибка подписки на Redis PubSub:', err);
});
redisSub.on('message', (channel, message) => {
if (channel === INVALIDATION_CHANNEL) {
try {
const { tag, key } = JSON.parse(message);
if (key) {
localL1.delete(key);
} else if (tag) {
// Очистка L1 при сбросе тега
localL1.clear();
}
} catch (e) {
console.error('Ошибка разбора сообщения инвалидации:', e);
}
}
});
// Экспорт класса CacheHandler по спецификации Next.js 16
module.exports = class RedisCacheHandler {
constructor(options) {
this.options = options;
}
async get(key) {
// Шаг 1: Проверка L1 памяти текущего процесса
const memoryHit = localL1.get(key);
if (memoryHit) {
return memoryHit;
}
// Шаг 2: Проверка L2 Redis
try {
const rawData = await redis.get(`cache:entry:${key}`);
if (!rawData) return null;
const parsed = JSON.parse(rawData);
// Сохраняем в локальный L1 для последующих мгновенных чтений
localL1.set(key, parsed);
return parsed;
} catch (err) {
console.error(`[RedisCacheHandler] Ошибка чтения ключа ${key}:`, err);
return null;
}
}
async set(key, data, ctx) {
const { tags = [], lifespan } = ctx || {};
const expireSeconds = lifespan?.expire || 86400; // По умолчанию 24 часа
try {
const payload = JSON.stringify({
value: data,
tags,
lastModified: Date.now(),
});
const pipeline = redis.pipeline();
// Сохраняем саму запись с TTL
pipeline.set(`cache:entry:${key}`, payload, 'EX', expireSeconds);
// Индексируем теги в виде Redis Sets (обратный индекс)
for (const tag of tags) {
pipeline.sadd(`cache:tag:${tag}`, key);
pipeline.expire(`cache:tag:${tag}`, expireSeconds);
}
await pipeline.exec();
// Сохраняем в локальный L1
localL1.set(key, { value: data, tags, lastModified: Date.now() });
} catch (err) {
console.error(`[RedisCacheHandler] Ошибка записи ключа ${key}:`, err);
}
}
async revalidateTag(tags) {
const tagList = Array.isArray(tags) ? tags : [tags];
for (const tag of tagList) {
try {
// Находим все ключи, привязанные к данному тегу
const keys = await redis.smembers(`cache:tag:${tag}`);
if (keys.length > 0) {
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(`cache:entry:${key}`);
}
pipeline.del(`cache:tag:${tag}`);
await pipeline.exec();
}
// Транслируем событие во все поды кластера для сброса L1
await redis.publish(INVALIDATION_CHANNEL, JSON.stringify({ tag }));
} catch (err) {
console.error(`[RedisCacheHandler] Ошибка инвалидации тега ${tag}:`, err);
}
}
}
};Запрос проверяется в локальной оперативной памяти текущего пода. При попадании ответ возвращается за 0.05 мс без сетевых вызовов.
Используется LRU-кэш с ограничением по объему памяти.
При промахе L1 под отправляет команду GET в Redis. При успехе запись копируется в локальный L1.
Задержка составляет порядка 1-2 мс по внутренней сети Kubernetes.
При промахе в Redis выполняется атомарная инструкция SETNX для предотвращения одновременного пересчета десятками подов.
TTL блокировки выставляется в 5 секунд с автоматическим снятием.
Результат записывается в Redis, индексы тегов обновляются, а событие инвалидации транслируется через Redis Pub/Sub на все поды.
Гарантируется нулевой простой и мгновенная консистентность.
Что произойдет, если распределенный Mutex в Redis был создан БЕЗ указания параметра TTL (PX 5000), а рабочий под Node.js, захвативший этот лок, внезапно упал по Out-Of-Memory (OOM) до момента освобождения замка?
Попытка сохранять массивные RSC Flight Payload (размером 5–20 МБ) непосредственно в строковые ключи Redis приводит к быстрому исчерпанию лимита памяти (maxmemory-policy: noeviction) и блокировке потока событий (Event Loop) Redis при сериализации больших буферов.
Решение: Настройте разделение: объекты размером до 64 КБ храните в Redis, а более тяжелые payload сохраняйте в S3-совместимое объектное хранилище (MinIO), сохраняя в Redis только ссылку (URL/ключ объекта) и метаданные тегов.
Если сетевой адаптер Redis испытывает проблемы или кластер временно недоступен, некорректно написанный CacheHandler подвешивает все клиентские HTTP-запросы в ожидании таймаута сокета.
Решение: Оборачивайте операции с Redis в блоки try/catch с короткими таймаутами (connectTimeout: 500ms, maxRetriesPerRequest: 1). При сбое Redis обработчик обязан мягко деградировать (Fallback to Database/Bypass), возвращая null, чтобы сайт продолжал открываться.
Использование простого объекта const cache = {} вместо специализированной структуры LRU (lru-cache) приводит к бесконечному росту потребления памяти процессом Node.js и аварийному завершению пода по сигналу SIGKILL (Kubernetes OOMKilled).
Решение: Всегда строго лимитируйте размер L1-кэша по количеству записей (max: 1000) или по объему занимаемых байт.