← назад к разделу

Когда интернет-магазин вырастает до миллионов товаров, запрос «найди всё, где есть слово "шоколад"» в обычной базе данных начинает занимать секунды: SQL проходит каждую строку и ищет вхождение. Elasticsearch решает эту задачу за миллисекунды — за счёт особой структуры данных, которая называется инвертированным индексом.

Как работает инвертированный индекс

В обычной базе данных индекс выглядит так: «значение → строка». Например, B-tree на колонке id говорит: «id=42 → вот эта строка таблицы». Это отлично работает для поиска по точным значениям.

Инвертированный индекс устроен наоборот: «слово → список документов». Elasticsearch заранее разбирает каждый документ на отдельные слова (это называется токенизацией) и запоминает, в каких документах каждое слово встретилось.

Документы:
  1: { name: "Конфеты шоколадные" }
  2: { name: "Шоколад в плитках" }
  3: { name: "Печенье с шоколадом" }

Инвертированный индекс (после разбивки на слова):
  конфеты    → [1]
  шоколадный → [1, 2, 3]   ← стемминг объединяет формы слова
  плитка     → [2]
  печенье    → [3]

Запрос «найди шоколад» — это один поиск по словарю: сразу находим [1, 2, 3]. Никакого перебора миллионов строк.

Цена: индекс строится при каждой записи. Elasticsearch пишет медленнее, чем PostgreSQL при простых INSERT, зато читает текст несравнимо быстрее.

Elasticsearch построен поверх Apache Lucene — той же библиотеки, что лежит в основе Solr и полнотекстового поиска PostgreSQL (tsvector). Elasticsearch добавляет к Lucene два слоя: кластеризацию (несколько машин, шарды, репликация) и REST API для работы с данными через JSON.

Документ и индекс

  • Документ — JSON-объект с уникальным _id. Аналог строки в SQL.
  • Индекс — коллекция документов одной структуры. Аналог таблицы.
# Сохранить документ с конкретным id
PUT /products/_doc/3
{
  "name": "Конфеты",
  "category_id": 1,
  "price": 150
}

# Сохранить документ, id генерирует ES
POST /products/_doc
{ "name": "Печенье", "price": 80 }

Почему документ виден не сразу

Это одна из главных особенностей Elasticsearch, которая удивляет разработчиков в начале.

Внутри каждого шарда (о шардах — ниже) данные хранятся в неизменяемых сегментах — файлах на диске. Записать документ «прямо в сегмент» нельзя: сегмент закрыт. Вместо этого новые документы сначала накапливаются в памяти.

Каждую секунду Elasticsearch выполняет refresh: сбрасывает буфер в новый сегмент, и этот сегмент становится видим для поиска. Именно поэтому ES называют near-real-time (почти реальное время): документ, записанный прямо сейчас, появится в результатах поиска через ~1 секунду.

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

При массовой загрузке данных refresh лучше временно отключить:

# Отключить автоматический refresh
PUT /products/_settings
{ "index": { "refresh_interval": "-1" } }

# ... загрузить данные ...

# Вернуть и обновить вручную
PUT /products/_settings
{ "index": { "refresh_interval": "1s" } }
POST /products/_refresh

Это даёт 5–10-кратное ускорение при начальной загрузке миллионов документов.

Ещё одно следствие: обновление документа — это не правка на месте. ES помечает старую версию как удалённую и создаёт новый сегмент с новой версией. Старые сегменты периодически сливаются в большие (это называется merge), и только тогда удалённые документы физически исчезают.

Кластер: как данные распределяются по машинам

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

В кластере есть несколько типов узлов:

  • Master-узлы — следят за состоянием кластера: какие индексы существуют, где лежат шарды, какие узлы живы. Для надёжности нужно минимум три master-узла (чтобы при потере одного оставшиеся два могли выбрать нового лидера).
  • Data-узлы — хранят данные и выполняют поиск. Именно их надо масштабировать, когда данных становится больше.
  • Coordinating-узлы — принимают запросы от приложения, рассылают их по data-узлам, собирают и возвращают результат. По умолчанию любой узел может выполнять эту роль.

Маленький кластер обычно совмещает роли: три машины работают и как master, и как data. При росте нагрузки master-роль выносят на отдельные машины.

Шарды и реплики

Индекс разрезан на primary-шарды — части, которые распределены по разным data-узлам. Число primary-шардов задаётся при создании индекса и потом не меняется.

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

PUT /products
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1
  }
}

Зачем это нужно:

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

При записи документа Elasticsearch вычисляет, в какой шард он попадёт: шард = hash(id) % число_primary_шардов. Запись идёт в primary, который синхронно передаёт её репликам.

Хороший размер одного шарда — от 10 до 50 ГБ. Слишком маленькие шарды создают лишние накладные расходы, слишком большие — замедляют поиск и обслуживание.

Маппинг: схема индекса

Маппинг описывает структуру документа: какой тип у каждого поля и как его индексировать.

Динамический маппинг работает из коробки: Elasticsearch сам определяет тип при первой записи. Строка "price": "150" становится полем типа text, число "price": 150 — типом long. Удобно для экспериментов, но опасно в продакшне: если первый документ задал неправильный тип, все последующие будут приводиться к нему, и запросы начнут давать неожиданные результаты.

В продакшне всегда задают явный маппинг:

PUT /products
{
  "mappings": {
    "properties": {
      "name":        { "type": "text", "analyzer": "russian" },
      "category_id": { "type": "long" },
      "price":       { "type": "scaled_float", "scaling_factor": 100 },
      "tags":        { "type": "keyword" },
      "created_at":  { "type": "date" }
    }
  }
}

Главные типы полей:

  • text — строка для полнотекстового поиска. Разбивается на слова, хранится в инвертированном индексе. Для описаний и имён.
  • keyword — строка как есть, без разбивки. Для категорий, статусов, тегов — всего, где нужен точный поиск или сортировка.
  • text + keyword вместе — частый приём: одно поле доступно и для полнотекстового поиска, и для точного совпадения.
  • long, integer, float, scaled_float — числа.
  • date — дата в формате ISO 8601.
  • boolean — true/false.
  • geo_point — координаты для географических запросов.
  • dense_vector — вектор для семантического поиска (ES 8+).

Важное ограничение: тип поля нельзя изменить после создания индекса. Если ошиблись — нужно создать новый индекс с правильным маппингом и перенести в него данные через API _reindex. Поэтому маппинг стоит продумать заранее.

Анализаторы: как текст превращается в слова

Когда Elasticsearch индексирует поле типа text, он не просто разбивает строку по пробелам. Текст проходит через анализатор — цепочку из трёх шагов:

  1. Character filters — предварительная обработка: убрать HTML-теги, заменить символы.
  2. Tokenizer — разбить на слова (токены).
  3. Token filters — обработать каждое слово: привести к нижнему регистру, убрать стоп-слова, привести к корневой форме (стемминг).
Исходный текст: "Конфеты «Шоколадные» 150г"
После tokenizer: ["Конфеты", "Шоколадные", "150г"]
После lowercase:  ["конфеты", "шоколадные", "150г"]
После стемминга:  ["конфет", "шоколадн", "150г"]

Те же шаги применяются к поисковому запросу. Пользователь вводит «конфеты» — ES применяет стемминг, получает «конфет» и ищет именно это в инвертированном индексе. Поэтому запрос «конфеты» найдёт и «конфета», и «конфет» — все формы одного слова.

Встроенные анализаторы:

  • standard — разбивка по словам + нижний регистр. Хорошо для латиницы, но без стемминга.
  • russian — стемминг + русские стоп-слова («в», «на», «с» и другие). Использовать для русскоязычных полей.
  • english — то же для английского.
  • keyword — не разбивает: всё поле = один токен. Применяется автоматически к полям типа keyword.

Для автодополнения (когда пользователь набирает «конф», а система уже предлагает «конфеты») используют edge_ngram: при индексации слово разбивается на префиксы — к, ко, кон, конф, конфе, конфет, конфеты. Поиск по «конф» мгновенно находит все слова с таким началом.

Для специфических задач составляют пользовательский анализатор:

PUT /products
{
  "settings": {
    "analysis": {
      "filter": {
        "russian_stop": { "type": "stop", "stopwords": "_russian_" },
        "russian_stemmer": { "type": "stemmer", "language": "russian" }
      },
      "analyzer": {
        "ru_text": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "russian_stop", "russian_stemmer"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": { "type": "text", "analyzer": "ru_text" }
    }
  }
}

Коротко

  • Elasticsearch хранит данные в инвертированном индексе: слово → список документов. Это делает полнотекстовый поиск быстрым даже по миллионам записей.
  • ES работает в режиме near-real-time: новый документ виден в поиске через ~1 секунду после записи, не мгновенно.
  • Обновление документа — это создание новой версии, а не правка на месте. Старая версия удаляется при следующем слиянии сегментов.
  • Индекс делится на primary-шарды (фиксируются при создании) и реплики (можно менять). Шарды позволяют хранить больше данных, реплики — ускоряют чтение и дают отказоустойчивость.
  • Маппинг — схема индекса. В продакшне задаётся явно до первой записи; тип поля нельзя изменить без пересоздания индекса.
  • Анализатор разбивает текст на токены при индексации и при поиске — поэтому запросы находят разные формы одного слова.
  • Для русского текста используйте анализатор russian или пользовательский с Russian Stemmer.

Что почитать дальше

  • Query DSL и relevance scoring — как формулировать запросы к индексу.
  • Клиенты Elasticsearch — подключение из Java и других языков.
  • Operations — управление индексами, снапшоты, мониторинг.