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

Elasticsearch построен поверх Apache Lucene. Это значит: внутри — те же inverted index'ы, что в Solr, в pgroonga, в tsvector PostgreSQL. ES добавляет к Lucene две вещи: распределённость (кластер с шардингом и репликацией) и REST API + JSON DSL. Эта статья — о том, как это устроено внутри и какие следствия из устройства вытекают для нашего кода.

Примеры — на каталоге товаров (та же product, что и в PostgreSQL / MongoDB).

Inverted index — основа быстрого поиска

В реляционной БД B-tree-индекс отвечает на вопрос «дай мне строку с id=N» — он отображает значения колонки в указатели на строки. В Elasticsearch основной индекс инвертированный: отображение термина в список документов, где этот термин встречается.

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

Inverted index (после tokenization + lowercasing):
  конфеты   → [1]
  шоколадные → [1]
  шоколад   → [2, 3]
  в         → [2]   ← stopword, обычно удаляется
  плитках   → [2]
  печенье   → [3]
  с         → [3]   ← stopword

Запрос name CONTAINS "шоколад" в SQL → full table scan (миллионы строк). В Elasticsearch → один lookup в hashmap'е → [2, 3] за миллисекунды.

Цена: индекс строится при записи (медленнее INSERT, чем в PG), занимает место (часто столько же, сколько и данные).

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

  • Документ — JSON-объект с уникальным _id. Аналог строки в SQL / документа в Mongo.
  • Индекс — коллекция документов с общей структурой. Аналог таблицы. Имя из строчных букв, цифр, -, _.
  • Тип (deprecated в 7.x, удалён в 8.x) — раньше внутри индекса были типы (как таблицы внутри схемы). Сейчас один индекс = один тип.
PUT /products/_doc/3
{
  "name": "Конфеты",
  "category_id": 1,
  "price": 150
}

_id можно указывать (PUT /products/_doc/3) или дать ES сгенерировать (POST /products/_doc).

Сегменты (segments) и near-real-time

Внутри shard документы хранятся в immutable сегментах — файлах Lucene. Когда документ пишется:

  1. Попадает в in-memory buffer + WAL (translog).
  2. По умолчанию каждые 1 секунду буфер переписывается в новый segment (это операция refresh).
  3. Сегмент видим для поиска, но ещё не на диске.
  4. Периодически (1-30 минут) сегменты flush на диск, translog обрезается.
  5. Маленькие сегменты merge в большие в фоне.

Главные следствия:

  • Near-real-time, не real-time. Документ, записанный сейчас, виден в поиске через ~1 секунду. Для UCP-сервисов это обычно ок (кто пишет — читает через свой кэш / БД, а ES для поиска от внешнего пользователя).
  • Запись дороже чтения. Каждый INSERT = создание сегментов + refresh + потом merge. Бэт-апдейты дешевле, чем по одному.
  • Updates — это re-index. Документ нельзя обновить in-place. ES помечает старый как deleted, создаёт новый сегмент с новым.

Управление:

# отключить refresh для bulk-загрузки
PUT /products/_settings { "index": { "refresh_interval": "-1" } }

# bulk-индексация...

# вернуть и forсе-refresh
PUT /products/_settings { "index": { "refresh_interval": "1s" } }
POST /products/_refresh

Для миллионов документов это даёт 5-10× speedup.

Cluster: master, data, coordinating

Elasticsearch-кластер — N узлов с разными ролями:

  • Master-eligible — выбирают активного master'а, который держит cluster state (метаданные: какие индексы существуют, на каких узлах шарды). Минимум 3 для кворума.
  • Data nodes — хранят шарды, выполняют поиск и индексацию. Самые нагруженные.
  • Coordinating — принимают клиентские запросы, маршрутизируют их по data nodes, агрегируют ответы. По умолчанию любой узел может быть coordinating.
  • Ingest — пред-обработка документов перед индексацией (pipelines).
  • Machine learning / Transform / Frozen — специализированные роли в Elastic Stack.

Минимальная production-конфигурация: 3 master-eligible + N data nodes. На малых кластерах master-роль совмещают с data, но при росте >10 data nodes — выделяют отдельные master'ы (3 машины с маленькими ресурсами).

Primary и replica shards

Индекс разрезан на N primary-шардов (фиксируется при создании, изменить нельзя без re-index). Каждый primary имеет M replica-шардов (можно менять в рантайме).

PUT /products
{
  "settings": {
    "number_of_shards": 3,       # primary, fixed
    "number_of_replicas": 2      # replicas, изменяемое
  }
}
  • Primary — туда идут все INSERT/UPDATE/DELETE.
  • Replica — копия primary, читает то же. Используется для поиска (нагрузка балансируется) и для отказоустойчивости (если primary упал, replica становится новым primary).

При записи документа ES вычисляет shard = hash(_routing) % number_of_primary_shards. _routing по умолчанию _id. Запись идёт в primary, потом синхронно реплицируется на все replicas с подтверждением.

Это значит: поиск идёт на все primary shards (один или его replica), результаты агрегируются на coordinating node. На больших кластерах поиск по 200 шардам = 200 параллельных подзапросов.

Сколько шардов брать

Главные правила:

  • Размер одного шарда в идеале 10-50 GB. Меньше — overhead на metadata, больше — медленные queries и долгие merges.
  • Число шардов на узел до 600 (heap-зависимо). Каждый шард = файлы + метаданные в master state.
  • Не делать тысячи маленьких индексов — лучше один индекс с partitioning через _routing или ILM по времени.

Для product с миллионом товаров и средним документом 2 KB → ~2 GB данных → 1-3 primary shard'а достаточно.

Mapping

Mapping — это схема индекса: типы полей, как они индексируются, какие анализаторы применяются.

Dynamic mapping

По умолчанию ES угадывает тип при первой записи. Поле в JSON "price": 150 становится long, "name": "Конфеты"text + автоматический .keyword суб-поле для exact match.

Удобно для прототипов, опасно в проде: первая запись с "price": "150 руб" → поле станет text, и дальше сравнения range: { price: { gte: 100 } } будут ломаться.

Explicit mapping

В проде — всегда явное:

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

Ключевые типы:

  • keyword — строка хранится как есть. Используется для exact match, аggregations, sorting. Категории, статусы, теги, IDs.
  • text — строка проходит анализатор, индексируется по словам. Используется для full-text search. Имена, описания.
  • text + .keyword — оба варианта (через multifields). Один и тот же контент доступен для full-text и для exact-match.
  • long, integer, short, byte, double, float, scaled_float — числа.
  • date — ISO 8601 строки или epoch_millis.
  • boolean — true/false.
  • object / nested — вложенные структуры. nested сохраняет independence объектов в массиве (важно для arrays of objects с независимыми query'ями).
  • geo_point, geo_shape — гео.
  • dense_vector — для embeddings, semantic search (ES 8+).

Что нельзя менять без re-index

После создания индекса нельзя изменить тип существующего поля. Можно только:

  • Добавить новые поля.
  • Добавить multifields к существующему полю.
  • Изменить настройки analyzer'а (с предупреждением).

Для серьёзного изменения mapping — re-index в новый индекс + alias-переключение. ES даёт _reindex API для этого.

Анализаторы

Когда документ с полем text индексируется, ES прогоняет его через analyzer: цепочку из character filters → tokenizer → token filters. На выходе — массив термов, которые попадают в inverted index.

Текст: "Конфеты «Шоколадные» 150г"
       │
       ▼ character filter (HTML strip, custom mappings)
"Конфеты Шоколадные 150г"
       │
       ▼ tokenizer (standard, ngram, whitespace)
["Конфеты", "Шоколадные", "150г"]
       │
       ▼ token filters (lowercase, stop, stemming, synonyms)
["конфет", "шоколадн", "150г"]   ← после russian stemmer

Тот же анализатор применяется к запросу при поиске. Это важно: запрос "конфеты" → стемминг → "конфет", и инвертированный индекс находит документы со стеммированным "конфет".

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

  • standard (default) — tokenize по словам + lowercase. Подходит для general-purpose, но без stemming.
  • russian — stemming + русские stopwords. Использовать для полей с русским текстом.
  • english — то же для английского.
  • whitespace — только split по пробелам, без lowercase.
  • keyword — не разбивает, всё поле = один term.
  • ngram / edge_ngram — генерирует N-граммы. Для autocomplete: "конфе" индексируется как "к", "ко", "кон", "конф", "конфе", и пользовательский запрос "конф" мгновенно матчит.

Custom analyzer

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

В UCP-проектах с русским контентом обычно нужен custom analyzer: russian stemming + synonyms (например, товар, продукт, изделие) + правильные stopwords.

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

  • Query DSL и relevance scoring — как искать в этом index.
  • Spring Data Elasticsearch — клиент из Java/Spring.
  • Operations — ILM, snapshots, sizing, мониторинг.
  • Elasticsearch reference — официальная документация.