У меня был проект, где один Next.js сайт обслуживал несколько доменов, и возникла задача - эффективно кешировать страницы, чтобы не пересоздавать их каждый раз. Сначала я попробовал внедрить кеширование через Redis: я написал хендлер, подключил его, но вскоре обнаружил, что Redis потребляет колоссальный объём оперативной памяти - порядка 100 ГБ, и это при том, что ещё не все запросы были закешированы. Тогда я решил поискать другой подход и обратил внимание на PVC - общее хранилище, которое могли бы использовать все поды. Я начал изучать варианты работы с PVC и довольно быстро пришёл к идее общего кеш-хранилища для всех подов. Я попробовал просто писать данные в PVC, но столкнулся с проблемой: каждый раз, когда под поднимался, он перезаписывал кеш. До тех пор, пока не подняты все поды, данные постоянно перезаписывались, а мне нужно было, чтобы первый под записал данные, а последующие только читали их. Я начал искать, как сделать кастомный кеш-хендлер, но готовых решений не нашёл.
Я нашёл в документации Next.js упоминание о кастомных кеш-хендлерах (вот ссылка), но там показан только минимальный интерфейс кеш-хендлера, без деталей про shared storage, инвалидацию и конкуренцию чтения/записи и не было примера, как это использовать с PVC. Разбираясь, я понял, что у кеш-хендлера есть ключевые методы. set - отвечает за сохранение данных, get - за получение, а revalidateTag - обновляет данные по тегам. Для работы с общим хранилищем мне также пришлось отдельно продумать сериализацию и десериализацию записей на стороне Node.js. Я создал хранилище на PVC, где записывал страницы, а поды затем читали данные оттуда. Так я получил централизованное кеширование. Вот реализация кеш-хендлера из документации:
const cacheHandler = {
async get(cacheKey, softTags) {
const entry = cache.get(cacheKey)
if (!entry) return undefined
// Check if expired
const now = Date.now()
if (now > entry.timestamp + entry.revalidate * 1000) {
return undefined
}
return entry
},
async set(cacheKey, pendingEntry) {
// Wait for the entry to be ready
const entry = await pendingEntry
// Store in your cache system
cache.set(cacheKey, entry)
},
} Используя Node.js, я реализовал метод set, который подготавливает запись, вычисляет срок её актуальности и сохраняет данные в общем внешнем хранилище:
Ниже приведены упрощённые и обезличенные фрагменты кода. Они передают архитектуру решения и ключевые идеи, но не повторяют продовую реализацию один в один.
async set(key, data, ctx) {
if (this.isBuildPhase || !this.storageUrl) {
return;
}
await this.initPromise;
const existingRequest = this.pendingWrites.get(key);
if (existingRequest) {
return existingRequest;
}
const entryKey = getEntryKey(key);
const request = (async () => {
try {
if (data == null) {
await storage.remove(entryKey);
return;
}
const revalidateSec = extractRevalidateSeconds(data, ctx);
const tags = parseTagsFrom(data, ctx);
const ttl = revalidateSec > 0 ? revalidateSec : DEFAULT_TTL_SEC;
const now = Date.now();
const entry = {
value: data,
modifiedAt: now,
tags,
revalidateSec: ttl,
expiresAt: now + ttl * 1000,
};
await storage.write(entryKey, encodeEntry(entry), ttl);
await this.touchEntry(entryKey);
await this.evictIfNeeded();
} catch (error) {
console.error('[CacheHandler] Failed to write cache entry', error);
} finally {
this.pendingWrites.delete(key);
}
})();
this.pendingWrites.set(key, request);
return request;
} Метод get читает запись из внешнего хранилища, декодирует её и дополнительно проверяет, не устарела ли она по времени или по тегам:
async get(key) {
if (this.isBuildPhase || !this.storageUrl) {
return null;
}
await this.initPromise;
const existingRequest = this.pendingReads.get(key);
if (existingRequest) {
return existingRequest;
}
const entryKey = getEntryKey(key);
const request = (async () => {
try {
const storage = await this.initPromise;
const rawValue = await storage.read(entryKey);
if (!rawValue) {
return null;
}
let entry;
try {
entry = decodeEntry(rawValue);
} catch (error) {
console.error('[CacheHandler] Failed to decode cache entry', error);
await storage.remove(entryKey);
return null;
}
if (
typeof entry?.expiresAt === 'number' &&
entry.expiresAt > 0 &&
entry.expiresAt <= Date.now()
) {
await storage.remove(entryKey);
return null;
}
if (
entry?.tags?.length &&
await this.isEntryStaleByTags(entry.tags, entry.modifiedAt)
) {
await storage.remove(entryKey);
return null;
}
await this.touchEntry(entryKey);
return entry;
} catch (error) {
console.error('[CacheHandler] Failed to read cache entry', error);
return null;
} finally {
this.pendingReads.delete(key);
}
})();
this.pendingReads.set(key, request);
return request;
} Метод revalidateTag не удаляет запись напрямую, а помечает связанные теги как инвалидированные. Благодаря этому при следующем чтении устаревшая запись будет отброшена и пересобрана заново:
async revalidateTag(tags) {
if (this.isBuildPhase || !this.storageUrl) {
return;
}
await this.initPromise;
const tagsList = Array.isArray(tags) ? tags : [tags].filter(Boolean);
if (!tagsList.length) {
return;
}
const invalidatedAt = Date.now();
await storage.markTagsInvalid(tagsList, invalidatedAt);
}Так шаг за шагом я построил кастомный подход, интегрированный с PVC.
Когда я реализовывал кастомный кеш-хендлер на PVC, у меня возникли проблемы. PVC медленнее памяти, и нужно было избегать гонки. Во-первых, я решал проблему устаревших данных с помощью функции isEntryStaleByTags:
async isEntryStaleByTags(tags, modifiedAt) {
if (!tags.length) {
return false;
}
const invalidatedAt = await storage.getTagsInvalidationTime(tags);
return invalidatedAt > modifiedAt;
}Во-вторых, я ограничивал количество записей функцией evictIfNeeded :
async evictIfNeeded() {
const overflow = await storage.getOverflowCount();
if (overflow <= 0) {
return;
}
const staleEntries = await storage.getLeastRecentlyUsed(overflow);
await storage.removeEntries(staleEntries);
}В-третьих, я гарантировал, что при записи мы аккуратно обновляем индекс (при каждом чтении или записи я обновляю индекс активности записи, чтобы можно было ограничивать размер кеша) через touchEntry:
async touchEntry(entryKey) {
await storage.touchIndex(entryKey, Date.now());
}Эти функции вместе обеспечили актуальность, ограничение и последовательность кеша.
Отдельной задачей оказался сброс кеша. В какой-то момент я попробовал схему с L1-кешем на каждом поде: каждый экземпляр приложения держал локальный горячий кеш, а для синхронизации инвалидации я использовал внешний сигнал через Redis. На практике это оказалось слишком сложным. Каждый под должен был отслеживать изменения во внешнем слое, вовремя понимать, что данные устарели, и отдельно сбрасывать свой локальный L1-кеш. Чем больше подов становилось в системе, тем менее удобной и предсказуемой выглядела такая схема.
В итоге мы отказались от идеи использовать Redis как основное или координирующее хранилище для всего кеша. Вместо этого Redis остался только небольшим быстрым L1-слоем для самых горячих записей - например, для последней тысячи наиболее посещаемых страниц. Всё остальное продолжило храниться в PVC как в основном общем хранилище. Такой подход позволил не раздувать потребление оперативной памяти, но при этом сохранить быстрый доступ к самой актуальной части кеша.
Кроме того, при подключении кастомного кеш-хендлера я добавил его в финальный runtime-образ на этапе сборки.
COPY --from=builder /workspace/custom-cache-handler.js ./custom-cache-handler.js В next.config.ts я добавил конфигурацию, которая указывает путь к кастомному кеш-хендлеру через переменную окружения. Заодно я отключил встроенный in-memory-кеш, чтобы приложение не расходовало лишнюю память поверх внешнего хранилища.
cacheHandler: process.env.HANDLER_PATH
? path.resolve(process.env.HANDLER_PATH)
: undefined,
cacheMaxMemorySize: 0Так я подключил кастомный кеш‑хендлер к Next.js, отключил встроенный in‑memory‑кэш и получил предсказуемую схему кэширования, которая работает в многоподовом окружении с общим PVC.






















