Привет читатель, на связи Владимир, техлид команды бэкенд-разработки в fashion бренде Befree. За время жизни и развития нашего интернет-магазина накопилось некоторое количество любопытных, на наш взгляд, решений, и появилось желание начать делиться этими решениями с сообществом. Начнем с рассказа о каталоге товаров и о том как этот каталог со временем преображался.
Предыдущий каталог товаров делался во времена, когда и трафика, и товаров было в десятки раз меньше по сравнению нынешним временем. Тот каталог толком не имел механизмов для фильтрации и кастомизации. У менеджеров интернет-магазина не было инструментов управления логикой вывода товаров, чтобы покупатель видел то, что надо бизнесу. Много товаров терялось на задворках каталога, поэтому верно сказать, что для покупателя доля товаров оставалась не представлена, т.к. мало кто листает десятки страниц, чтобы найти там модель из новой коллекции, которая почему то не на первой странице. Это осложнялось еще и тем, что в каталоге имелся скудный набор характеристик товаров, что не давало вывести покупателям нормальные фильтры. Имелись и другие проблемы: при значительных скачках трафика, например во время рассылки пушей, каталог банально переставал справляться с нагрузкой.
Требования, которые команда сформулировала к новому каталогу:
Каждый товар (артикул), а также его варианты (sku), включает в себя неограниченное количество атрибутов.
Менеджеры интернет-магазина создают гибкие подборки товаров - это некие группы товаров, отобранные по заданному менеджером фильтру, т.е. набору значений атрибутов. При этом набор этих фильтров может быть довольно хитер: с объединением разных условий через AND в группы и OR-ами между группами. Менеджер также может помещает эти подборки в древовидное меню для отображения покупателю, за счет этого структуру каталога формируется почти что на лету.
При этом мы не отказались и от классических категорий, которые в первую очередь нужны для присвоения им некоторых атрибутов, чтобы не получилась ситуация когда у ботинок есть длина рукава, а футболок материал подошвы. Для упрощения нашей жизни определили, что один товар принадлежит только одной категории.
В каждой из подборок опционально задается кастомная сортировку списка товаров из этой подборки. Каждый такой список называется листингом, а сортировка товаров в листинге - это “мерч”, по аналогии с тем, как происходит мерчандайзинг товара в нашем физическом магазине. Это означает, что первые условные 100 товаров подборки веб-мерчандайзер руками расставляет в порядке приоритета, а все остальные товары, которые идут после, сортируются в порядке по умолчанию. Подробно рассматривать этот механизм не будем, так как это тема для отдельной статьи.
Разные варианты (sku) одного и того же товара могут визуально сильно отличаться друг от друга. Условная футболка может быть синего и красного цвета, и технически это одна и та же футболка, т.е. это один товар (артикул), но для покупателя это не так. Поэтому менеджер при необходимости разделяет этот товар на карточки товара различных цветов, причем в карточке может быть как один цвет, так и 2+- эти карточки покупатель видит в листингах.
Покупатели хотят легко фильтровать листинг товаров, что означает фильтрацию по значениям атрибутов товаров и вариантов товара, которые входят в этот листинг. Для этого менеджер интернет-магазина гибко настраивает доступность тех или иных атрибутов для фильтрации в каждом листинге.
Каталог товаров геозависим - покупателям доступен заказ товара за наличия в розничном магазине, чтобы не ждать доставки. Состав одного и того же листинга будет отличаться с учетом остатков в розничных магазинах исходя из города покупателя.
Каталог работает быстро и выдерживает нагрузку на большом трафике.
Другие требования описывать не будем, иначе статья превратится в документацию :) Помимо этого у бизнеса было множество сопутствующих потребностей и вызванных их пересечением граничных условий.
Первым делом пошли реализовывать полноценный и почти классический EAV-паттерн поверх традиционной реляционной БД, в нашем случае это Postgres. Примеров как готовить EAV в интернетах много, подробно останавливаться на этом смысла не вижу.
Но у EAV поверх реляционных баз есть одна особенность – он не жизнеспособен в условиях хоть сколько-то высоких нагрузок. Для простых случаев реализации еще можно накинуть побольше ресурсов на БД и пережить, но в нашем случае, когда подборка сама по себе может содержать десятки фильтров, а потом еще и поверх этого быть отфильтрованной покупателем, о работоспособности такой реализации в проде речи и не шло.
Подхода к решению такой проблемы в основном два: либо кэширование, либо индексация. С кэшированием возникает пресловутая проблема инвалидации: система живая, товары то раскупают, то появляются новые, то меняются какие-то характеристики, то состав каталога, и т.д. С индексом возникает похожая проблема своевременной актуализации его по отношению к основным данным и риск потенциального рассинхрона.
В качестве финального видения того, как это должно быть реализовано, остановились на варианте с индексом. Но жизнь внесла свои коррективы…
Временные костыли
Поскольку проект был сам по себе масштабным, т.к. требовалось реализовать сам функционал каталога, плюс админку для управления всем этим, то в сроки мы, естественно, как любые уважающие себя разработчики укладывались с трудом ☺. В какой-то момент выяснилось, что саму систему-то мы сделали, но вот тот самый индекс, который позволит этой системе работать на проде, мы сделать как раз и не успеваем. Что же делать?
Было принято решение втащить по-быстрому кэш. Чтобы не заморачиваться с инвалидацией, кэш решено было сделать коротким: полторы минуты (почему именно полторы – ниже). Но короткий кэш порождает низкий хитрейт, и через это львиная доля той нагрузки, которую он мог бы снять с БД, просто просачивается сквозь запросы, не попадающие по кэшу. Поэтому в дополнение к кэшированию мы реализовали его перманентный прогрев. Суть примерно такая: для условной категории «Платья» для города Москва, хотя на самом деле параметров кэша несколько больше, мы кэшируем листинг товаров и каждую минуту запускаем прогрев, который гарантирует нам постоянное наличие в кэше данного ключа, но при этом и постоянное (ежеминутное) обновление данных в нем. Именно поэтому основной кэш сделан на 90 секунд – так мы гарантируем, что ключ не исчезнет. И если кэширование срабатывает абсолютно для любого листинга при обращении покупателяк нему, то прогрев работает только по определенным городам - условно, по миллионникам, тем с которых больше всего трафика, и только по наиболее нагруженным подборкам: мы же помним, что подборке можно задать один фильтр, а можно – десятки, то есть нагрузка, создаваемая на базу запросом товаров по разным подборкам, далеко не эквивалентна.
Аналогичным образом мы поступили и с кэшированием фильтров.
Самым главным достоинством такого решения стала цена входа: сама разработка заняла буквально пару дней. Далее вопрос был только в подборе оптимального баланса трех параметров: нагрузки на базу, вызываемую прогревом, покрытия прогревом как можно большего множества возможных листингов и востребованности каждого конкретного листинга. Например, листинг вечерних платьев для деревни в Саратовской области вряд ли будет сильно популярен, хотя как знать. Примерно неделю после релиза мы не вылезали из мониторингов, занимаясь эмпирическим подбором этих настроек.
Удивительно, но на этих костылях мы вполне успешно проковыляли почти 4 месяца – до того момента, как нам удалось создать и отладить тот вариант, о котором главным образом и пойдет речь в этой статье.
Индекс на Elasticsearch
Для индексаци поверх реляционных данных можно было бы использовать практически что угодно. Это может быть любая NoSql или даже JSONB в самом Постгресе. Мы решили использовать Elasticsearch (далее будем называть его просто Эластик). Основные причины такого выбора:
Возможность практически снять нагрузку с реляционной БД
Чертовски быстрая работа за счет in-memory реализации
Возможность использовать индекс для поиска
Возможность строить сложные индексы с большими объемами данных, в том числе и вложенных друг в друга
Мощные возможности для различных фильтраций и сортировок (по крайней мере мы так думали ☺)
Мы решили не прекращать поддержку SQL-версии и оставить возможность переключения между ними на случай, если что-то пойдет не так. Для этого на DAL-слое приложения мы реализовали два параллельных репозитория, подчиняющихся общему контракту. Естественно, при переключении рубильника на Эластик также перестают запускаться прогревы кэшей, за ненадобностью.
Первым делом позаботились о синхронизации данных и гарантировании консистентности индекса. По всему коду, где шла работа с товарами, их атрибутами, остатками или подборками, мы расставили ивенты, которые отвечают за экспорт изменившихся данных в эластик, а также удаление неактуальных данных из него. Но этого еще недостаточно, ведь проект не маленький и можно легко что-то упустить. Поэтому мы дополнительно реализовали «круговой» экспорт, который раз в n минут где n подбирается экспериментально, проходит по всем товарам и актуализирует данные. Таким образом, даже если в процессе работы какие-то данные станут не консистентны, то время само исцелит наш каталог ☺.
Сам индекс представляет собой фактически набор денормализованных данных для каждой карточки товара и включает в себя только те данные, которые необходимы и достаточны для поиска, фильтрации и отображения карточек в листинге. Пример записи для одного товара (естественно, сильно сокращенный):
[
{
"_index": "listing_cards",
"_id": "1",
"_source": {
"id": 1,
"category": {
"id": 4,
"gender": "male",
"is_promo": false
},
"product": {
"id": 1,
"article": "BASIC-T2",
"gender": "male",
"title": "футболка мужская",
"marketing_title": "Футболка хлопковая базовая",
"description": null,
"sticker": {
"new": false,
"hit": false,
"online": false,
"custom": false
},
"compilations": [329, 160, 274, 279, 4, 295, 166]
},
"variations": [
{
"id": 6,
"sku": "4680129420925",
"category_id": 4,
"prices": [
{ "level": 0, "sum": 499, "discount": 50 },
{ "level": 1, "sum": 499, "discount": 50 },
{ "level": 2, "sum": 499, "discount": 50 },
{ "level": 3, "sum": 499, "discount": 50 },
{ "level": 4, "sum": 499, "discount": 50 }
],
"color": {
"id": 1,
"code_id": 1
},
"size": 6,
"height": 1,
"cities": [
{ "id": 41, "kladr": "8600001100000" },
{ "id": 70, "kladr": "3600000100000" }
],
"attributes": [
{ "attribute_id": 109, "value_id": 12 },
{ "attribute_id": 110, "value_id": 13 }
],
"stored_at": "2023-03-06T21:00:00.000000Z"
}
]
},
"sort": [1]
}
]
Важная особенность: у товаров и их вариантов есть как свойства, так и атрибуты. Свойства (такие как цвет, размер и рост) отличаются от атрибутов тем, что присущи всем элементам сущности, вне зависимости от ее принадлежности к той или иной категории. Их всего несколько и в реляционной базе они являются непосредственно полями таблицы самой сущности. Соответственно, и в индексе они занимают отдельное место.
Пример использования
Пускай у нас есть подборка «Черные вечерние платья», которая выдает товары по следующему фильтру:
Категория «платья»
Свойство «цвет» = «черный»
Атрибут «стиль» = «вечернее»
Флоу запроса подборки получается такой:
Получаем из постгреса настройки фильтра, сохраненные для данной подборки
Получаем из эластика пагинированные id карточек, отвечающие условиям фильтра (запрос приведен ниже)
Снова идем в постгрес, получаем по полученным айди всю необходимую для отображения листинга информацию по товарам и их вариантам, и формируем конечный вывод данных в API
{
"query": {
// основная часть запроса, где мы непосредственно применяем все необходимые фильтры по нужным параметрам
"bool": {
"filter": [
{
"nested": {
"path": "product",
"query": {
"term": {
"product.gender": "female"
}
}
}
}
],
"must": [
{
"bool": {
"must": [
{
"nested": {
"path": "product",
"query": {
"term": {
"product.compilations": 173
}
}
}
},
{
"bool": {
"must": [
{
"nested": {
"path": "variations",
"query": {
"bool": {
"filter": [
// фильтрация по городам (0 - означает любой город)
{
"nested": {
"path": "variations.cities",
"query": {
"terms": {
"variations.cities.kladr": [
0,
"7800000000000"
]
}
}
}
}
],
"must": [
// фильтрация по свойствам
{
"nested": {
"path": "variations.color",
"query": {
"terms": {
"variations.color.id": [6, 5]
}
}
}
},
{
"terms": {
"variations.size": [2, 3]
}
},
{
"bool": {
"must": [
// фильтрация по атрибутам
{
"nested": {
"path": "variations.attributes",
"query": {
"bool": {
"must": [
{
"term": {
"variations.attributes.attribute_id": 178
}
},
{
"terms": {
"variations.attributes.value_id": [6688]
}
}
]
}
}
}
},
{
"nested": {
"path": "variations.attributes",
"query": {
"bool": {
"must": [
{
"term": {
"variations.attributes.attribute_id": 153
}
},
{
"terms": {
"variations.attributes.value_id": [8606]
}
}
]
}
}
}
}
]
}
}
]
}
}
}
}
]
}
}
]
}
}
]
}
},
"sort": [
// часть запроса, касающаяся сортировки. об этом - ниже
],
"from": 0,
"size": 36
}
Конечно мы не избавились от использования реляционной базы полностью, но вынесли за ее пределы наиболее нагруженную часть – непосредственно фильтрацию.
Одна из наиболее сложных проблем, с которой мы столкнулись в процессе реализации, конечно же была связана с сортировкой. У нас даже появилось шуточное уже нет правило: если в задаче присутствует слово «сортировка» - оценка такой задачи увеличивается в 5 раз ☺ В данном случае это была сортировка вариантов внутри карточки товара. Покупатель может отсортировать весь листинг по нескольким параметрам: по цене, размеру скидки, новизне (дате поступления товара на сток) и так далее. Но внутри каждой карточки варианты товара тоже отсортированы в определенном порядке, заданном либо по умолчанию, либо переопределенным вручную в админке. Если мы применяем к листингу фильтр, то и состав вариантов, которые мы в принципе должны показывать, а значит и сортировать, в данной карточке меняется. И тут мы упираемся в особенность эластика, связанную с сортировкой вложенных объектов: при одновременной фильтрации вложенных элементов и их сортировки эластик в качестве базы для сортировки использует не отфильтрованные данные. То есть, если из списка из, скажем, 5 вложенных вариантов по итогу применения фильтра остались только последние 3, то в отсортированных данных мы получим все равно значение, саггрегированное по всем пяти. Более того, при сортировке по двум полям, по цене и дате поступления, оба этих поля могут спокойно оказаться взятыми из разных вариантов: минимальная цена оказалась у одной, а минимальная дата – у другой, так как по умолчанию значение параметра “max_children” равно “10”, и именно на него эластик обращает внимание при поиске значений. Поведение тут похоже на агрегацию с group by в SQL.
Решили эту проблему так: в блоке “sort” эластик-запроса мы должны повторить ВСЕ условия, по которым были отфильтрованы варианты в основном запросе. Более того, эти условия должны быть повторены для каждого параметра сортировки, если их несколько. Только таким образом мы получим данные, по которым сортировка пройдет без ошибок и несоответствий.
"sort": [
{
// первый параметр сортировки - дата поступления на сток
"variations.stored_at": {
"order": "desc",
"missing": "_last",
"nested": {
"path": "variations",
"max_children": 1,
"filter": {
// применяем необходимые параметры фильтрации из query. Эти условия будут дублироваться во всех параметрах сортировки.
"bool": {
"must": [
{
"bool": {
"filter": [
{
"nested": {
"path": "variations.cities",
"query": {
"terms": {
"variations.cities.kladr": [
0,
"7800000000000"
]
}
}
}
}
],
"must": [
{
"nested": {
"path": "variations.color",
"query": {
"terms": {
"variations.color.id": [6, 5]
}
}
}
},
{
"terms": {
"variations.size": [2, 3]
}
},
{
"bool": {
"must": [
{
"nested": {
"path": "variations.attributes",
"query": {
"bool": {
"must": [
{
"term": {
"variations.attributes.attribute_id": 178
}
},
{
"terms": {
"variations.attributes.value_id": [6688]
}
}
]
}
}
}
},
{
"nested": {
"path": "variations.attributes",
"query": {
"bool": {
"must": [
{
"term": {
"variations.attributes.attribute_id": 153
}
},
{
"terms": {
"variations.attributes.value_id": [8606]
}
}
]
}
}
}
}
]
}
}
]
}
}
]
}
}
}
}
},
{
// второй параметр сортировки - цена
"variations.prices.sum": {
"order": "desc",
"missing": "_last",
"nested": {
"path": "variations",
"max_children": 1,
"filter": {
//здесь применяются ровно те же условия фильтрации, что и для блока "variations.stored_at" выше
},
"nested": {
"path": "variations.prices"
}
}
}
},
{
// последним параметром всегда задаем id, чтобы гарантировать единый порядок на случай, если по предыдущим условиям найдется несколько вариантов
"variations.id": {
"order": "desc",
"missing": "_last",
"nested": {
"path": "variations",
"max_children": 1,
"filter": {
// и здесь мы в третий раз дублируем те же условия фильтрации
}
}
}
}
],
Блоки «"bool": {"must": []}» служат для группировки условий для применения через OR (наподобие скобок в SQL-запросе).
Тут стоит дополнительно обратить внимание на следующие параметры:
"max_children": 1 – нужен для того, чтобы фильтр возвратил только первый элемент вложенного массива: это спасает от того, что агрегированные данные в разных полях будут взяты от разных элементов.
"missing": "_last" – если поле, по которому мы пытаемся сортировать, отсутствует, то использовать максимальное положительное значение типа integer.
Что в итоге?
С точки зрения бизнеса менеджеры интернет-магазина теперь могут создавать любые подборки товаров по любым, даже самым хитрым, комбинациям параметров, да еще и буквально на лету составлять из них дерево навигации по сайту (очередная тема для отдельной статьи). Это особенно удобно, например, в случае проведения акций: добавлять и убирать нужный товар можно за считаные минуты. Как итог - покупатели видят намного больше товаров, и в первую очередь именно те товары, продажа которых приоритетна в моменте.
Работает все это чертовски быстро: скорость отдачи сервером результата лишь ненамного медленнее получения данных из Redis в случае кэширования. При этом скорость не зависит (или почти не зависит) от объема и состава применяемых фильтров. И самое интересное: Эластик как будто вообще не чувствителен к нагрузке! При увеличении объема трафика не наблюдается рост потребления ресурсов на нодах Эластика. Благодаря этому мы получили фактически «безлимитный» бэкенд: на синтетических тестах мы теперь вообще не упираемся в его ресурсы - лимит коннектов nginx-а наступает раньше.
Теперь о минусах. Одним из главных минусов именно для нас и именно на сегодняшний день стала сложность поддержки и связана она главным образом с двумя вещами. Первое – необходимость поддерживать как Эластик, так и SQL-версию в качестве резервного источника. И второй - работа с Эластиком не похожа на работу с SQL, да и в общем-то и на работу с другими no-sql решениями, и требует перестройки сознания. Пока все еще страшновато наткнуться на некую особенность поведения, разрешить которую будет сложно, как в примере с сортировкой вложенных объектов. Если эластик и дальше будет показывать себя с хорошей стороны, то эти минусы со временем сойдут на нет: экспертность команды растет, а необходимость поддерживать реляционный вариант отпадет сама собой.
Но проблемы ведь все еще есть?
А как же. Одна из таких проблем – пресловутый рассинхрон между данными в Постгресе и Эластике. Пока что это не является критичным: при обновлении каких-то данных, например фильтров для подборок, мы готовы к тому, что есть временной лаг до обновления данных у на экранах устройств покупателей. Однако это может стать критичным в будущем, и нужно думать над тем, как можно его максимально сократить.
Другая проблема: на Эластике сейчас работают только листинги, а фильтры остались на SQL. Да, кэширование спасает, но остается как проблема скорости для не покрытых прогревом областей, так и тот факт, что источник для данных все же разный, и проблема рассинхрона в свете этого начинает играть новыми дополнительными красками.
Какие планы дальше?
Прежде всего – продолжить работу, и перевести на Эластик также и фильтры. Это даст нам по-настоящему и в полной мере быстрый каталог, а также отсутствие рассинхрона данных между доступными параметрами для выбора в фильтрах и возвращаемыми в результате его применения данными.
Также возникает вопрос: а зачем нам вообще заниматься индексированием, решая проблемы рассинхронов и прочие, если можно сразу хранить определенную часть данных в no-sql базе? Это вопрос довольно неоднозначный. Во-первых, для этого не подходит конкретно Эластик: он не персистентный, его in-memory индекс создается и пересоздается, хотя на проде он живет условно «вечно», но постоянно заново поднимается, например, на тестовых средах. Но даже если взять персистентное no-sql хранилище, то совместить его с sql-данными будет не так-то просто из-за довольно сильной связанности данных о товарах с другими данными, такими как остатки, города, способы доставок и многие другие. Но направление мысли такое есть, мы об этом также размышляем.