javascript

Как я построил кеш страниц для многодоменного проекта с помощью PVC и кастомного подхода

  • вторник, 28 апреля 2026 г. в 00:00:10
https://habr.com/ru/articles/1028314/

У меня был проект, где один 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.