Производительность
Как я заменил таймер кеша вопросом «изменился ли товар?»
Библиотека витрин хранила данные товаров фиксированное время, поэтому страницы то показывали устаревшие данные, то заново запрашивали товары, которые не менялись. Проверка даты изменения каждого товара сократила число запросов примерно на 70%.
На этой странице
Поставьте на главную страницу магазина несколько товарных подборок, и на одном экране легко окажется от 60 до 200 товаров. Каждому нужны данные в браузере: цены, варианты, остатки и то, что произойдёт, когда покупатель нажмёт «В корзину». В inSales, e-commerce-платформе с 60 000+ магазинов, этим занимается CommonJS — JavaScript-библиотека, на которой работают витрины. Я несколько лет был её мейнтейнером.
Когда я начал разбираться с её кешированием, данные товаров в браузере жили по таймеру: две минуты в обычном сценарии, меньше на странице товара и несколько секунд в режиме администратора. Самый обычный кеш, и ломался он сразу в две противоположные стороны:
- Если товар уже изменился, покупатель мог видеть старые данные, пока не истечёт таймер.
- Если ничего не изменилось, таймер всё равно истекал, и библиотека заново скачивала те же самые данные.
То есть кеш был одновременно слишком долгим и слишком коротким. Была и менее очевидная цена. Когда покупатель добавлял в корзину один товар, библиотека проверяла весь список товаров, сохранённый при загрузке страницы. Если к этому моменту таймер истёк, один клик мог заново запросить данные всех товаров на странице, а не только того, с которым работал покупатель.
Другой вопрос к кешу
Таймер отвечает на вопрос «сколько лет этим данным?». Витрине возраст данных, по сути, безразличен. Ей нужно знать, тот ли это ещё товар. Ответ у сервера уже был: у каждого товара есть дата изменения. Просто до браузера она не доходила.
Я передал её через шаблон. Liquid-разметка кладёт дату в атрибут каждого товарного элемента:
<div
data-product-id="{{ product.id }}"
data-product-updated-at="{{ product.updated_at }}"
>
Библиотека сохраняет это значение в IndexedDB рядом с данными товара. При следующей загрузке страницы она сравнивает дату из разметки с сохранённой. Кеш при этом полностью клиентский: сервер отдаёт только дату, с которой идёт сравнение.
Решение по каждому товару свелось к небольшой таблице:
| Состояние товара | Что делает библиотека |
|---|---|
| Товар в кеше, дата та же, данных хватает для этого виджета | Берёт из кеша без запроса |
| Товара нет в кеше | Запрашивает и сохраняет |
| Дата изменилась | Получает актуальные данные и обновляет запись |
| Виджету нужны варианты, которых нет в кеше | Догружает полные данные |
| Старый шаблон не передаёт дату | Оставляет прежнюю проверку по времени |
Если упростить, правило укладывается в одну функцию:
/** Решает, откуда брать данные одного товара при загрузке страницы. */
function resolveProductSource({ cached, updatedAt, needsVariants }) {
if (!cached) return 'fetch'
// Шаблоны без даты изменения сохраняют прежнее поведение по таймеру
if (!updatedAt) return isExpired(cached) ? 'fetch' : 'cache'
if (cached.updatedAt !== updatedAt) return 'refresh'
if (needsVariants && !cached.hasAllVariants) return 'fetch-full'
return 'cache'
}
Для корзины появилось отдельное правило. Товары, которые лежат в корзине, но отсутствуют на текущей странице, больше не заставляют перезагружать всё, что видно на экране. Для них библиотека берёт сохранённые данные, а если записи нет, запрашивает только нужные варианты. Товары, которые есть и на странице, проходят обычную проверку по дате.
Самое сложное: свежие данные ещё не значит полные
Дата изменения оказалась простой половиной задачи. Сложнее было понять, что вообще считать «товаром в кеше», если разным частям страницы нужен разный объём данных.
В крупных магазинах встречаются товары с 500–1000 вариантами. Запрос списка из 20 таких товаров мог занимать 20–30 секунд и заканчиваться таймаутом, хотя превью в слайдере полный список вариантов вообще не требовался. Я сформулировал задачу на облегчённый ответ API для превью, согласовал её с бэкендом и адаптировал под него библиотеку. Теперь превью узнаёт только, что у товара несколько вариантов, и предлагает открыть карточку или модальное окно. Товар с единственным вариантом по-прежнему можно сразу положить в корзину.
После этого один и тот же товар мог лежать в общем кеше и одновременно показываться в нескольких местах:
- в превью без выбора вариантов;
- в другом виджете с полноценным выбором;
- в модальном окне, где покупатель выбирает вариант;
- в корзине.
Оба наивных правила здесь что-нибудь ломают. Если облегчённый ответ перезапишет полную запись, следующий виджет потеряет нужные ему варианты. Если считать любую запись в кеше достаточной, модальное окно откроется с половиной данных. Одной свежести мало. Правила кеша должны учитывать, что нужно конкретному месту на странице: облегчённый ответ не должен стирать варианты, на которые рассчитывает другой виджет, а виджет, которому не хватает данных, догружает недостающее. Это четвёртая строка таблицы.
Даже у свежести оказалось больше одного измерения. После изменения остатков в кеше мог сохраниться прежний порядок вариантов, и превью показывало цену первого варианта, которого уже не было в наличии. Пришлось работать над обновлением данных варианта и его положения в сохранённом списке, а не только даты.
Выкатка в шаблоны, которые я не контролирую
Библиотека работает внутри шаблонов, которые владельцы магазинов и их разработчики дорабатывают под себя. Если бы новая версия вдруг начала требовать data-product-updated-at, сломался бы каждый доработанный шаблон, где этого атрибута нет.
Поэтому выкатка шла в два этапа. Сначала вышла версия библиотеки, которая понимает дату изменения, а для разметки без неё оставляет прежний таймер. Это последняя строка таблицы. Потом атрибут появился в разметке виджетов. Ни одному магазину не нужно было, чтобы оба изменения пришли одновременно.
Сценарии для QA шли по той же таблице: переходы между страницами, изменение цены в админке и повторное открытие страницы, открытие после того, как старый таймер уже истёк бы, смесь полных и сокращённых данных о вариантах, товары в корзине и один товар в нескольких виджетах.
Что изменилось
Когда я сравнил количество запросов за данными товаров до и после изменений, их стало примерно на 70% меньше, то есть примерно в 3,3 раза. Товары, которые не менялись, перестали загружаться заново только потому, что прошло две минуты.
Вместе с AJAX-рендерингом товарных блоков, который запускается после основного содержимого, тяжёлые страницы с большим количеством товаров стали заметно быстрее. В характерных замерах время загрузки сократилось с более чем 5 до 2–3 секунд. Я проверял это на более чем 10 стандартных шаблонах и на реальных страницах клиентов, в том числе с 60–200 товарами. Это характерный результат, и в конкретном магазине он может отличаться: многое зависит от количества виджетов, устройства, сети и состояния кеша. Особенно тяжёлые страницы раньше могли грузиться около 15 секунд.
После релиза
Продакшен нашёл то, что не нашёл QA, и два таких бага оказались об одном и том же.
В декабре 2024 года товар на мобильном Safari перестал инициализироваться после перехода из каталога в карточку. Выглядело это как зависание хранилища на странице товара. Пошаговое воспроизведение показало, что проблема начиналась раньше. По кнопке «Загрузить ещё» в каталоге библиотека запускала инициализацию отдельно для каждого товара, и каждый запуск заново готовил список и снова шёл в IndexedDB. На странице клиента было два виджета каталога, так что повторной работы становилось вдвое больше. Я сделал инициализацию пакетной: загруженные товары обрабатываются вместе, без отдельного прохода по хранилищу для каждой карточки.
В мае 2025 года ответ с ошибкой для части товаров ронял инициализацию всей библиотеки. Переставали работать даже товары, данные которых загрузились нормально. Я поправил обработку ошибок, и теперь успешно загруженные товары инициализируются в любом случае.
Общий кеш разносит данные одного товара по всей странице. Работа и ошибки, которые относятся к одному товару, должны к нему и относиться, иначе одна карточка утянет за собой всю страницу.
Если ваш кеш живёт по таймеру
Спросите себя, что вам на самом деле нужно знать. Обычно вопрос в том, изменился ли источник с момента сохранения, а возраст данных тут вторичен. Если у сервера уже есть дата изменения или версия, донести её до клиента часто дешевле, чем подбирать TTL: перестаёшь выбирать между устаревшими данными и лишними запросами.
Перед релизом задайте второй вопрос: может ли двум потребителям понадобиться разный объём одной и той же записи? Если может, полнота данных должна быть в правилах кеша наравне со свежестью, а облегчённый ответ никогда не должен молча побеждать.
И если вы не контролируете все места, где рендерится ваша разметка, сначала выпускайте код, который читает новый сигнал, и только потом код, который его записывает. Новая версия должна понимать сигнал и продолжать работать без него.
Подробнее о проекте — в кейсе о загрузке товаров на витрине.