У меня был проект, где один 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.
ссылка на оригинал статьи https://habr.com/ru/articles/1028314/