Косметика в разделе с гаджетами, садовые лопаты в зоотоварах и непредсказуемо меняющиеся цены. Эти баги портили жизнь покупателям и сводили с ума разработчиков, ведь с ними ничего нельзя было поделать, но только до определенного момента.

Здравствуй, Хабр! Меня зовут Никита Вахрамеев, я работаю ведущим разработчиком в команде, которая занимается бэкендом витрины СберМегаМаркет. Основные направления нашей работы — листинги (каталоги товаров) и карточки товаров. В этом посте мы проведем небольшое расследование, погрузимся в нюансы шардирования и кэширования в ElasticSearch и исправим проблемы в каталоге на 16 миллионов товаров.

Внимание спойлер: индексы, во всем виноваты индексы!

Листинг и с чем его едят

Листинг — это динамический каталог товаров, который строится по некоему принципу на основе определенного набора идентификаторов. Например, товары делят на категории или ранжируют в зависимости от поискового запроса.

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

  • Фильтрация товаров и фасетный поиск — речь идет о выборе товаров по определенным параметрам и определении того, какие варианты фильтрации еще доступны при уже активированных фильтрах. 

  • Сортировка — у клиента должна быть возможность расположить товары в удобном порядке. Особого внимания заслуживает сортировка по популярности, так как это значение постоянно меняется, в то время, как категория товара в идеале определяется раз и навсегда.

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

  • Учет местоположения клиента — многие магазины проверяют адрес доставки при оформлении заказа, но куда круче, если наличие и цены товаров, а также построенные фильтры сразу учитывают адрес, который указан в профиле пользователя.

Проблемы с производительностью и как мы пытались их решить

Объем каталога СберМегаМаркет — около 7 млн уникальных товаров и 12 млн товарных предложений на них, и эти цифры постоянно растут. При этом к листингам предъявляются жесткие требования по скорости загрузки. Кроме того, мы стремимся к минимальной задержке между созданием или обновлением товара и моментом отображения на витрине. С учетом этих требований и особенностей мы выбрали:

  • Golang — в качестве основного языка программирования;

  • gRPC — для коммуникации между сервисами;

  • Redis — для распределенного кэширования;

  • ElasticSearch — в качестве основной базы данных для листинга (кстати, он задействован и в сервисе текстового поиска).

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

То есть, один товар одновременно могут предлагать несколько продавцов, но карточка на листинге только одна. Следовательно, приходится делать выбор в пользу одного продавца. Оффер, то есть товарное предложение, которое мы показываем на листинге, может меняться, например, при применении фильтра по продавцу. Эта фича делает каталог менее загруженным с точки зрения пользователя, но доставляет массу проблем бэкендерам и серьезно мешает сортировке.

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

Площадка росла и развивалась:

  • Увеличилось число позиций в каталоге;

  • Возросло число продавцов;

  • Вышли на прод новые механики динамичного снижения цен;

  • Развивалась гиперлокальности офферов: то есть уточнение цены и наличия товара по адресу.

В результате цены стали меняться куда чаще, а нагрузка на инфраструктуру возросла. Цифры, которые мы показывали на листинге, стали отставать от изменений цен на карточке товара.

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

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

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

Архитектура товарного каталога, метрики и профилирование — ищем корни проблемы 

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

То же самое с данными для листинга. На тот момент мы получали все необходимое из индексов ElasticSearch, которые заполняла команда контента. Данные поступали в большой кластер ElasticSearch. На пике нагрузки он включал в себя 43 ноды, расположенные на железных серверах. Перед ElasticSearch стоял Nginx, который отвечал за балансировку и дополнительно кэшировал запросы. Несмотря на мощность кластера, он не справлялся с ростом нагрузки, доходя до реджектов. Да и при обычной нагрузке наблюдался высокий фон тайм-аутов. 

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

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

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

{
  "query": {
  "..."
  },
  "profile": true
}

Для этого нужно всего лишь добавить параметр profile, и в ответе на запрос появится информация о том, каким образом выполнялась каждая его часть. Причем эта информация будет со всех шардов.

Шардирование и эврика. Что не так с нашим ElasticSearch

Однажды, изучая результаты профилирования, я задумался: а почему в ответе так много информации с различных шардов? В том случае поступили данные с целых 80 сегментов базы данных.

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

В документации ElasticSearch указаны рекомендуемые размеры шардов, которые позволяют сохранить баланс между возможностью параллелить запросы и простотой релокации шардов между нодами. В дополнение к этой рекомендации о размере и количестве шардов следует ориентироваться на количество нод в кластере: шарды могут быть реплицированы, а реплики могут быть расположены на разных нодах кластера. Это позволяет кластеру реализовать так называемую adaptive replica selection, выбирая менее нагруженную ноду и балансируя нагрузку.

У ElasticSearch есть возможность указать в запросе ключи тех шардов, на которых нужно произвести поиск. При этом по умолчанию ElasticSearch шардирует записи при индексации, распределяя их на основании ID документа.

Если известны конкретные ID документов, то при выполнении запроса можно воспользоваться параметром routing. Эта возможность пригодилась нам на карточке товара, в моменты, когда очень много запросов направляется в этот же индекс, чтобы забрать товар по ID.

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

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

Разматываем клубок и чиним овершардинг

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

{
"filters" : {
    ...
        "1CC8DA86F3ED0F20420587D6B7C61DA8" : "разноцветный",
    "3B11BC72148BA09C81221B67A44DDCE0" : "металл",
    "56B77E64E10AFFB3C630B8E788DDFDAD" : "IP20",
    "C160E95F502B5E4E66684F3D12C4A133" : "коричневый"
    ...
}
}

В товарных коллекциях может быть множество уникальных фильтров. Это приводит к тому, что в разных документах товаров содержится много различных полей, каждое из которых добавляется в mapping.

Эта проблема ElasticSearch известна под названием mapping explosion. Для ее предотвращения существует ограничение в 1 тыс. полей на индекс, и превышать его не рекомендуется. Десятки индексов под одним алиасом понадобились, чтобы обойти это ограничение.

Мы решили использовать поле nested, которое сделало mapping всех индексов одинаковым:

{
"nested_filters": [
    ...
    {
        "filter_id": "1CC8DA86F3ED0F20420587D6B7C61DA8",
        "string_values": "разноцветный",
        "float_values": 0
    },
    {
        "filter_id": "56B77E64E10AFFB3C630B8E788DDFDAD",
        "string_values": "",
        "float_values" : 100
    }
    ...
]
}

    "query": {
        "nested": {
        "path": "nested_filters",
        "query": {
            "bool": {
                "filter": [
                    {
                        "term": {
                          "nested_filters.filter_id": "1CC8DA86F3ED0F20420587D6B7C61DA8"
                        }
                    },
                    {
                        "term": {
                            "nested_filters.string_values": "разноцветный"
                        }
                    }
                ]
            }
        }
    }
}

Ага, мы наконец сделали именно то, что рекомендует документация. Теперь ничто не мешало заполнить индекс правильно.

Как только мы переписали логику построения фильтров и фильтрации товаров на nested-запросы, перелили индекс в тот же кластер, но с другими настройками, случилось чудо: нагрузка на процессор резко упала, network между нодами также существенно сократился. Фон таймаутов также упал в разы.

Какое-то время наш листинг работал без проблем, но мы твердо решили, что пора менять подход. Мы пришли к трем выводам: 

  • исходная структура индекса не оптимальна;

  • нагруженные индексы должны храниться изолированно;

  • необходимо отделить динамичные данные от статичных.

Строим новый индекс

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

При проектировании нового индекса мы стремились к минимизации размера для ускорения сборки и обновления. Так что в новом индексе мы оставили только те поля, которые нужны непосредственно для фильтрации и сортировки на листинге. Это позволяет повысить скорость его заполнения. Контентную составляющую товара мы продолжаем добирать налету из индекса, о котором было рассказано выше.

Радикально решаем проблемы с фильтрацией и сортировками

Вернемся к проблеме с некорректными фильтрациями и сортировками: в первую очередь она связана с тем, что мы показываем одну карточку для товара на листинге, в то время как его могут продавать десятки продавцов по разным ценам. В итоге при фильтрации по цене мы могли по ошибке показать не того продавца или товар мог неправильно попасть под объединение фильтров, например, «продавец» и «цена».

Для того чтобы решить эту проблему, мы снова воспользовались nested-типом поля. Теперь храним товарные предложения в таком виде:

{
"offers": [
    {
        "merchant_id": "12345",
        "region": [
                50,
            51,
            52,
            53
        ],
        "price": 9000
    }
]
}

Это позволяет нам объединять в запросе все условия, которые предъявляются к оферу товара. Например:

"nested": {
        "path": "offers",
        "query": {
            "bool": {
                "filter": [
                    {
                        "term": {
                                "offers.region": 50
                        }
                    },
                    {
                        "range": {
                                "offers.price" : {
                                    "gte" : 300
                            }
                        }
                    }
                ]
            }
        }
    }


Правда, количество документов в nested-запросах ограничено 10 тысячами, но до такого количества мы еще не дошли. Если мы все-таки упремся в ограничение, то разделим nested-запросы по регионам, и количество документов значительно снизится.

Балансируем нагрузку

Из существенно нового: мы отказались от Nginx перед новым кластером. Здравый смысл подсказывал, что мы часто не попадаем в кэш Nginx, а эксперимент с балансировкой между нодами ElasticSearch на клиенте, показал, что это дополнительно снижает фон таймаутов.

Балансировка на клиенте — это, конечно, хорошо, но как вводить или выводить на горячую новые ноды в кластер? Чтобы реализовать более умную клиентскую балансировку, мы воспользовались Sniffing. Это еще одна функция ElasticSearch, которая, к счастью, поддерживается основными библиотеками для этой поисковой системы.

ElasticSearch предоставляет API, через который он отдается набор нод в кластере. При инициализации приложения мы скармливаем ему набор бутстрап нод, все это отдается клиенту ElasticSearch. В свою очередь, клиент опрашивает API, актуализируя список нод. При ошибочном ответе он забывает об адресе до тех пор, пока нода не появится в списке активных на другой ноде. В простой реализации балансировку можно доверить обычному Round-robin, но поддерживаются и более сложные алгоритмы.

Кэшируем в ElasticSearch

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

Механизм кэширования в ElasticSearch состоит из трех уровней:

  1. page cache (его можно называть кэшем файловой системы);

  2. shard-level request cache;

  3. query cache.

Основная идея первого уровня заключается в том, чтобы помещать данные в доступную память при их чтении с диска. Благодаря этому следующий запрос данных не требует поиска на диске. 

Кэш файловой системы:

  • уменьшает количество обращений к диску;

  • контролируется операционной системой;

  • из документации — рекомендуется выделять под этот кэш половину доступной памяти.

Этот уровень кэширования тяжело поддается управлению со стороны клиента, поэтому перейдем к следующему.

Shard-level request cache:

  • сохраняет результат запроса к шарду;

  • по умолчанию работает только в запросах с size = 0;

  • можно включать/выключать этот кэш параметром request_cache=true/false в запросе.

Второй уровень кэширования реализован на уровне шардов и позволяет кэшировать результаты агрегации, используя в качестве ключа кэша тело запроса. Не агрегационные запросы (те, в которых size ≠ 0) по умолчанию не кэшируются из-за неоптимальности использования кэша. Если хотите его использовать, не забывайте проставлять в ваших агрегационных запросах size = 0, так как по умолчанию он равняется 10. 

Третий уровень — это кэширование составляющих частей запроса. То есть часто повторяющиеся условия фильтраций будут закэшированы. 

Query cache:

  • работает более точечно, вычленяя часто повторяющиеся фильтрации в запросах;

  • работает только в filter контексте запроса.

ElasticSearch разделяет query и filter контексты запроса. Filter-контекст сразу же отбрасывает часть документов, которые не подходят под условия. Query-контекст помимо определения того, подходит ли документ под запрос, также высчитывает, насколько хорошо он подходит. При этом ElasticSearch не кэширует условия из query-контекста. Это неоптимально, ведь рассчитанный скор отличается от запроса к запросу. 

Уменьшаем вытеснение кэша

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

Такой же прием работает в кластере, когда в нем есть несколько реплик шардов на разных нодах, и мы хотим привязать пользователя к одним и тем же нодам, чтобы уменьшить разрозненность кэша. Это можно сделать, воспользовавшись параметром preference в запросе, используя как значение, например, идентификатор пользователя.

Так что в итоге?

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

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

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

Еще одна оптимизация, которую стоит реализовать на бэкенде, это кеширование. Сейчас запросы в индекс индивидуальны практически для всех пользователей, указавших адрес, из-за гиперлокальности некоторых предложений. Однако, если мы научимся делать более общие запросы, например, в разрезе региона, и доставать из индекса большие наборы данных, то сможем кешировать и отфильтровывать их с учетом местоположения пользователя. Так можно будет существенно снизить нагрузку на ElasticSearch и повысить скорость загрузки листинга, но об этом в следующий раз. А пока: 

  • читайте документацию;

  • выбирайте оптимальные настройки индексов;

  • уделяйте время оптимизации запросов;

  • и следите за кэшированием.

Надеюсь, наш опыт и идеи принесут пользу, и найдут применение в новых проектах.

Комментарии (6)


  1. lorriess
    14.09.2022 17:13

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


    1. shianmala Автор
      15.09.2022 22:24

      Именно так и мы и сделали, поместив предложения товара в nested поле, которое позволило делать точные запросы и агрегации по характеристикам предложений)

      Но на самом деле решение проблемы рассинхронизации, это trade off между попыткой держать данные в индексе актуальными и «не убивать» кластер постоянным потоком обновлений

      Очевидно, что с ростом товарного каталога и количества продавцов, а также с новыми динамическими акциями, пытаться держать индекс в актуальном состоянии по сути означает подвергать индекс постоянным обновлениям, что под капотом приводит к появлению новых сегментов в lucene индексах, что замедляет поиск (заставляя искать в большем количестве сегментов в каждом шарде), а также провоцирует эластик на периодический merge сегментов (дорогая операция в плане cpu/io).

      Поэтому мы пытаемся искать золотую середину между обновлениями индекса и возможностью актуализировать цены налету


      1. GAmoVeR
        16.09.2022 12:01

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

        Но надо считать эффективность


  1. mSnus
    15.09.2022 01:57
    +1

    А как у вас лопаты оказывались в зоотоварах? По логике они должны быть в разных индексах.. как они смешивались?


    1. shianmala Автор
      15.09.2022 21:59

      Заголовок больше для привлечения внимания, но на самом деле, если опуститься к реализации, то все товары хранятся в одном индексе)

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

      Принимая это во внимание, мы храним все товары в одном индексе, а коллекции(категории), в которые входит этот товар, храним массивом в документе товара


  1. md_backend_binance
    16.09.2022 14:49

    1) Тоесть у вас получается по 1й схеме из эластика может вернутся 100000 ids подходящих под запрос , но при этом эластик не содержит цены , если пользователь указал фильтр цены , получается вы на стороне сервеса будете через базу отсеивать резульаты? select * where Price > 10 and IDS IN (....10000000 return from ES) . Этоже абсолютно не хайлоуд?!

    Как при этом происходит пагинация? я даже не представляю

    2) Как я понял у вас в ES отдельно только важное о товарах, при этом есть сортировка по лайкам которые очевидно в другой базе данных, как происходит трансфер данных ? Опять из эластика приходит миллион подходящих результатов , а потом через базу с лайками сортируете ? Так же хочу обратить внимание почему я пишу миллионы , т.к. если у вас товары в 1 базе , а сортировка в другой , то нельзя вернуть "первые 100 подходящие под запрос ES", т.к. по сортировки лайков это 100 вернувшихся вообще могут быть далеко не первые .

    В общем интересны эти моменты