Всем привет! Меня зовут Азамат, я backend-разработчик в Циан, занимаюсь поисковыми сервисами. В статье я расскажу, как мы в команде оптимизировали поиск объявлений по датам бронирования в разделе посуточной аренды. Как мы решили проблему роста потребления cpu, ускорили сам поиск и удешевили железо.

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

Проблема

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

Также имелись ограничения. У каждого объявления есть календарь с доступными датами и ценами за эти даты на полгода вперед.

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

Изначальное решение — nested поле

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

Было создано nested поле, которое хранит в себе список всех доступных дат и цен. Выглядит это так:

# документ в elasticsearch с объявлением
{
    ...
    "available_dates": [ # поле с типом nested
        {"date": "01-09-2022", "price": 1500},
        {"date": "02-09-2022", "price": 1600},
        ... # таких объектов до 120 штук
    ]
    ...
}

И соответственно, если мы хотим найти объявления за период с 1 сентября до 3 сентября по цене от 1000 до 2000 рублей в сутки, то ищем объявления, у которых есть все свободные даты за этот период. Выглядит это так:

{
    "query": {
        "bool": {
            "must": [
                {
                    # Условие для 1-го сентября
                    "nested": {
                        "path": "available_dates",
                        "query": {
                            "bool": {
                                "must": [
                                    {"range": {"available_dates.price": {"gte": 1000, "lte": 2000}}},
                                    {"term": {"available_dates.date": "01-09-2022"}}
                                ]
                            }
                            
                            
                        }
                    }
                }
                {
                    # Условие для 2-го сентября, в условии отличается только дата
                    "nested": {
                        "path": "available_dates",
                        "query": {
                            "bool": {
                                "must": [
                                    {"range": {"available_dates.price": {"gte": 1000, "lte": 2000}}},
                                    {"term": {"available_dates.date": "02-09-2022"}}
                                ]
                            }
                            
                            
                        }
                    }
                }
                # 3-го сентября нет, потому что это дата выезда, в этот день может заселиться другой человек
            ]
        }
    }
}

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

Минусы этого решения

Главная проблема — это рост количества удалённых документов в индексе. До добавления дат бронирования в индексе количество было около 3 млн документов. После добавления стало 10—12 млн документов (это не количество объявлений). Из-за этого сильно выросла нагрузка на cpu и кластер перестал справляться с нагрузкой.

Хочу отметить, что документы это не только отдельные объявления, но и отдельные даты бронирования. Об этом подробнее я пишу ниже.
Как же так получилось? Почему выросло количество удалённых документов? Чтобы понять это, нужно разобраться:

  • как работает переиндексация объявлений

  • как хранятся nested документы

  • как формируются сегменты, и как они подчищаются

Как работает переиндексация объявлений

Когда делается обновление документа в эластике, на самом деле, делается 2 операции:

  1. Документ в индексе помечается удалённым (!не удаляется, а только помечается удалённым)

  2. Создается новый документ с новыми данными
    Документ, помеченный удаленным, хранится в памяти до тех пор, пока не будет объединение сегментов в индексе.

    Подробнее можно почитать об этом тут.

Как хранятся nested-документы

Каждый объект, хранящийся в nested-поле, является отдельным документом на уровне lucene (документация)

В этой схеме каждое объявление представляет собой 1 документ для полей самого объявления + 120 документов для каждой даты бронирования с ценой (потому что мы храним 120 дат бронирования). Поэтому, когда мы обновляем одно объявление, в котором хранится 120 дат бронирования, то на самом деле происходят следующие операции:

  • 121 документ помечается удалённым (1 объявление + 120 дат бронирования)

  • Создаются 121 новых документов с новыми значениями. Причём, если мы обновляем только одну дату бронирования, то всё равно переиндексируется 121 документ.

Как формируются сегменты и как они подчищаются

Сегменты — это блоки, в которых хранятся документы. Они создаются по мере добавления новых документов. Когда сегмент доходит до определённого размера, он перестает наполняться, вместо этого создается новый сегмент. После того, как достигается определенное количество сегментов, они начинают объединяться. В момент объединения из сегментов удаляются документы, помеченные удаленными.

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

Собираем все вместе

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

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

Поэтому было решено избавиться от nested-поля, и делать фильтрацию через painless-скрипт.

Новое решение

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

calendar_date_availability = '001101' # массив доступности дат начиная с 10 сентября
calendar_price_availability = [0, 0, 1000, 1500, 0, 1000] # массив цен за каждую дату начиная с 10 сентября
booking_start_day = 9 # количество дней после 1 сентября 2022 года

calendar_date_availability — это текстовое поле, 1 — обозначает, что дата доступна для бронирования, 0 — не доступна. Почему именно текстовое поле расскажу ниже.

calendar_price_availability — это массив чисел, каждый элемент — это цена за дату бронирования. Порядковый индекс элемента соответствует символу в calendar_date_availability.

booking_start_day — это поле, благодаря которому мы сможем понять, с какой даты начинается маска calendar_date_availability и массив цен calendar_price_availability внутри painless скрипта. Чтобы его посчитать, мы взяли условную дату, от которой делается отсчет дней — 1 сентября 2022 года. В момент индексации мы считаем количество дней от этой условной даты. Затем, когда выполняем поисковый запрос, мы сможем понять внутри скрипта, с какой даты начинается перечисление доступных дат в calendar_date_availability и отфильтровать объявление по нужным датам.

Маппинги этих полей:

{
    "properties": {
        "props": {
            "properties": {
                "calendar_date_availability": {"type": "text", "fielddata": true},
                "calendar_price_availability": {"type": "integer", "store": true},
                "booking_start_day": {"type": "integer"},
            }
        }
    }
}

Painless-скрипт, фильтрующий по этим полям, выглядит так:

# Определяем начальный и конечный индекс, в котором проверяем доступность дат для данного объявления
int start_index = params['start_days_from_point'] - (int) doc['props.booking_start_day'].value;  
int end_index = params['end_days_from_point'] - (int)doc['props.booking_start_day'].value;

char[] dates_availability = doc['props.calendar_date_availability'].value.toCharArray();

# Проверяем, что есть информация о доступности за все искомые дни
if (dates_availability.length <= end_index) {
    return false;
}

# Проверяем, что все даты за искомый период доступны
for (int i = start_index; i <= end_index; i++) {
    if (dates_availability[i] == (char)'0') {
        return false;
    }
}

# Считаем среднюю цену за указанный период и определяем, попадает ли она в запрашиваемый диапазон цен
int min_price = params['min_price'];  
int max_price = params['max_price'];  
def prices = params._fields['props.calendar_price_availability'].values; # доступ к store полю отличается
int total_price = 0;  
for (int i = start_index; i <= end_index; i++) {
    total_price += prices[i];
}  
int average_price = total_price / (end_index - start_index + 1);  
if (
    average_price >= min_price &&
    (average_price <= max_price || max_price == 0)
) {
    return true;
}
return false;

Нюансы

Мы не можем использовать маппинги по умолчанию для полей calendar_date_availability, calendar_price_availability. Есть несколько нюансов:

Обычные массивы не сохраняют порядок элементов на этапе поиска (документация). Поэтому calendar_date_availability имеет тип text, и в скрипте он парсится на символы. Чтобы иметь доступ к тексту из painless, нужно, чтобы был выставлен флаг "fielddata": true. Можно рассмотреть другие варианты:

  1. Хранить массив boolean в поле с флагом stored в маппинге. Этот способ повышает время поиска, из-за того, что доступ к такому полю становится в несколько раз дольше, чем к обычному.

  2. В новых версиях эластика есть тип dense-vector, который сохраняет порядок элементов. Но поскольку в нашей версии elasticsearch его нет, этот вариант нам не подошёл. Ещё это поле имеет фиксированную длину, если мы захотим её увеличить, то придется заводить новое поле.

  3. Хранить объект с ключами '0','1','2' . В нашем случае дат бронирования было 120, и поэтому количество полей было бы сильно большим, и неизвестно к каким последствиям это могло привести. К тому же количество хранимых дней может увеличиться.

Поле calendar_price_availability как раз имеет флаг "store": true в маппинге, и это позволяет получать исходный порядок элементов. Но приходится жертвовать скоростью. Когда происходит фильтрация с учетом цен, поиск получается дольше в 5—10 раз. В самом скрипте доступ к такому полю происходит по-другому.

Использовать аналогичный подход, как с датами бронирования (использовать строковый тип), не получилось. Пришлось бы парсить строку по разделителю. Это само по себе не быстрее, чем долгий доступ к store-полю.

Чтобы уменьшить влияние долгого доступа к ценам из-за store поля, мы сделали оптимизацию. На этапе индексации мы определяем, является ли цена на все даты одинаковой, если да, то записываем её в отдельное поле. На этапе поиска мы смотрим, есть ли единая цена на все даты. Если да, то избегаем доступа к store-массиву и фильтруем только по единой цене. Благодаря тому, что 90% объявлений имеют одинаковые цены на все даты бронирования, эта оптимизация дает существенный рост скорости (примерно в 5 раз) при фильтрации объявлений по цене.

Для недоступных дат бронирования в массиве с ценами хранится значение 0. Не стоит хранить null, иначе все значения null объединятся в одну, и массив может оказаться другой длины. Тогда вы не сможете искать соответствующие цены в массиве по таким же индексам.

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

Ещё был рассмотрен Parent / Join

Parent / Join — это механизм, который позволяет запрашивать документы с учётом связей между ними. Он чем-то похож на join-запрос в реляционных базах, но имеет ряд ограничений.

Чтобы сделать такой поиск, нужно в маппинге индекса указать поле с типом join. Дочерние и родительские документы находятся в одном индексе. В маппинге нужно указать, какие связи могут быть у документов, например {объявление} -> {дата бронирования}. При индексации для parent-документа нужно указать тип {объявление}, а для child документа — тип {дата бронирования} и id родительского объявления. Затем мы сможем искать и агрегировать документы с учётом этой связи.

Например, для нашего случая можно было бы для объявлений создать связь {объявление} -> {дата бронирования} и отдельно индексировать даты бронирования как отдельные документы со связью с родительским документом. А затем делать поиск через has_child. Т.е. запрашивать объявления, которые имеют нужные даты бронирования и соответствующую цену на эти даты.

Но у этого подхода есть несколько ограничений, из-за которых решили от него отказаться:

  • Поскольку дочерние документы — это такие же документы, как родительские, то id у них могут пересекаться. Поэтому нужно придумать, как генерировать id для каждой даты бронирования, чтобы они не пересеклись с id объявлений.

  • Объявления и даты должны быть на одной шарде (нужно контролировать роутинг при индексации дат бронирования (routing — это id объявления) индекс должен был single_type. Придётся создавать новый индекс с настройкой single_type=true (эта настройка говорит, что индекс может иметь только один тип маппинга документа, в 7-й и более новых версиях типы документов считаются устаревшими).

  • Даты бронирования нужно индексировать отдельно от объявлений.

Неизвестно, какие результаты может дать этот подход, потому что мы не экспериментировали с ним. Но есть опасение, что он может быть менее эффективным, чем поиск по nested полям, поскольку nested документы находятся в одном lucene блоке, а parent/child документы находятся лишь на одной шарде и поиск по ним сложнее (обсуждение на эту тему). Если у вас был опыт использования parent/child в похожей задаче, напишите об этом в комментарии.

Итог

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

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