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

Это третья часть серии статей об обновлении кластера Elasticsearch без простоев и с минимальным воздействием на пользователей.

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

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

Как устроен поиск в Meltwater

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

Meltwater AND ((blog NEAR post) OR “search perfor*”)

Далее они преобразуются в запросы Elasticsearch, которые выполняются в кластере. Встроенный в платформу логический язык включает в себя множество различных типов запросов; наиболее популярны термины (terms), фразы (phrase), поиск ближайшего соседа (nears), поиск пересечений с диапазоном (ranges) и поиск с использованием подстановочных знаков (wildcards).

Последний тип разбивается на пять подкатегорий:

  • Префиксы: foo или ?oo;

  • Суффиксы: bar или ba?;

  • Подстановка с обеих сторон: bar;

  • Внутренняя(-ие) подстановка(-и): b?art;

  • Подстановки внутри фраз: “foo* b*a?”;

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

Обычно логические запросы, которые создают наши клиенты, огромны! Поисковый запрос может включать до 60 000 терминов и 30 000 подстановочных знаков, а всего в нем может насчитываться до 750 000 символов. Поддержка столь огромных запросов — одно из конкурентных преимуществ Meltwater на рынке, но это создает проблемы как для нашей команды, так и для инженеров Meltwater в целом.

В старом кластере для обработки таких запросов пришлось серьезно доработать форкнутую версию Elasticsearch. Подробности о внедренных оптимизациях можно узнать из одной из прошлых публикаций.

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

Базовая поисковая производительность новой версии Elasticsearch

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

В staging-окружении уже имелся старый кластер подходящего размера, который можно было использовать для сравнения. Одинаковые типы инстансов и одинаковое число узлов данных в обоих кластерах позволило сделать сравнение максимально объективным,.

Затем настала очередь production-запросов. Мы воспроизвели их на обоих кластерах и сравнили время выполнения. Изначально старый оптимизированный кластер был намного быстрее нового, особенно для запросов с подстановочными знаками. Кроме того, некоторые из запросов приводили к всплескам heap-памяти в новом кластере, из-за чего узлы переставали отвечать на пинги и, как следствие, покидали кластер.

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

Выполнение запросов с подстановочными знаками

Прежде чем продолжить, следует сказать пару слов о том, как Lucene (поисковая библиотека в основе Elasticsearch) индексирует документы, а затем выполняет на них запросы с подстановочными знаками.

Предположим, требуется индексировать следующий документ:

{ 
  "text": "wildcards wont win"
}

В процессе индексирования все термины поля text добавляются в словарь терминов. Словарь терминов — это, по сути, набор всех уникальных терминов в поле по всему индексу. Например, проиндексировав документ из примера, получим следующий словарь терминов:

[ 
  "wildcards", 
  "wont", 
  "win" 
]

При поиске (search) по полю text с помощью wildcard-запроса Elasticsearch заменяет подстановки подходящими терминами. Это делается путем поиска всех терминов в словаре терминов, которым соответствует подстановочный знак. Далее на основе найденных терминов создается новый логический запрос Lucene.

Примечание: речь идет о запросах типа BooleanQuery в Lucene, а не о логическом языке Meltwater, упомянутом ранее.

Таким образом, при поиске wi* (помните, в нашем индексе есть только вышеупомянутый документ?) Elasticsearch преобразует запрос с подстановкой в BooleanQuery Lucene следующего вида:

(wildcards OR win)

Затем с его помощью ищутся документы, соответствующие запросу.

Подстановочные префиксы

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

Однако гораздо сложнее найти термины, соответствующие запросу, когда тот начинается с подстановочного знака. Не существует индексной структуры, обеспечивающей эффективный поиск терминов, которые заканчиваются некоторым набором символов, например, *sticsearch. В этом случае Elasticsearch приходится проводить regex-проверку каждого термина в словаре, чтобы найти те, которые соответствуют шаблону с подстановочным символом. Для больших индексов это замедляет работу на несколько порядков. В документации по Elasticsearch данная проблема упоминается в разделе «Запросы с подстановочными знаками»:

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

К счастью, существует довольно простой способ оптимизации для поиска с подстановочными префиксами. Если проиндексировать все термины в обратном порядке, подстановочный префикс превратится в суффикс, после чего можно вести привычный быстрый поиск по отраженным терминам. В современных версиях Elasticsearch это легко настроить, создав многопольное сопоставление с подполем, которое использует обратный фильтр (reverse token filter).

Недостаток такой оптимизации — каждое поле, к которому добавляется обратный индекс, занимает в два раза больше места на диске. В нашем случае не было необходимости включать эту функцию для всех полей. Большинство запросов с подстановочными знаками нацелены только на небольшое подмножество доступных полей, в основном на текстовые поля title и body. С учетом этого размеры индексов выросли примерно на 30%. Дополнительное использование диска компенсировалось меньшим потреблением ресурсов процессора и памяти во время поиска, а выигрыш в производительности обрадовал клиентов.

Ограничения на перезапись запросов с подстановочными знаками

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

С помощью инструмента под названием Blunders.io удалось определить, что основной объем heap-памяти занимал класс SpanBooleanQueryRewriteWithMaxClause. Он отвечал за переписывание подстановочных знаков в интервальных запросах-поисках соседа, которые используются для реализации поиска с подстановками внутри фраз (Wildcards within phrases), которым так гордится Meltwater.

Типичная картина была следующей: внезапно потребление heap-памяти на узлах с данными подскакивало, что приводило к недостатку оперативной памяти. В результате этого процесс Elasticsearch «умирал», а мастер исключал узел из кластера. Пример такого поведения можно увидеть на рисунке 2:

Рисунок 2. Примерно у 60% узлов закончилась память и они были исключены из кластера.

Эти инциденты возникали из-за комбинации двух вещей: увеличения параметра indices.query.bool.max_clause_count и поисковых запросов с огромным количеством подстановочных знаков во фразах. При замене подстановочных знаков терминами-кандидатами на поиск все они оставались в памяти (в виде длинных OR-цепочек) при выполнении запроса. Если список таких терминов был слишком большим, чтобы поместиться в heap-памяти, на некоторых узлах Elasticsearch заканчивалась память и они «падали».

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

В старом кластере риск возникновения проблем с памятью устранялся путем внесения изменений в сам Elasticsearch. Была добавлена эвристика, учитывающая специфику домена, кэши замененных терминов, а в некоторых случаях вообще удавалось избегать замены подстановочных знаков с помощью ленивых вычислений. Все это позволяло обрабатывать большие запросы с подстановочными знаками. В конечном итоге были реализованы пределы на размер запросов, требующих замены терминов, аналогичные тем, которые имеются в современных версиях Elasticsearch. Чтобы не затрагивать слишком многих пользователей, предел был установлен на 100 тыс. терминов. Это намного больше, чем значение по умолчанию в Elasticsearch 7 (1024) и автоматически вычисляемый суммарный лимит, который использует Elasticsearch 8.

Учитывая лимит, установленный в старом кластере, многие из 800 тыс. сохраненных клиентских запросов с подстановочными знаками требуют замены куда большего числа терминов, нежели 1024. Мы наивно полагали, что достаточно увеличить предел с 1024 до 100 000, чтобы сделать новый кластер совместимым со старым. Но учитывая проблемы со стабильностью, которые это вызвало, стало очевидно, что увеличенный предел не пройдет, если нужен надежный кластер.

Начался поиск оптимального предела для нашего сценария использования. Была проведена серия контролируемых тестов, в ходе которых предел постоянно снижался и попутно обрабатывались поисковые запросы. Это продолжалось до исчезновения каких-либо существенных проблем с heap-памятью. В итоге выяснилось, что безопасный предел — 2 000 терминов. Получился достаточно стабильный кластер (даже для наихудших сценариев), что видно на рисунке 3. Да, все еще наблюдались всплески использования heap-памяти, но узлы больше не «падали» из-за ее дефицита.

Рисунок 3. Ограничение на число терминов при замене подстановочных знаков позволило решить проблемы с памятью.

Однако проблема с новым пределом заключалась в том, что 15% клиентских поисковых запросов (или более 100 тыс.) в него не укладывались. Задача просмотреть их все, изменить, а затем проверить работоспособность оказалась бы непосильной.

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

Индекс-префиксы

Индекс-префиксы — функция в Elasticsearch, которая при индексировании разбивает термин, как если бы за каждым символом следовал подстановочный знак. Все возможные префиксы термина индексируются в отдельное подполе _index_prefix.

Например, при индексации документа с термином Monkey поле _index_prefix будет содержать термины M, Mo, Mon, Monk, Monke, Monkey. С помощью этого индекса можно преобразовать запрос с подстановочным знаком (например, Monk*) в запрос с обычным термином (Monk) по полю _index_prefix без какой-либо замены подстановочного знака. Таким образом, по сути, каждый подстановочный суффикс в рассматривается как запрос с обычным термином, что дает огромный выигрыш в производительности. Обратите внимание, что для корректной работы этого метода необходимо использовать префиксный запрос (за это отвечал сервис-посредник).

Благодаря использованию индекс-префиксов использование heap-памяти на узлах с данными заметно сократилось (см. рисунок 4). С точки зрения стабильности это просто потрясающе! В старом кластере такого не было несмотря на все оптимизации и кастомный код.

Рисунок 4. Использование heap-памяти после включения индекс-префиксов.

Недостаток этого подхода, как вы уже, вероятно, догадались, в том, что индекс-префиксы занимают кучу места на диске. Чтобы удержать использование диска в разумных пределах, индекс-префиксы были добавлены только к нескольким выбранным полям, по которым поиск часто ведется с помощью подстановочных знаков. Но даже тогда совокупное использование диска выросло примерно на 60%.

Итоги

Это может показаться контринтуитивным, но, несмотря на возросшее использование дисков, индекс-префиксы сделали новый кластер намного дешевле старого. Из-за проблем с heap-памятью в старом кластере его приходилось масштабировать по heap-памяти, чтобы гарантировать стабильность. Индекс-префиксы позволили масштабировать новый кластер по дисковому пространству. В результате число узлов с данными (эквивалентных по параметрам) снизилось с 1100 до 600, затраты сократились примерно на 45%.

Кроме того, новый кластер был намного быстрее старого. Elasticsearch больше не нужно было тратить уйму времени на переписывание запросов и обработку подстановочных знаков; среднее время поиска сократилось примерно на 25%, а 95-й процентиль — на 33%.

С сожалению, индекс-префиксы не смогли помочь с подстановками внутри слов и с обеих сторон от слова. К счастью, таких запросов было гораздо меньше, что позволило привлечь нашу фантастическую поддержку. Они помогли изменить все неудачные внутренние/наружные подстановки на более оптимальные варианты.

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

Использование индекс-префиксов вместе с оптимизацией подстановочных префиксов позволило получить гораздо более дешевый, стабильный и мощный кластер, и все это — на базе официальной «ванильной» версии Elasticsearch!

На этом заканчивается третья часть серии публикаций об обновлении кластера Elasticsearch. Следите за обновлениями: очередная статья будет опубликована на следующей неделе.

Чтобы оставаться в курсе событий, подписывайтесь на нас в Twitter или Instagram.

P.S.

Читайте также в нашем блоге: