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. Когда документ пишется:
- Попадает в in-memory buffer + WAL (translog).
- По умолчанию каждые 1 секунду буфер переписывается в новый segment (это операция refresh).
- Сегмент видим для поиска, но ещё не на диске.
- Периодически (1-30 минут) сегменты flush на диск, translog обрезается.
- Маленькие сегменты 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 — официальная документация.