Технологии
Elasticsearch
Как использовать Elasticsearch для решения широкого круга задач по System Design
Многие задачи по проектированию систем включают аспекты поиска и извлечения данных. Хотя большинство систем баз данных довольно хорошо справляются с этим (например, Postgres с полнотекстовым индексом вполне достаточно для многих задач), на определенном масштабе или уровне сложности вам потребуется специализированная система. Обычно такая система предполагает выполнение множества схожих требований, таких как сортировка, фильтрация, ранжирование, фасетный поиск и др. Именно тогда мы обращаемся к одной из наиболее известных поисковых систем: Elasticsearch.
С точки зрения интервью, этот разбор позволит рассмотреть два различных подхода к пониманию Elasticsearch:
-
Во-первых, вы узнаете, как пользоваться Elasticsearch. Это даст вам мощный инструмент в вашем арсенале. Редко встречаются вопросы поиска и извлечения данных, которые были бы слишком сложными для Elasticsearch. Если вы проходите собеседование в стартап или участвуете в интервью по продуктовой архитектуре, знание Elasticsearch будет полезно. Вы можете пропустить этот раздел, если ранее уже пользовались Elasticsearch.
-
Во-вторых, вы изучите внутреннее устройство Elasticsearch. Как выдающийся образец инженерии распределенных систем, Elasticsearch объединяет множество различных концепций, которые могут применяться даже вне задач поиска и извлечения данных. Некоторые придирчивые интервьюеры могут попросить вас представить, что Elasticsearch не существует, и самостоятельно объяснить некоторые ключевые концепции. Это чаще встречается на позициях, ориентированных на инфраструктуру.
Elasticsearch - это огромный проект, над которым работали более десяти лет, поэтому здесь мы не сможем охватить все его возможности и функции, однако постараемся затронуть важные моменты. Вперед!
Основные понятия
Давайте начнем с терминологии. Важнейшие понятия Elasticsearch с точки зрения клиента - это документы, индексы, отображения (mappings) и поля (fields).
Документы
Документы являются отдельными единицами данных, по которым осуществляется поиск. Термином "документ" может обозначаться что угодно, например, веб-сайт или пост в блоге, поэтому не стоит слишком привязываться к этому названию. Просто представьте себе обычный объект JSON. Например, книги в нашем книжном магазине:
{
"id": "XYZ123",
"title": "Алгоритмы: построение и анализ",
"author": "Томас Кормен",
"price": 400,
"createdAt": "2026-01-01T00:00:00.000Z"
}Индексы
Индекс представляет собой коллекцию документов. Каждый документ ассоциируется с уникальным идентификатором и набором полей, которые представляют собой пары ключ-значение, содержащие искомые данные. Представляйте индекс как таблицу базы данных. Поиск выполняется с использованием индексов и возвращает документы, соответствующие критериям поиска.
Обратите внимание на перегрузку термина "индекс", который также часто используется для обозначения вспомогательных структур данных, ускоряющих поиск. Мы постараемся пояснять, какой именно "индекс" имеется в виду, чтобы избежать путаницы.
Мы создадим индекс для наших книг, но у нас могут быть индексы и для любых других сущностей, релевантных нашему бизнесу. Отзывы, пользователи, заказы и прочее.
Отображения (Mappings) и Поля
Наконец, отображение - это схема индекса. Оно определяет поля, которые будет содержать индекс, типы данных каждого поля и любые другие свойства, такие как способ обработки и индексирования поля. Вы можете поместить любую желаемую информацию в документ, но именно отображение определяет, какие поля подлежат поиску и какого типа данные они содержат.
Эти типы могут быть произвольной сложности. Например, вы можете вкладывать объекты и массивы внутрь ваших документов, использовать специальные геопространственные типы, определять собственные анализаторы или даже применять эмбеддинги для семантического поиска. Мы не будем рассматривать все возможные варианты, но если вы подозреваете, что вам понадобится что-то особенное для поиска, велика вероятность, что Elasticsearch уже поддерживает такую возможность.
Вот пример отображения:
{
"properties": {
"id": { "type": "keyword" },
"title": { "type": "text" },
"author": { "type": "text" },
"price": { "type": "float" },
"createdAt": { "type": "date" }
}
}Отображение критически важно, поскольку оно подсказывает Elasticsearch, каким
образом интерпретировать хранимые вами данные. Например, в приведенном выше
отображении мы определяем поле id как тип keyword. Это означает, что
значение поля id обрабатывается целиком, а не как строка, которую можно
токенизировать. Это имеет большое значение, потому что позволяет проводить более
эффективный поиск и сортировку. Чаще всего я ищу единственное значение поля
id, аналогично хеш-таблице, в то время как запросы по полю title могут
искать включение определенного значения внутри заголовков, аналогично
инвертированному индексу.
Отображения также имеют важное влияние на производительность вашего кластера:
если включить в отображение много полей, которые фактически не используются в
поиске, это увеличит объем памяти, потребляемый каждым индексом. Это может
привести к проблемам производительности и увеличению затрат. Допустим, у вас
есть объект User с десятью полями, но вы разрешаете поиск только по двум из
них. Если вы отображаете весь объект, память расходуется впустую на восемь
неиспользуемых полей. Это важно отметить, потому что большая часть контроля над
производительностью запросов зависит от изменений в отображениях и различных
параметрах кластера. Позже мы вернемся к этому аспекту.
Базовое использование
Далее давайте пройдемся по операциям по созданию индекса, хранению данных и выполнению поиска, чтобы понять основные функциональные возможности. У Elasticsearch есть удобный REST API, позволяющий легко выполнять эти операции, хотя доступно также множество графических клиентов.
Создание индекса
Простой PUT запрос создаст индекс с динамическим отображением, одним шардом и одной репликой. Эти параметры можно обновить после создания индекса.
// PUT /books
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1
}
}Установка отображения
Если динамическое отображение не подходит (возможно, большинство полей наших данных не подлежит поиску), мы можем заранее задать отображение для индекса. Это позволяет Elasticsearch определить, какие поля должны рассматриваться как доступные для поиска и какие типы данных ожидать в этих полях.
// PUT /books/_mapping
{
"properties": {
"title": { "type": "text" },
"author": { "type": "keyword" },
"description": { "type": "text" },
"price": { "type": "float" },
"publish_date": { "type": "date" },
"categories": { "type": "keyword" },
"reviews": {
"type": "nested",
"properties": {
"user": { "type": "keyword" },
"rating": { "type": "integer" },
"comment": { "type": "text" }
}
}
}
}Здесь мы предварительно зарегистрировали поля, которые хотим сделать доступными для поиска в нашем книжном магазине. Когда мы добавляем документы, Elasticsearch извлекает значения для этих полей и индексирует их таким образом, чтобы они были готовы к поиску.
Отображения могут быть весьма сложными и меняться со временем по мере добавления
новых данных и появления новых сценариев использования. В качестве примера
обратите внимание на вложенное поле reviews. Здесь каждый отзыв является
вложенным документом со своими собственными полями. Это отличается от плоской
структуры, где каждый отзыв представлен отдельным документом.
Выбор включать ли отзывы непосредственно в книги главным образом зависит от паттернов записи и чтения данных и может стать предметом пристального внимания со стороны требовательного интервьюера. Если отзывы редко обновляются, но часто просматриваются, возможно, эффективнее встроить их прямо в документы книг. Иначе лучше создать отдельный индекс для отзывов. Думайте об этом как о компромиссе между нормализацией и денормализацией, который может быть знаком вам, если вы имели дело с SQL-базами данных.
Добавление документов
Итак, у нас есть индекс и отображение. Далее, нам нужно добавить документы в этот индекс! Это простой POST запрос к эндпоинту /_doc.
// POST /books/_doc
{
"title": "Думай медленно... Решай быстро",
"author": "Даниэль Канеман",
"description": "Нобелевский лауреат Даниэль Канеман объясняет, почему мы подчас совершаем нерациональные поступки и как мы принимаем неверные решения.",
"price": 600,
"publish_date": "2011-01-10",
"categories": ["Psychology", "Non-fiction"],
"reviews": [
{
"user": "reader1",
"rating": 5,
"comment": "Замечательно, книга открыла для меня целый мир!"
},
{
"user": "reader2",
"rating": 4,
"comment": "Буквально на каждой странице описаны интересные и неочевидные механизмы человеческого мышления."
}
]
}Если я хочу добавить еще один документ, я могу сделать еще один запрос.
// POST /books/_doc
{
"title": "Тысячеликий герой",
"author": "Джозеф Кэмпбелл",
"description": "Что объединяет библейского Моисея, буддийского Гаутаму и Люка Скайуокера из «Звездных войн»?",
"price": 450,
"publish_date": "1949-03-10",
"categories": ["Mythology", "Non-fiction"],
"reviews": [
{
"user": "reader3",
"rating": 5,
"comment": "Эта книга относится к разряду тех, что нужно прочесть несколько раз."
}
]
}Каждый из этих запросов вернет идентификатор документа вместе с информацией о том, каким образом документ сохранен в кластере.
{
"_index": "books",
"_id": "oWtBJIBtqCdRNhR6pT",
"_version": 1, // Обратите внимание!
"result": "created",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 0,
"_primary_term": 1
}Особое внимание обратите на поле _version. Это специальное поле, которое
Elasticsearch использует для обеспечения атомарного обновления документа.
Обновление документов
Обновление документа аналогично созданию документа, однако вам необходимо указать идентификатор документа в URL. Мы можем повысить цену книги, задав весь документ целиком:
// PUT /books/_doc/oWtBJIBtqCdRNhR6pT
{
"title": "Тысячеликий герой",
"author": "Джозеф Кэмпбелл",
"description": "Что объединяет библейского Моисея, буддийского Гаутаму и Люка Скайуокера из «Звездных войн»?",
"price": 550,
"publish_date": "1949-03-10",
"categories": ["Mythology", "Non-fiction"],
"reviews": [
{
"user": "reader3",
"rating": 5,
"comment": "Эта книга относится к разряду тех, что нужно прочесть несколько раз."
}
]
}Это может быть уместно в некоторых случаях, но это рискованно. Если другой процесс одновременно обновляет тот же самый документ, вы можете перезаписать внесенные им изменения.
Чтобы защититься от этого, мы можем воспользоваться полем _version, указанным
ранее, и задать условие, что обновление документа должно произойти только в том
случае, если версия совпадает. Следующий запрос выполнит обновление документа
только в том случае, если версия равна 1. Иначе возникнет ошибка.
// PUT /books/_doc/oWtBJIBtqCdRNhR6pT?version=1
...Эти ошибки предоставляют клиентам возможность обработать конфликт и повторить запрос, что является простым примером [оптимистичного управления конкурентностью] (https://en.wikipedia.org/wiki/Optimistic_concurrency_control).
Наконец, эндпоинт _update (обратите внимание на метод POST) позволяет обновить
некоторые поля документа без необходимости извлекать весь документ.
// POST /books/_update/oWtBJIBtqCdRNhR6pT
{
"doc": {
"price": 600
}
}Помните, что Elasticsearch распределенный, асинхронный и поддерживает параллельную обработку. Наш запрос потенциально отправляется на многие разные узлы, и запросы могут обрабатываться в произвольном порядке. Явное указание семантики обновления гарантирует, что обновления происходят именно так, как мы ожидаем.
Обратите внимание на некоторые из этих механизмов проектирования API. Некоторые компании и интервьюеры просят углубляться в проектирование API, и изучение API популярных открытых проектов - отличный способ развить свои навыки.
Поиск
Хорошо, у нас есть индекс с документами, как же нам искать среди них? Elasticsearch упрощает эту задачу! Синтаксис запросов Elasticsearch очень похож на синтаксис SQL и основан на JSON, что значительно облегчает работу.
Простым запросом может быть поиск книг с названием, содержащим слово "Думай":
// GET /books/_search
{
"query": {
"match": {
"title": "Думай"
}
}
}Это тело GET запроса? Да и нет. Elasticsearch примет GET-запросы с телом, либо, если мы имеем дело с прокси-сервером, нам возможно потребуется упаковать этот объект в строку запроса или использовать эндпоинт POST. Мы записали это таким образом, чтобы было легче читать.
Мы также можем искать книги с названием, содержащим слово "Думай", цена которых меньше 700:
// GET /books/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "Думай" } },
{ "range": { "price": { "lte": 700 } } }
]
}
}
}Наконец, мы можем осуществить поиск внутри вложенного поля "reviews" для поиска книг с отзывом, содержащим слово "замечательно":
// GET /books/_search
{
"query": {
"nested": {
"path": "reviews",
"query": {
"bool": {
"must": [
{ "match": { "reviews.comment": "замечательно" } },
{ "range": { "reviews.rating": { "gte": 4 } } }
]
}
}
}
}
}Ответ может выглядеть следующим образом:
{
"took": 7,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 2,
"relation": "eq"
},
"max_score": 4.1806526,
"hits": [
{
"_index": "books",
"_type": "_doc",
"_id": "1",
"_score": 4.1806526,
"_source": {
"title": "Думай медленно... Решай быстро",
"author": "Даниэль Канеман",
"price": 600
}
},
{
"_index": "books",
"_type": "_doc",
"_id": "2",
"_score": 4.0876543,
"_source": {
"title": "Думай и богатей",
"author": "Наполеон Хилл",
"price": 640
}
}
]
}
}Мы используем отображение и применяем серию ограничений/фильтров к данным, чтобы получить результаты. Результаты содержат как идентификаторы документов соответствующих книг, так и оценки релевантности (подробнее об этом чуть позже), а также исходные документы (в данном случае наш JSON с книгами), если мы явно не указали иного.
Сортировка
После того, как мы сузили круг результатов до набора книг, которые считаем интересными, каким образом нам отсортировать их так, чтобы наши пользователи видели лучшие результаты вверху страницы?
Сортировка является ключевой функцией в Elasticsearch, позволяющей упорядочивать результаты вашего поиска на основании определенных полей.
Базовая сортировка
Чтобы отсортировать результаты, вы можете использовать параметр sort в вашем поисковом запросе. Вот простой пример, сортирующий книги по цене в порядке возрастания:
// GET /books/_search
{
"sort": [{ "price": "asc" }],
"query": {
"match_all": {}
}
}Вы также можете сортировать по нескольким полям. Например, чтобы сначала отсортировать по возрастанию цены, а затем по убыванию даты публикации:
// GET /books/_search
{
"sort": [{ "price": "asc" }, { "publish_date": "desc" }],
"query": {
"match_all": {}
}
}Сортировка с помощью скрипта
Elasticsearch также позволяет сортировать на основе пользовательских скриптов (используя язык скриптов "Painless"). Это полезно, когда вам нужно сортировать по вычисляемому значению. Вот пример, сортирующий книги по скидочной цене (со скидкой 10%):
// GET /books/_search
{
"sort": [
{
"_script": {
"type": "number",
"script": {
"source": "doc['price'].value * 0.9"
},
"order": "asc"
}
}
],
"query": {
"match_all": {}
}
}Сортировка по вложенным полям
При работе с вложенными полями необходимо использовать вложенную сортировку. Это гарантирует, что значения сортировки берутся из одного и того же вложенного объекта. Вот как можно отсортировать книги по наивысшему рейтингу отзывов:
// GET /books/_search
{
"sort": [
{
"reviews.rating": {
"order": "desc",
"mode": "max",
"nested": {
"path": "reviews"
}
}
}
],
"query": {
"match_all": {}
}
}Сортировка по релевантности
Если мы не задаем порядок сортировки, Elasticsearch автоматически сортирует результаты по релевантности (_score). Этот механизм настраиваемый, однако алгоритм расчета релевантности по умолчанию тесно связан с методом TF-IDF (Term Frequency-Inverse Document Frequency).
Если вы еще не знакомы с TF-IDF, стоит потратить время на изучение этого метода, поскольку он может быть полезен во многих задачах.
Разбиение на страницы и курсоры
После того, как мы определили правила фильтрации и сортировки результатов, наша задача - вернуть их пользователю, а точнее - осуществить постраничный вывод. Разбиение на страницы в Elasticsearch позволяет извлекать подмножество результатов поиска, обычно используемое для отображения результатов на нескольких страницах. Также важно учитывать, хотим ли мы сохранять состояние или запускать заново наш поисковый запрос на каждой странице.
Разбивка на страницы From/Size
Это самая простая форма разбивки, при которой вы задаете:
from: стартовый индекс результатовsize: количество возвращаемых результатов
Пример запроса:
// GET /my_index/_search
{
"from": 0,
"size": 10,
"query": {
"match": {
"title": "elasticsearch"
}
}
}Однако этот метод становится неэффективным при глубокой разбивке (например, при просмотре результатов глубже 10 000 записей) из-за накладных расходов на сортировку и получение всех предыдущих документов. Кластеру приходится получать и сортировать все эти документы при каждом запросе, что может оказаться чрезмерно затратным.
Метод Search After
Этот метод является более эффективным для глубокой разбивки. Он использует значения сортировки последнего результата в качестве отправной точки для следующей страницы. Используя эти значения, мы можем ограничить каждую страницу извлечением только тех документов, которые следуют за последним документом предыдущей страницы, постепенно сужая область поиска.
Пример:
// GET /my_index/_search
{
"size": 10,
"query": {
"match": {
"title": "elasticsearch"
}
},
"sort": [{ "date": "desc" }, { "_id": "desc" }],
"search_after": [1234567890, "654321"]
}Параметр search_after использует значения сортировки последнего результата
предыдущей страницы. Вот как это работает:
- В первом запросе мы не включаем параметр
search_after. - Из результатов первого запроса мы берем значения сортировки последнего документа.
- Эти значения сортировки становятся параметром
search_afterдля следующего запроса.
В приведенном примере:
1234567890- это отметка времени (timestamp), значение поляdateдля последнего документа на предыдущей странице."654321"- это_idпоследнего документа на предыдущей странице.
Предоставляя эти значения, Elasticsearch точно знает, откуда начать следующую страницу, что делает этот метод очень эффективным даже при глубокой постраничной навигации. Такой подход гарантирует следующее:
- Вы не упустите ни одного документа, добавленного на последующих страницах, даже если новые документы были добавлены между запросами.
- Вы не получите дублирующихся результатов на разных страницах.
Однако, это требует сохранения состояния на стороне клиента (помнить значения сортировки последнего документа) и не позволяет реализовать случайный доступ к страницам - мы можем двигаться только вперед по результатам. Этот способ постраничной навигации также рискует пропустить документы на предыдущих страницах, если данные обновляются или удаляются.
Курсоры
Курсоры в Elasticsearch обеспечивают возможность постраничной разбивки результатов поиска с сохранением состояния, решая проблему смещения документов. Курсоры поддерживают согласованность между постраничными запросами, однако требуют значительно больше ресурсов по сравнению с методами разбивки, которые мы уже рассмотрели ранее.
Elasticsearch использует точку во времени (point in time, PIT) API совместно с
параметром search_after для реализации постраничной навигации на основе
курсора:
- Создание PIT:
// POST /my_index/_pit?keep_alive=1mЭтот запрос возвращает идентификатор PIT.
- Использование PIT в запросах:
// GET /_search
{
"size": 10,
"query": {
"match": {
"title": "elasticsearch"
}
},
"pit": {
"id": "42So...",
"keep_alive": "1m"
},
"sort": [{ "_score": "desc" }, { "_id": "asc" }]
}- Для последующих страниц добавляем параметр
search_after:
// GET /_search
{
"size": 10,
"query": {
"match": {
"title": "elasticsearch"
}
},
"pit": {
"id": "42So...",
"keep_alive": "1m"
},
"sort": [{ "_score": "desc" }, { "_id": "asc" }],
"search_after": [1.0, "1234"]
}- Закрытие PIT:
// DELETE /_pit
{
"id": "42So..."
}Использование PIT вместе с параметром search_after обеспечивает
последовательный просмотр данных в процессе постраничной навигации, даже если
индекс обновляется в фоновом режиме.
Как это работает
Теперь, когда у нас есть базовое представление о том, как можно использовать Elasticsearch в качестве клиента, мы погрузимся во внутреннее устройство Elasticsearch. Как реализована каждая из этих операций?
Elasticsearch можно рассматривать как высокоуровневый оркестрирующий фреймворк для библиотеки Apache Lucene, оптимизированной низкоуровневой поисковой системы. Elasticsearch занимается аспектами распределенных систем: координацией кластера, API-интерфейсом, агрегациями и возможностями реального времени, тогда как ядро функциональности поиска обрабатывается Lucene.
Давайте начнем с высокоуровневой архитектуры кластера Elasticsearch, а затем углубимся в некоторые наиболее интересные моменты индексации и поиска.
Архитектура кластера
Типы узлов
Elasticsearch представляет собой распределенную поисковую систему. Когда вы запускаете кластер Elasticsearch, фактически вы создаете несколько узлов. Узлы бывают пяти типов:
-
Главный узел (master node): отвечает за координацию кластера. Только главный узел может выполнять операции уровня кластера, такие как добавление или удаление узлов, создание или удаление индексов.
-
Узел хранения данных (data node): ответственен за хранение данных. Именно здесь физически хранятся ваши данные. В большом кластере таких узлов будет много.
-
Координирующий узел (coordinating node): координирует запросы на поиск по всему кластеру. Это тот узел, который получает поисковые запросы от клиентов и отправляет их соответствующим узлам.
-
Узел приема данных (ingest node): обрабатывает загрузку данных. Здесь происходят преобразования ваших данных перед индексацией.
-
Узел машинного обучения (machine learning node): выполняет задачи, связанные с машинным обучением.
Эти узлы работают вместе довольно простым образом. Вот диаграмма последовательности действий: узлы приема данных загружают данные в узлы хранения данных, которые затем запрашиваются через координирующие узлы.
Каждый узел Elasticsearch может относиться сразу к нескольким типам, а тип определяется конфигурацией узла. Например, узел может быть настроен одновременно как кандидат на роль главного узла и координирующий узел. В более сложных развертываниях каждый из этих типов может иметь выделенный хост (например, узел приема данных может быть ориентирован на производительность процессоров и содержать много ядер, в то время как узел хранения данных может иметь высокую пропускную способность ввода-вывода дисков или больший объем памяти).
Каждый из этих типов узлов может иметь специализацию. Например, узлы хранения данных могут быть горячими, теплыми, холодными или замороженными в зависимости от вероятности обращения к данным и возможности изменения данных.
При запуске кластера мы указываем список начальных узлов (это кандидаты на роль главного узла), которые выполняют алгоритм выбора лидера для назначения главного узла кластера. Активным главным узлом должен быть только один узел, остальные подходящие узлы находятся в резерве.
Хотя узлы координации и приема данных интересны, именно на узлах хранения данных происходит магия поиска, поэтому начнем с них.
Узлы хранения данных
Основная задача узлов хранения данных заключается в хранении документов и
обеспечении быстрого поиска по ним. Elasticsearch решает эту задачу путем
разделения исходных данных (_source) и индексов Lucene, используемых при
поиске. Можно представить это как отдельную базу данных документов.
Запросы выполняются в два этапа: сначала этап "запроса", когда соответствующие документы определяются с использованием оптимизированных структур данных индекса, а затем этап "получения", когда выбранные идентификаторы документов (при необходимости) извлекаются из узлов.
Идеальные запросы - это те, которые могут быть обработаны без обращения к исходным документам, что возможно благодаря включению необходимых полей непосредственно в индекс.
Узлы хранения данных содержат наши индексы (рассмотренные ранее), состоящие из
шардов и их реплик. Внутри шардов располагаются индексы Lucene, которые
состоят из сегментов Lucene.
Шарды позволяют Elasticsearch разделить данные (и сопутствующие индексы) между
несколькими узлами. Это позволяет Elasticsearch распределять как сами документы,
так и структуры индексов по множеству узлов в кластере, что существенно повышает
производительность и масштабируемость.
Запросы будут выполняться параллельно по всем релевантным шардам, а результаты объединятся и отсортируются на координирующем узле. Запросы обычно исполняются на координирующем узле, который распределяет запрос по нужным шардам.
Реплики представляют собой точные копии шардов. Elasticsearch позволяет
создавать одну или несколько реплик шардов индекса.
Реплики служат двум основным целям:
- Высокая доступность: если основной шард выходит из строя, реплика может обеспечить доступ к данным.
- Повышение производительности запросов: если наш шард способен обрабатывать X транзакций в секунду (TPS), то наличие Y реплик позволит обработать X × Y TPS.
Координирующий узел может использовать реплики для повышения производительности поиска, распределяя поисковые запросы по всем доступным копиям шардов (первичным и репликам), эффективно балансируя нагрузку на весь кластер.
Наконец, шарды Elasticsearch имеют соотношение 1:1 с индексами Lucene. Помните, что Lucene - это низкоуровневая, высокопроизводительная библиотека поиска, лежащая в основе Elasticsearch. Многие операции, выполняемые Elasticsearch над шардами (слияние, разделение, обновление, поиск), являются прокси-операциями над индексами Lucene.
Таким образом, теперь мы можем упростить понимание Elasticsearch как набора механизмов доступности и масштабирования поверх большого количества индексов Lucene.
Операции CRUD с сегментами Lucene
Индекс Lucene состоит из сегментов, которые являются базовой единицей нашего
поискового движка. Сегменты - это неизменяемые контейнеры индексированных
данных. Пусть это слово немного осядет в голове, прежде чем продолжить. Разве
нам не нужно уметь обновлять, добавлять и удалять документы из нашего индекса
Elasticsearch?
Индексирование в Lucene осуществляется путем пакетной записи и построения сегментов. Когда мы вставляем документ, он не записывается немедленно в индекс. Вместо этого он добавляется в сегмент. После накопления пакета документов создается новый сегмент и сбрасывается на диск.
Когда количество сегментов становится чрезмерным, мы можем объединить их: создаем новый сегмент из тех, которые хотим слить, и удаляем предыдущие сегменты.
Удаление документов - дело непростое: каждый сегмент фактически хранит набор идентификаторов удаленных документов. Во время запроса сегмента к удаленному документу он притворяется, будто этого документа вообще не существует, хотя данные остаются на месте! Во время слияния сегменты очищают удаленные документы.
Обновления документов тоже реализуются особым образом: мы не обновляем сам сегмент. Вместо этого мы помечаем старый документ как "мягко удаленный" (soft deleted) и добавляем новый документ с обновленными данными. Старый документ впоследствии очищается во время операций слияния сегментов. Такое решение делает удаления очень быстрыми, но накладывает временную потерю производительности до момента слияния и очистки сегментов. Желательно избегать частых обновлений!
Заметим, что обновления на самом деле имеют худшую производительность по сравнению с вставками, поскольку требуют обработки учета мягких удалений. Именно поэтому Elasticsearch плохо подходит для данных, которые часто обновляются.
Такая архитектура с неизменяемостью несет ряд преимуществ для Lucene:
-
Улучшенная производительность записи: новые документы могут быть быстро добавлены в новые сегменты без модификации существующих.
-
Эффективное кэширование: поскольку сегменты неизменны, их можно безопасно хранить в оперативной памяти или на твердотельном накопителе, не беспокоясь о проблемах с согласованностью.
-
Упрощенная работа с конкурентностью: операции чтения не нуждаются в учете изменений данных во время выполнения запроса, упрощая конкурентный доступ.
-
Простота восстановления: в случае сбоев восстановление проще, так как состояние неизменяемых сегментов известно и согласовано.
-
Оптимальное сжатие: неизменяемые данные могут быть эффективнее сжаты, экономя пространство на диске.
-
Быстрый поиск: благодаря своей неизменяемости эта структура допускает использование оптимальных алгоритмов и структур данных для поиска.
Тем не менее, такая конструкция создает и некоторые трудности, включая необходимость периодического объединения сегментов и временное увеличение требований к хранилищу до завершения очистки сегментов. Elasticsearch и Lucene используют продвинутые механизмы управления этими компромиссами, обеспечивая эффективную работу.
Подобные решения (как использовать преимущества неизменяемости) играют большую роль в интервью по проектированию инфраструктуры для систем, работающих с большими объемами данных! Надеемся, они вдохновляют вас на размышления о других системах, которые вам предстоит построить.
Возможности сегментов Lucene
Сегменты - это не просто контейнеры данных, они также содержат высокоэффективные
структуры данных, важные для операций поиска. Две самые значимые из них - это
инвертированный индекс (inverted index) и документные значения (doc values).
Инвертированный индекс
Если Lucene - сердце Elasticsearch, то инвертированный индекс - сердце самого
Lucene. По сути, если вы хотите ускорить поиск, у вас есть две опции:
-
Организовать данные таким образом, каким вы хотите получать к ним доступ. Если бы вы хотели искать конкретные элементы, плохим вариантом была бы таблица, которую нужно сканировать построчно (
𝑂(𝑛)), лучше было бы упорядоченное дерево (𝑂(log 𝑛)), а идеальный выбор - хеш-таблица (𝑂(1)). -
Создать копию данных и организовать ее аналогично пункту 1.
Представим, что у нас есть миллиард книг, среди которых лишь малая доля содержит слово "ленивый" в заголовке. Мы хотим создать код, который сможет находить все книги, содержащие слово "ленивый", как можно быстрее. Как мы могли бы это сделать?
Инвертированный индекс - это структура данных, используемая для хранения сопоставления содержимого, такого как слова или числа, с их расположениями в базе данных или документах. Именно благодаря этому инвертированному индексу поиск по ключевым словам в Elasticsearch выполняется настолько быстро. Индекс перечисляет каждое уникальное слово, которое встречается в любом документе, и определяет все документы, в которых оно присутствует. В данном случае речь идет о карте соответствия строк типа "ленивый" документам, содержащим данное слово (например, документы #12 и #53).
Теперь вместо того, чтобы сканировать каждый документ, чтобы найти документы, содержащие слово "ленивый", мы можем просто обратиться к инвертированному индексу и мгновенно найти нужные документы.
Мы воспользовались обоими пунктами выше, создав копии наших данных и грамотно
организовав их, и превратили операцию линейного поиска 𝑂(𝑛) в обращение за
константное время 𝑂(1).
Документные значения (Doc Values)
Что же произойдет, если мы захотим отсортировать полученные результаты по цене?
Тут-то и вступают в игру документные значения. Хотя документы содержат
множество других полей вроде автора, названия и т.п., нам нужны только цены всех
найденных результатов. Такая проблема часто возникает в реляционных базах
данных, ориентированных на строки: даже если мне нужен доступ всего к одному
столбцу, приходится считывать всю строку целиком и обращаться к нужному полю.
Тайна эффективности инструментов аналитики, таких как Spark или Amazon Redshift,
заключается в том, что они используют колоночный формат хранения данных в
непрерывных областях памяти. Когда вы обращаетесь к столбцу, вы на самом деле
читаете непрерывный участок памяти. Структура документных значений действует
аналогичным образом, представляя одно поле всех документов в сегменте в виде
компактного, непрерывно расположенного массива. Наш инвертированный индекс
дает нам отображение токенов на документы, а документные значения
предоставляют необходимые данные для финальной сортировки.
Координирующие узлы
Помните, мы говорили о том, что Elasticsearch - это распределенная система? Координирующие узлы отвечают за прием запросов от конечных пользователей и организацию их исполнения по всему кластеру. Они являются точкой входа для пользовательских запросов и ответственны за разбор запроса, определение ответственных узлов и возврат результатов пользователю.
Одним из важнейших этапов обработки на координирующих узлах является планирование запросов. Планировщики запросов - это алгоритмы, определяющие самый эффективный способ выполнения поискового запроса. После того, как запрос разобран координирующим узлом, планировщик оценит наилучший способ извлечения релевантных документов. Это подразумевает принятие решений о том, использовать ли инвертированный индекс, определение лучшего порядка выполнения частей запроса и управление процессом комбинирования результатов с нескольких узлов.
Оптимизация порядка выполнения
Давайте разберем это простыми словами. Допустим, мы ищем миллионы документов по строке "игра престолов". В вашем инвертированном индексе слово "игра" встречается миллион раз, а слово "престолов" - всего несколько сотен. Как нам поступить?
Мы можем:
- Создать хеш-множество всех документов, содержащих слово "престолов", и затем перебрать документы, содержащие слово "игра", ища пересечения, а потом провести поиск строки "игра престолов".
- Создать хеш-множество всех документов, содержащих слово "игра", и затем перебрать документы, содержащие слово "престолов", снова ища пересечения, а потом выполнить поиск строки "игра престолов".
- Загрузить все документы, содержащие слово "престолов", и выполнить поиск строки "игра престолов".
- Загрузить все документы, содержащие слово "игра", и выполнить поиск строки "игра престолов".
- ...
Есть множество вариантов! А разница в производительности между ними может различаться на порядки величин.
Собирая статистику по присутствующим полям, популярным ключевым словам и длине документов, планировщик запросов Elasticsearch выбирает оптимальный путь, минимизирующий время возврата результатов пользователю. Эта оптимизация критически важна для поддержания производительности по мере роста размера и сложности наборов данных.
Если вы сталкивались с собеседованиями по проектированию инфраструктурных систем, подобные вопросы должны быть вам знакомы. Планировщики запросов, добавив слой статистики и планирования, позволяют системе динамически реагировать на данные в индексе. Умение справляться с зависимостью от данных объясняет, почему базы данных в целом столь мощны.
Использование Elasticsearch на собеседовании
Elasticsearch идеально вписывается во многие вопросы проектирования систем. Любая ситуация, связанная со сложными поисковыми операциями, вероятно, станет хорошим кандидатом для демонстрации возможностей Elasticsearch. Чаще всего на собеседованиях Elasticsearch упоминается в контексте интеграции через захват изменений данных (Change Data Capture, CDC) с основными источниками данных, такими как Postgres или DynamoDB.
Что учитывать при использовании Elasticsearch
-
Обычно нежелательно использовать Elasticsearch в роли основной базы данных. Прежде всего, это мощный поисковый движок, и хотя он обладает широкими возможностями, он не предназначен заменять традиционные базы данных. Ранние версии Elasticsearch имели проблемы с согласованностью и надежностью, схожие с теми, которыми страдали другие инструменты, такие как CouchDB. Важно помнить: если надежное сохранение данных обязательно, размещайте их в другом месте.
-
Elasticsearch разработан для обработки интенсивных нагрузок на чтение. Если ваша система характеризуется преимущественно операциями записи, возможно, стоит рассмотреть альтернативные варианты или реализовать буферизацию записей. Добавление полей вроде счетчиков лайков или просмотров может привести к проблемам, потому что Elasticsearch испытывает трудности с поддержкой таких динамических данных.
-
Учтите модель конечной согласованности Elasticsearch. Ваши результаты могут быть неактуальными, порой весьма значительно. Если ваше приложение не может позволить себе подобное поведения, подумайте о других вариантах.
-
Elasticsearch не является реляционной базой данных. Вам потребуется денормализовать данные насколько это возможно, чтобы повысить эффективность поисковых запросов. Возможно, придется предусмотреть дополнительную логику трансформации на этапе записи, чтобы достичь нужного формата.
-
Не все поисковые задачи требуют использования Elasticsearch! Если ваш объем данных невелик (меньше 100 тысяч документов) или редко меняется, существуют гораздо более простые и быстрые решения. Проверьте, достаточно ли простого запроса к вашей основной базе данных, и обращайтесь к Elasticsearch только в случае реальной необходимости.
-
Будьте осторожны с синхронизацией Elasticsearch с исходными данными. Проблемы с синхронизацией могут привести к рассогласованности и стать источником ошибок.
Помня, что Elasticsearch - это мощный инструмент, но не универсальное средство, будьте готовы обосновать свое решение выбрать именно его, обсудив как сильные стороны, так и ограничения.
Уроки от Elasticsearch
Даже если мы не используем Elasticsearch, мы можем почерпнуть немало полезных уроков из его конструкции при разработке производительной инфраструктуры:
-
Неизменяемость может быть мощным инструментом, позволяющим улучшать возможности кэширования, компрессии и оптимизации данных. Нам также не придется беспокоиться о проблемах синхронизации и целостности, которые намного сложнее решать при работе с изменяемыми данными.
-
Разделение выполнения запросов и хранения данных позволяет оптимизировать каждую сторону независимо друг от друга. Узлы хранения данных и координирующие узлы Elasticsearch прекрасно дополняют друг друга, сосредоточившись на ответственности каждого типа узла.
-
Стратегии индексирования сильно влияют на производительность поиска. Инвертированные индексы Elasticsearch обеспечивают быстрый полнотекстовый поиск, а значения документов делают возможным эффективную сортировку и агрегирование данных. Проектируя системы, требующие быстрой обработки данных, учитывайте, как можно структурировать данные, чтобы поддерживать наиболее частые паттерны доступа.
-
Распределенные системы предоставляют масштабируемость и устойчивость к отказам, но также добавляют сложность. Кластерная архитектура Elasticsearch позволяет ей обрабатывать большие объемы данных и высокие нагрузки, но требует тщательного рассмотрения вопросов согласованности данных и сетевых сбоев. При проектировании распределенных систем всегда рассматривайте компромиссы между согласованностью, доступностью и устойчивостью к сетевым сбоям (Теорема CAP).
-
Важность специализированных структур данных нельзя переоценить. Применение специализированных структур данных, таких как списки с пропусками (skip lists) и конечные автоматы состояний для инвертированного индекса в Elasticsearch, показывает, как специализированные структуры могут существенно повысить производительность для конкретных сценариев использования.