Когда интернет-магазин вырастает до миллионов товаров, запрос «найди всё, где есть слово "шоколад"» в обычной базе данных начинает занимать секунды: 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, он не просто разбивает строку по пробелам. Текст проходит через анализатор — цепочку из трёх шагов:
- Character filters — предварительная обработка: убрать HTML-теги, заменить символы.
- Tokenizer — разбить на слова (токены).
- 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 — управление индексами, снапшоты, мониторинг.