Цена в карточке уже новая, а в поисковой выдаче ещё вчерашняя. Для покупателя это один магазин и один чайник, для Nuxt — две разные страницы с разной историей: одну он собрал или закешировал давно, другую отрисовал только что. Чтобы понять, почему так происходит и как этим управлять, возьмём один товар и пройдём с ним по всем интерфейсам магазина.
Один чайник в трёх местах
Пусть в каталоге есть электрический чайник. Вчера он стоил 4 990 ₽, сегодня в 12:00 менеджер выставил 4 490 ₽. Это условный пример, но он хорошо показывает механику: на цифрах проще увидеть, где именно возникает расхождение.
Покупатель встречает чайник минимум в трёх местах.
- Карточка товара. Её открывают из поисковиков, из рекламы, по ссылке от друга. Страницу смотрят тысячи людей, и для всех она выглядит одинаково: название, фотографии, описание, характеристики, цена.
- Поиск и листинги. Здесь чайник стоит среди десятков других товаров, а состав выдачи зависит от запроса, фильтров и сортировки. Цена в строке выдачи — копия цены из карточки, снятая в другой момент.
- Кабинет. История заказов, адреса, сохранённые корзины. Содержимое принадлежит конкретному человеку, и показывать его другому нельзя ни при каких настройках кеша.
Каждая из этих поверхностей отвечает на разный вопрос. Карточка: «как выглядит товар для всех». Поиск: «что подходит под этот запрос прямо сейчас». Кабинет: «что известно именно обо мне». Отсюда и разные правила: у ответа на первый вопрос может быть долгая жизнь, у второго — короткая, у третьего вообще не должно быть общего кеша.
Если применить к магазину один режим рендеринга целиком — всё предсобрать или всё рисовать на каждый запрос, — придётся жертвовать либо актуальностью, либо скоростью, либо безопасностью. Гибридный подход в Nuxt как раз позволяет не выбирать одно на весь сайт. Если вы ещё не определились, нужен ли вам серверный рендеринг вообще, сначала загляните в материал Nuxt на VPS: когда достаточно статики, а когда нужен SSR. Здесь мы исходим из того, что решение принято и на VPS работает Node-сервер Nuxt.
Что готовить заранее, а что получать по запросу
Прежде чем писать правила, полезно разложить данные магазина по двум осям: как быстро они устаревают и кому принадлежат.
| Данные | Как быстро устаревают | Чьи |
|---|---|---|
| Страница «О магазине», условия доставки | Дни и недели | Общие |
| Описание и характеристики товара | Дни | Общие |
| Цена и наличие | Часы, а при акциях минуты | Общие |
| Выдача поиска по запросу | Минуты | Общие, но зависят от запроса |
| Заказы, адреса, корзина | Мгновенно | Личные |
Таблица показывает главное: «общее» не значит «одинаково свежее». Описание чайника можно подготовить заранее и забыть до следующей пересборки. Цена — тоже общая, но живёт гораздо меньше. А личные данные нельзя готовить заранее по определению: их просто нет в момент сборки.
Отсюда вытекают три категории маршрутов:
- Маршруты, содержимое которых известно при сборке и меняется редко. Их можно отрисовать один раз.
- Маршруты с общим содержимым, которое должно обновляться без пересборки. Им нужен кеш с понятным сроком жизни.
- Маршруты с личными данными. Им нужен ответ на каждый запрос или отрисовка на стороне клиента, но с обязательной проверкой прав на сервере.
Как это записывается в 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 соответственно, так что кеш свежий.
| Поверхность | Когда кеш считается просроченным | Что увидит первый посетитель после этого | Что увидит следующий |
|---|---|---|---|
| Карточка (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 не перестраивает страницы по таймеру.
Как сократить расхождение
Есть несколько способов, и у каждого своя цена.
- Выровнять сроки. Если у карточки и поиска одинаковый срок, расхождение между ними становится меньше, хотя полностью не исчезает: страницы построены в разные моменты. Но разрыв в пять минут между двумя поверхностями заметнее, чем разрыв в тридцать секунд.
- Укоротить срок для карточек с акциями. В
routeRulesможно задать отдельное правило для отдельного раздела, например для распродажи. Короткий срок означает больше перестроений страниц и больше нагрузки на сервер и базу, поэтому его стоит применять точечно, а не ко всему каталогу. - Отдавать цену не из кешированной страницы, а отдельным запросом. Тогда страница остаётся быстрой, а число на ней всегда свежее. Ценой будет описанная выше задержка появления цены и зависимость от доступности API.
- Принять расхождение, но сделать его безопасным. Это самый честный путь, и ему посвящён следующий раздел: показанная цена — это информация, а не обязательство.
Нужен сервер под эту задачу?
Разверните 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 ₽». Когда расхождение идёт в обратную сторону, а цена выросла, такое сообщение ещё важнее: оформление не должно молча увеличить сумму.
Практическое правило для оформления заказа:
- Клиент присылает серверу не сумму, а состав: идентификаторы товаров и количества.
- Сервер сам берёт цены из базы и считает итог.
- Если итог отличается от суммы, которую покупатель видел на экране оформления, сервер возвращает ответ с новой суммой, и покупатель подтверждает её явно.
- Только после подтверждения создаётся заказ с зафиксированными в нём ценами.
Такая схема делает кеш карточек безопасным по определению: даже самая устаревшая карточка не может привести к списанию по неверной цене, потому что она в расчёте вообще не участвует.
Как проверить, что цены обновляются без пересборки
Пересборка проекта нужна, когда меняется код или содержимое предсобранных страниц. Смена цены в базе к ней отношения иметь не должна. Проверить это можно на самом 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 ₽, и в корзине его ждало бы короткое объяснение, почему цена теперь другая. Эта сцена менее приятная, но честная: каждая поверхность называет цену, которую знает, а ту, по которой платят, называет только сервер в момент оформления. В итоге карточка, корзина и оформление объясняют одну и ту же сумму, а расхождение, если оно случилось, объяснено прямо на экране и ограничено сроком, который вы выбрали сами.