Цена в карточке уже новая, а в поисковой выдаче ещё вчерашняя. Для покупателя это один магазин и один чайник, для Nuxt — две разные страницы с разной историей: одну он собрал или закешировал давно, другую отрисовал только что. Чтобы понять, почему так происходит и как этим управлять, возьмём один товар и пройдём с ним по всем интерфейсам магазина.

Один чайник в трёх местах

Пусть в каталоге есть электрический чайник. Вчера он стоил 4 990 ₽, сегодня в 12:00 менеджер выставил 4 490 ₽. Это условный пример, но он хорошо показывает механику: на цифрах проще увидеть, где именно возникает расхождение.

Покупатель встречает чайник минимум в трёх местах.

  • Карточка товара. Её открывают из поисковиков, из рекламы, по ссылке от друга. Страницу смотрят тысячи людей, и для всех она выглядит одинаково: название, фотографии, описание, характеристики, цена.
  • Поиск и листинги. Здесь чайник стоит среди десятков других товаров, а состав выдачи зависит от запроса, фильтров и сортировки. Цена в строке выдачи — копия цены из карточки, снятая в другой момент.
  • Кабинет. История заказов, адреса, сохранённые корзины. Содержимое принадлежит конкретному человеку, и показывать его другому нельзя ни при каких настройках кеша.

Каждая из этих поверхностей отвечает на разный вопрос. Карточка: «как выглядит товар для всех». Поиск: «что подходит под этот запрос прямо сейчас». Кабинет: «что известно именно обо мне». Отсюда и разные правила: у ответа на первый вопрос может быть долгая жизнь, у второго — короткая, у третьего вообще не должно быть общего кеша.

Если применить к магазину один режим рендеринга целиком — всё предсобрать или всё рисовать на каждый запрос, — придётся жертвовать либо актуальностью, либо скоростью, либо безопасностью. Гибридный подход в Nuxt как раз позволяет не выбирать одно на весь сайт. Если вы ещё не определились, нужен ли вам серверный рендеринг вообще, сначала загляните в материал Nuxt на VPS: когда достаточно статики, а когда нужен SSR. Здесь мы исходим из того, что решение принято и на VPS работает Node-сервер Nuxt.

Что готовить заранее, а что получать по запросу

Прежде чем писать правила, полезно разложить данные магазина по двум осям: как быстро они устаревают и кому принадлежат.

Данные магазина по скорости устаревания и принадлежности
ДанныеКак быстро устареваютЧьи
Страница «О магазине», условия доставкиДни и неделиОбщие
Описание и характеристики товараДниОбщие
Цена и наличиеЧасы, а при акциях минутыОбщие
Выдача поиска по запросуМинутыОбщие, но зависят от запроса
Заказы, адреса, корзинаМгновенноЛичные

Таблица показывает главное: «общее» не значит «одинаково свежее». Описание чайника можно подготовить заранее и забыть до следующей пересборки. Цена — тоже общая, но живёт гораздо меньше. А личные данные нельзя готовить заранее по определению: их просто нет в момент сборки.

Отсюда вытекают три категории маршрутов:

  1. Маршруты, содержимое которых известно при сборке и меняется редко. Их можно отрисовать один раз.
  2. Маршруты с общим содержимым, которое должно обновляться без пересборки. Им нужен кеш с понятным сроком жизни.
  3. Маршруты с личными данными. Им нужен ответ на каждый запрос или отрисовка на стороне клиента, но с обязательной проверкой прав на сервере.

Как это записывается в routeRules

В Nuxt правила для разных путей задаются в routeRules. Документация описывает, что там можно выбрать для пути режимы prerender, ssr и swr (Nuxt: Rendering modes). Для нашего магазина набросок выглядит так:

export default defineNuxtConfig({
  routeRules: {
    '/about': { prerender: true },
    '/delivery': { prerender: true },
    '/catalog/**': { swr: 300 },
    '/search': { swr: 60 },
    '/account/**': { ssr: false }
  }
})

Числа здесь условные: в swr указывается время в секундах, а именно 300 и 60 — это отправная точка для обсуждения, а не рекомендация для любого магазина. Разберём каждую строку по смыслу.

prerender: страницы, которые не зависят от дня недели

Страницы «О магазине» и «Доставка» отрисовываются при сборке и дальше отдаются как готовый результат. Сервер не тратит процессорное время на их рендер, а покупатель получает ответ почти мгновенно. Цена за такую скорость одна: изменение текста доставки требует новой сборки и выкладки. Для страниц, которые правят раз в месяц, это нормальный обмен.

Соблазн применить prerender к карточкам товаров понятен: их много, они общие, их читают поисковые роботы. Но тогда цена «замораживается» в момент сборки. Чайник за 4 990 ₽ останется чайником за 4 990 ₽, пока вы не пересоберёте проект, а сборка тысяч карточек из-за одной акции — плохая плата за актуальность. Есть и компромиссный вариант: предсобрать оболочку карточки с описанием, а цену подтягивать отдельным запросом к API уже в браузере. Тогда цена будет свежей, но появится короткий момент, когда страница уже открыта, а цены на ней ещё нет, и поисковый робот, не исполняющий скрипты, цену не увидит. Для каталога, где цена — часть предложения и часть разметки для выдачи, такой компромисс обычно хуже, чем кеш с коротким сроком жизни.

swr: каталог, у которого есть срок годности

Для карточек подходит режим swr. Документация формулирует его поведение так: пока страница обновляется в фоне, просроченный кеш может быть отдан посетителю. Для магазина это означает следующее: никто не ждёт полного рендера, но и «вечной» копии нет — по истечении срока страница перестраивается.

Важна здесь именно последовательность. Срок жизни кеша закончился — это ещё не значит, что в этот же момент появится новая страница. Новая версия создаётся, когда кто-то пришёл за страницей после истечения срока. Тот посетитель, который запустил обновление, может получить ещё старую версию, а следующий — уже свежую. Поэтому реальное окно устаревания зависит не только от числа секунд в конфиге, но и от трафика, и мы подробно посчитаем его чуть ниже.

ssr: false для кабинета и что это не защищает

ssr: false говорит Nuxt не рендерить маршрут на сервере: страница собирается в браузере. Для кабинета это логично — нечего готовить заранее и нечего делить между людьми. Но нужно трезво понимать, что именно эта настройка делает и чего не делает. Она не закрывает данные. Если API кабинета отдаёт заказы любому, кто знает адрес, отсутствие серверного рендера страницы ничего не спасёт. Проверка прав должна быть на стороне API: каждый запрос заказов привязан к сессии и отдаёт только то, что принадлежит её владельцу. Маршрут без SSR — это решение про то, где выполнять отрисовку, а не про то, кто что видит.

Кеш полного ответа на самостоятельном сервере

Теперь о том, где этот кеш живёт. В документации отдельно сказано, что Nitro node-server может кешировать полный ответ. На VPS именно он и работает: ваш Node-процесс принимает запрос, проверяет, есть ли готовый ответ, и либо отдаёт его, либо рендерит страницу.

И здесь стоит остановиться на границе, которую легко пропустить. В той же документации сказано, что ISR с кешем CDN зависит от платформы, которая это поддерживает, и не является универсальной возможностью любого VPS. Вывод для практики такой: пока вы сами не поставили перед сервером CDN или кеширующий прокси и не настроили их, «кеш» — это то, что делает ваш собственный процесс. Обещания, прочитанные про хостинговые платформы с готовым ISR, на ваш сервер автоматически не переносятся. Решение работает, но устроено иначе: кеш находится рядом с приложением, а не на краю сети.

У этого устройства есть несколько последствий, которые полезно держать в голове.

  • Один сервер — одно место для кеша. Нет географической раздачи: пользователь из другого региона получит ответ с вашего VPS с обычной сетевой задержкой. Зато нет и рассинхронизации между точками раздачи.
  • Кеш занимает ресурсы машины. Чем больше разных страниц и вариантов поиска вы кешируете, тем больше памяти или диска нужно. Размер каталога и разнообразие запросов — это факторы, которые влияют на выбор тарифа, и мы вернёмся к этому в разделе про ресурсы.
  • Если запущено несколько процессов Node, проверьте, общий ли у них кеш. Если каждый процесс держит свой, то один и тот же товар может отдаваться в двух версиях разной давности. Это легко проверить опытом, и мы покажем как.

Событие «цена изменилась»: кто и когда о нём узнает

Вернёмся к чайнику. В 12:00:00 цена в базе стала 4 490 ₽. Что видит покупатель на каждой поверхности? Возьмём условную схему: карточка с swr: 300, поиск с swr: 60, кабинет рисуется в браузере и обращается к API при каждом открытии. Допустим, что карточку и выдачу поиска перед событием кто-то открывал в 11:58:30 и 11:59:30 соответственно, так что кеш свежий.

Условный сценарий: что видно после изменения цены в 12:00:00
ПоверхностьКогда кеш считается просроченнымЧто увидит первый посетитель после этогоЧто увидит следующий
Карточка (300 с)11:58:30 + 5 мин = 12:03:30До 12:03:30 — 4 990 ₽. После — возможно, всё ещё 4 990 ₽, пока идёт фоновое обновление4 490 ₽
Поиск (60 с)11:59:30 + 1 мин = 12:00:30До 12:00:30 — 4 990 ₽. После — возможно, всё ещё 4 990 ₽4 490 ₽
КабинетОбщего кеша нетДанные из API на момент открытияТо же

В этом сценарии поиск «догоняет» изменение быстрее карточки. Получается именно та картина из начала статьи, только наоборот: бывает, что в выдаче уже новая цена, а в карточке ещё старая. Покупателю такое видеть неприятно: он сравнил две страницы одного магазина и увидел два числа.

Причина проста: у двух поверхностей разные сроки жизни и разные моменты, когда кеш был построен. Размер расхождения ограничен сверху. Для карточки это пять минут плюс время, которое нужно, чтобы дождаться первого посетителя после истечения срока и завершить фоновое обновление. Если на карточку никто не заходит полчаса, то и обновляться она не начнёт полчаса — режим swr не перестраивает страницы по таймеру.

Как сократить расхождение

Есть несколько способов, и у каждого своя цена.

  1. Выровнять сроки. Если у карточки и поиска одинаковый срок, расхождение между ними становится меньше, хотя полностью не исчезает: страницы построены в разные моменты. Но разрыв в пять минут между двумя поверхностями заметнее, чем разрыв в тридцать секунд.
  2. Укоротить срок для карточек с акциями. В routeRules можно задать отдельное правило для отдельного раздела, например для распродажи. Короткий срок означает больше перестроений страниц и больше нагрузки на сервер и базу, поэтому его стоит применять точечно, а не ко всему каталогу.
  3. Отдавать цену не из кешированной страницы, а отдельным запросом. Тогда страница остаётся быстрой, а число на ней всегда свежее. Ценой будет описанная выше задержка появления цены и зависимость от доступности API.
  4. Принять расхождение, но сделать его безопасным. Это самый честный путь, и ему посвящён следующий раздел: показанная цена — это информация, а не обязательство.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Поиск — отдельная история

Выдача поиска похожа на каталог, но ведёт себя иначе. Карточек у вас, допустим, две тысячи, а возможных поисковых запросов — практически бесконечность: «чайник», «чайник стеклянный», «чайник 1,7 л», опечатки, фильтры и сортировки. Если кешировать каждую выдачу, кеш будет заполнен записями, которые запросят один раз.

Поэтому поиску нужно уделить отдельное внимание по трём причинам.

  • Разные запросы не должны сливаться в один ответ. Прежде чем включать кеш для поиска, проверьте на практике, что страницы с разными параметрами запроса отдаются как разные. Откройте поиск по двум словам и убедитесь, что выдачи различаются, а не повторяют ту, что была построена первой.
  • Срок жизни у поиска должен быть коротким. Выдача — это производное от цены, наличия и сортировки, то есть от нескольких быстро меняющихся величин сразу. Одна устаревшая цена в выдаче заметна, потому что сравнение цен — главный сценарий поиска.
  • Можно кешировать часть, а не страницу. Популярные запросы и страницы категорий имеет смысл кешировать как каталог. Произвольный текстовый запрос можно не кешировать совсем и отдавать на каждый запрос: нагрузка при этом растёт, но выдача всегда свежая.

Разумная середина для небольшого магазина — кеш у страниц категорий и сортировок, которые часто повторяются, и отсутствие кеша у свободного текстового поиска. Тогда актуальность цены в поиске зависит только от базы данных, а не от того, кто и когда последним задал такой же вопрос.

Кабинет: ничего общего, кроме шаблона

Кабинет — единственная поверхность, где вопрос «как давно построена страница» не стоит вообще: в ней нет данных, пригодных для общего показа. Два действия защищают этот раздел.

Во-первых, правило ssr: false для /account/** убирает серверный рендер этих страниц, так что личные данные не попадают в готовую страницу, которую мог бы запомнить кеш. Во-вторых, и это важнее, API кабинета проверяет сессию на каждый запрос. Любой кабинетный эндпоинт должен отвечать «нет», если запрос не принадлежит владельцу данных, независимо от того, откуда он пришёл и как отрисована страница. Если вдруг для кабинета придётся включить серверный рендер, личные ответы нельзя пускать под тот же кеш, что и каталог: кеш общий, а данные — нет.

Есть и мелкая деталь, связанная с ценой. В кабинете лежат прошлые заказы, и цена в них — историческая: чайник, купленный за 4 990 ₽, остаётся купленным за 4 990 ₽, даже когда цена в каталоге изменилась. Эти суммы нужно хранить в самом заказе, а не вычислять заново из текущего каталога. Иначе история покупок начнёт «переписываться» вслед за каждой скидкой.

Цена для показа и цена для оплаты

Теперь главный поворот. Всё, что мы настроили выше, касается цены для показа. Она может быть чуть старой, потому что стоит в кешированной странице. Но деньги списываются не по ней. Цену, по которой оформляется заказ, считает сервер в момент оформления — по данным базы, а не по тому, что было нарисовано на странице несколько минут назад.

Разница между этими двумя ценами — это не баг, а разделение ролей:

  • Цена в карточке и поиске отвечает на вопрос «сколько это примерно стоит сейчас», и её допустимая давность — осознанный выбор, выраженный в сроке swr.
  • Цена при оформлении отвечает на вопрос «сколько я заплачу», и её давность должна быть нулевой, насколько позволяет работа базы данных.

Что должно происходить в корзине? Страница корзины не опирается на кеш карточки. Она запрашивает у сервера актуальные цены позиций и по ним же считает итог. Теперь представьте покупателя, который открыл чайник в 12:01, пока в карточке ещё была цена 4 990 ₽, и положил его в корзину. Корзина запросила свежую цену и показала 4 490 ₽. Расхождение с карточкой — в пользу покупателя, но оно заметно. Нужно, чтобы интерфейс его объяснил: например, строка «Цена изменилась с 4 990 ₽ на 4 490 ₽». Когда расхождение идёт в обратную сторону, а цена выросла, такое сообщение ещё важнее: оформление не должно молча увеличить сумму.

Практическое правило для оформления заказа:

  1. Клиент присылает серверу не сумму, а состав: идентификаторы товаров и количества.
  2. Сервер сам берёт цены из базы и считает итог.
  3. Если итог отличается от суммы, которую покупатель видел на экране оформления, сервер возвращает ответ с новой суммой, и покупатель подтверждает её явно.
  4. Только после подтверждения создаётся заказ с зафиксированными в нём ценами.

Такая схема делает кеш карточек безопасным по определению: даже самая устаревшая карточка не может привести к списанию по неверной цене, потому что она в расчёте вообще не участвует.

Как проверить, что цены обновляются без пересборки

Пересборка проекта нужна, когда меняется код или содержимое предсобранных страниц. Смена цены в базе к ней отношения иметь не должна. Проверить это можно на самом VPS с помощью обычных запросов, без специальных инструментов.

Ход эксперимента такой. Выберите один товар и выставьте в панели администратора заметную тестовую цену, например с необычными копейками, чтобы её было легко найти в тексте ответа. Затем запрашивайте страницу в цикле и ищите в ответе это число:

while true; do
  date +%T
  curl -s https://example.com/catalog/kettle-k12 | grep -o '4490'
  sleep 10
done

Адрес и число здесь замените на свои. Запустив цикл до изменения цены, вы увидите, в какой момент и после скольких обращений ответ сменился. Повторите то же для страницы поиска, где есть этот товар. Сравните времена.

Что именно должно получиться:

  • Новая цена появляется на карточке не позже, чем срок кеша плюс время одного фонового обновления, и при этом вы не запускали пересборку.
  • Если после изменения цены ответ остаётся старым дольше срока кеша, проверьте, не отдаёт ли страницу промежуточный слой: прокси или CDN, который вы поставили перед приложением, может держать собственную копию со своим сроком.
  • Если приложение запущено в нескольких процессах, делайте серию запросов и смотрите, не чередуются ли старая и новая цены. Чередование означает, что у процессов разные кеши.
  • Откройте два разных поисковых запроса и убедитесь, что содержимое выдач отличается.
  • Откройте страницу кабинета из двух разных сессий и убедитесь, что данные одного человека не видны другому.

И отдельная проверка для оформления: поменяйте цену товара, который уже лежит в корзине, и убедитесь, что сервер при оформлении пересчитал сумму, а клиент увидел новое значение, а не подтвердил старое.

Что понадобится от VPS

Гибридный рендеринг на собственном сервере переносит часть работы с платформы на вас. Процесс Node рендерит страницы при просроченном кеше, хранит кеш и отвечает на запросы кабинета и поиска. Поэтому при выборе ресурсов имеет смысл смотреть не на общие слова вроде «магазин небольшой», а на конкретные вещи: сколько у вас карточек и вариантов поиска, как долго они кешируются, сколько одновременных рендеров вероятно при истечении срока у многих страниц сразу.

VPS MAATRIX предоставляет NVMe-диск, процессоры AMD EPYC и root-доступ, а значит, вы сами выбираете, как запускать Node-процесс, нужен ли перед ним обратный прокси и где держать кеш. Из линейки тарифов для небольшого магазина как стартовую точку рассматривают Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) или Uab (2 vCPU, 4 ГБ, 80 ГБ). Для каталога побольше или поиска с тяжёлыми запросами — Heka (4 vCPU, 8 ГБ, 160 ГБ) и выше: Maat (8 vCPU, 16 ГБ, 240 ГБ), Djed (16 vCPU, 32 ГБ, 320 ГБ). Эти параметры — описание ресурсов, а не обещание, что такое количество карточек или посетителей будет обслужено. Реальную ёмкость покажет нагрузочная проверка вашего приложения: сколько занимает память с прогретым кешем, как ведёт себя процессор при массовом истечении сроков, как отвечает база.

Локации в публичном предложении — UK, US, RU и FR; для магазина с российской аудиторией разумно сопоставить расположение сервера с расположением покупателей и сервисов, с которыми он общается, например платёжного шлюза. Оплата картой РФ и СБП доступна, а VPS создаётся автоматически через пару минут после оплаты, так что тестовый стенд для описанных выше замеров можно поднять быстро и сравнить сроки кеша на живой машине. Доступность конкретной ОС и образов уточняйте в форме заказа.

Один заказ — одна сумма

Теперь соберём сцену заново, с настроенными правилами. Покупатель находит чайник через поиск: в выдаче цена 4 490 ₽ — короткий срок кеша или его отсутствие сделали своё дело. Он открывает карточку, которая отдаётся из кеша на пять минут и показывает ту же сумму, потому что обновилась уже после изменения. Он кладёт чайник в корзину. Корзина не доверяет страницам, а спрашивает сервер и получает 4 490 ₽ за единицу. На оформлении сервер пересчитывает итог по базе, сумма совпадает с корзиной, и заказ создаётся с зафиксированной ценой. Потом покупатель заходит в кабинет, видит заказ на 4 490 ₽ и никакие будущие акции эту строку уже не изменят.

А если бы покупатель открыл карточку за минуту до обновления кеша, он увидел бы 4 990 ₽, и в корзине его ждало бы короткое объяснение, почему цена теперь другая. Эта сцена менее приятная, но честная: каждая поверхность называет цену, которую знает, а ту, по которой платят, называет только сервер в момент оформления. В итоге карточка, корзина и оформление объясняют одну и ту же сумму, а расхождение, если оно случилось, объяснено прямо на экране и ограничено сроком, который вы выбрали сами.