Elasticsearch для тех, кто пришёл из SQL и Redis: документы, шарды и бакеты

Слева три узла кластера с шардами и JSON-заказами, справа бакеты по странам: EE - 2 заказа на 300, DE - 1 на 80, LV - 1 на 120

Elasticsearch - это распределённая система поиска и аналитики по документам. Внутри у неё библиотека Apache Lucene, а снаружи HTTP API с JSON. Если PostgreSQL отвечает на вопрос «дай строки по условию», а Redis - «дай значение по ключу», то Elasticsearch отвечает на вопрос «найди документы по словам, отфильтруй и сразу посчитай, как они распределяются по группам».

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

Основная единица данных в Elasticsearch - документ. Это обычный JSON. Вот заказ интернет-магазина:

{
  "order_id": 1001,
  "country": "EE",
  "status": "paid",
  "amount": 100.0,
  "description": "Беговые кроссовки Nike, размер 42",
  "created_at": "2026-10-20T14:30:00Z"
}

Документы складываются в индекс, например orders. Если сравнивать с SQL, индекс ближе всего к таблице: это коллекция однотипных записей, у которой есть схема.

Ловушка в названии. В PostgreSQL «индекс» - это вспомогательная структура (например, B-tree), которая ускоряет поиск по таблице. В Elasticsearch «индекс» - это сами данные целиком, то есть скорее таблица. Когда коллега говорит «создай индекс orders», он имеет в виду «создай хранилище для заказов».

Схема индекса называется mapping. Она говорит, как хранить и индексировать каждое поле:

ПолеТипЗачем такой тип
order_idlongЦелое число: точные сравнения и диапазоны
countrykeywordКод страны целиком: фильтр country = EE, группировка по странам
statuskeywordЗначение из короткого списка: paid, cancelled
amountdoubleЧисло: суммы, средние, диапазоны цен
descriptiontextСвободный текст, по которому ищут словами
created_atdateДата: фильтры «за неделю», группировка по дням

Самое важное различие для новичка - text против keyword:

  • text при записи разбивается на отдельные слова. Строку «Беговые кроссовки Nike» можно найти по слову «nike». Зато группировать по такому полю нельзя: непонятно, по какому из слов.
  • keyword хранится целиком, как одно значение. Найти "EE" можно только точным совпадением, зато по нему удобно фильтровать, сортировать и группировать.

Тип поля после создания поменять обычно нельзя: для этого создают новый индекс с новым mapping и переливают данные (reindex).

Почему поиск быстрый: инвертированный индекс

Обычная таблица устроена «документ → его слова»: чтобы найти все заказы со словом «nike», пришлось бы прочитать каждое описание. Lucene при записи строит обратную структуру: слово → список документов, где оно встречается. Это и называется инвертированным индексом. Поиск по слову превращается в чтение одного готового списка, а поиск по двум словам - в пересечение двух списков.

Инвертированный индекс
Нажмите на слова. Выбранные слова объединяются через AND: документ должен содержать их все.

Документы

Термы в индексе

Тот же индекс в виде таблицы:

ТермДокументы
кроссовки1, 2
nike1, 3
adidas2, 4
куртка3, 4
беговые1
беговая3

Превращение текста в термы называется анализом: строка разбивается на слова, приводится к нижнему регистру, иногда слова сводятся к основе. Обратите внимание на «беговые» и «беговая»: без настройки это два разных терма, и поиск по одному не найдёт другой. Для русского языка анализатор нужно выбрать и настроить самостоятельно, автоматической магии нет.

Кластер, шарды и реплики

ПонятиеЧто это
КластерВсе серверы Elasticsearch, которые работают вместе как одна система
Узел (node)Один процесс Elasticsearch, обычно один сервер
Primary shardЧасть индекса. Каждый документ живёт ровно в одном primary-шарде
ReplicaКопия primary-шарда на другом узле: для отказоустойчивости и для чтения

Индекс orders можно разрезать, например, на три шарда и дать каждому по одной реплике. Elasticsearch раскладывает их так, чтобы копия никогда не лежала на том же узле, что и оригинал:

Кластер из трёх узлов. Узел 1 хранит P0 и R2, узел 2 хранит P1 и R0, узел 3 хранит P2 и R1 индекс orders: 3 primary-шарда, по 1 реплике Узел 1 P0 primary R2 копия P2 Узел 2 P1 primary R0 копия P0 Узел 3 P2 primary R1 копия P1
Если узел 1 упадёт, P0 не потеряется: его копия R0 есть на узле 2 и станет новым primary.

Шард - это физическое размещение части данных: «вот эти документы лежат вот на этом узле». Технически каждый шард - отдельный индекс Lucene со своим инвертированным индексом.

Как проходит запись

  1. Любой узел принимает запрос на запись документа.
  2. Он определяет нужный шард по _id документа (в API это id документа, его можно задать равным order_id): хэш от _id по модулю числа primary-шардов. Поэтому число primary-шардов нельзя просто так поменять у существующего индекса.
  3. Запрос уходит на узел с primary-шардом. Primary записывает документ и рассылает операцию своим репликам.
  4. Клиент получает ответ, когда подтвердили запись нужное число копий шарда - сколько задано в wait_for_active_shards (по умолчанию все активные: primary и реплики).

Как проходит поиск

  1. Узел, получивший запрос, становится координатором.
  2. Он отправляет запрос в одну копию каждого шарда: либо primary, либо реплику.
  3. Каждый шард выполняет поиск локально по своим данным.
  4. Координатор сливает частичные результаты в один ответ.

Реплики не удваивают результат: из каждого шарда в запросе участвует только одна копия.

Почти реальное время. Только что записанный документ попадает в поиск не мгновенно, а после операции refresh, которая по умолчанию происходит примерно раз в секунду. GET orders/_doc/1001 по известному _id читает документ напрямую и не ждёт refresh, в отличие от _search. Если тест пишет документ и тут же ищет его через _search, он может его не найти.

Не путайте master-узел и primary-шард. Master управляет кластером: помнит, какие индексы есть и где лежат шарды, решает, куда переносить шарды при падении узла. Принимать все записи он не обязан, записи идут в primary-шарды, разбросанные по разным узлам.

Бакеты: GROUP BY в мире Elasticsearch

Теперь главное. Возьмём шесть заказов:

order_idcountrystatusamount
1EEpaid100
2EEpaid200
3EEcancelled40
4DEpaid80
5DEcancelled50
6LVpaid120

Задача: сколько оплаченных заказов и на какую сумму в каждой стране? В SQL это GROUP BY. В Elasticsearch то же самое делает агрегация, а каждая получившаяся группа называется бакетом (bucket, «корзина»).

SQL
SELECT country,
       COUNT(*)    AS doc_count,
       SUM(amount) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY country;
Elasticsearch
GET orders/_search
{
  "size": 0,  // сами документы не нужны
  "query": {
    "term": { "status": "paid" }
  },
  "aggs": {
    "by_country": {
      "terms": { "field": "country" },
      "aggs": {
        "revenue": { "sum": { "field": "amount" } }
      }
    }
  }
}

Читается так: query - это WHERE, агрегация terms по полю country - это GROUP BY country, вложенная sum - это SUM(amount) внутри каждой группы. "size": 0 говорит «список найденных документов не возвращай, нужны только группы». Ответ (упрощённо):

"aggregations": {
  "by_country": {
    "buckets": [
      { "key": "EE", "doc_count": 2, "revenue": { "value": 300.0 } },
      { "key": "DE", "doc_count": 1, "revenue": { "value": 80.0 } },
      { "key": "LV", "doc_count": 1, "revenue": { "value": 120.0 } }
    ]
  }
}

Каждый бакет - это key (значение группы), doc_count (сколько документов попало в группу) и результаты вложенных агрегаций. По умолчанию бакеты отсортированы по doc_count от большего к меньшему.

Живой пример
Фильтр: Группировка:
Фильтр работает как query, группировка как агрегация terms. Зачёркнутые строки не прошли фильтр и в бакеты не попадают.

Документы

order_idcountrystatusamount

Бакеты

Ответ Elasticsearch


  

Теперь видно, почему шард и бакет - совсем разные вещи, хотя оба звучат как «части данных»:

ШардБакет
Что этоРазмещение части индекса на узлеГруппа документов в ответе на запрос
Когда существуетПостоянно, пока существует индексРождается при выполнении запроса и исчезает после ответа
Откуда документыТолько документы этого шарда на одном узлеСобирается со всех шардов: каждый шард считает свои частичные бакеты, координатор их складывает
Кто решаетНастройки индекса и кластерВаш запрос
Ключевая мысль. Шард отвечает на вопрос «где лежат данные». Бакет отвечает на вопрос «на какие группы разбить результат». Заказы из Эстонии могут лежать во всех трёх шардах сразу, но в ответе они окажутся в одном бакете EE.

Основные виды бакетных агрегаций:

  • terms - группа на каждое значение поля: по стране, по статусу, по бренду.
  • date_histogram - группа на каждый интервал времени: заказы по дням или по месяцам.
  • range - группы по диапазонам чисел: заказы до 50, от 50 до 150, больше 150.
  • filters - группа на каждое заданное условие: «крупные заказы», «заказы из Балтии».

Две практические заметки:

  • Один документ может попасть в несколько бакетов: например, в filters с пересекающимися условиями или если поле содержит массив тегов. Поэтому сумма всех doc_count не всегда равна числу документов.
  • terms возвращает только top-N групп (параметр size, по умолчанию 10). Чтобы перебрать все группы постранично, есть агрегация composite.

Чем отличается от PostgreSQL

В PostgreSQL заказ, пользователь и товары лежат в разных таблицах и соединяются через JOIN. В Elasticsearch JOIN почти нет. Типы nested и parent-child существуют, но их используют редко - они тяжелее обычного документа; основной путь - денормализация, поэтому данные обычно денормализуют: кладут в документ всё, что нужно для поиска и отображения.

{
  "order_id": 1001,
  "user_id": 42,
  "user_name": "Анна Тамм",          // копия из таблицы users
  "product_names": ["Nike Pegasus 41", "Носки беговые"],  // копия из products
  "amount": 100.0
}

Цена такого подхода - дублирование. Если пользователь сменит имя, придётся обновить все его заказы в индексе, а не одну строку в users.

Второе отличие - нет транзакций на несколько документов. Запись одного документа атомарна, но пачка через _bulk может выполниться частично: 998 документов записались, 2 нет. Ответ _bulk нужно проверять поэлементно, а не только по общему статусу.

Честно стоит сказать и обратное: в PostgreSQL есть полнотекстовый поиск (tsvector), JSONB и GIN-индексы. Для поиска по каталогу на несколько тысяч товаров их часто достаточно. Elasticsearch оправдан, когда поиск становится отдельной тяжёлой нагрузкой: нужна настраиваемая релевантность, анализаторы под разные языки, фасеты и агрегации по миллионам документов, масштабирование на несколько серверов.

Чем отличается от Redis

Redis работает с известным ключом и структурами данных вокруг него:

GET  session:abc123                 # сессия по известному ключу
INCR views:product:42               # атомарный счётчик
ZADD leaderboard 1500 "user:7"     # сортированное множество
ZREVRANGE leaderboard 0 9          # топ-10

Elasticsearch работает с запросом по набору документов: «все оплаченные заказы из Эстонии со словом nike за неделю». Какие _id подойдут, заранее неизвестно, их находит поиск.

Redis держит данные в памяти, но умеет сохранять их на диск (RDB-снимки и AOF-журнал), поэтому при рестарте он не обязательно теряет всё: это зависит от настроек. Elasticsearch хранит поисковые структуры на диске и активно использует оперативную память и файловый кэш операционной системы, чтобы читать их быстро.

В Redis Cluster ключи распределяются по 16384 hash slots. Это про размещение данных по серверам, то есть аналог шардов, а не бакетов. Группировок результата в обычном Redis нет.

Для полноты: у Redis есть модуль поиска (Redis Search) с полнотекстовым поиском и агрегациями. Но это отдельная возможность со своими ограничениями, а не основной сценарий Redis.

Что выбрать и как их сочетать

PostgreSQLElasticsearchRedis
Основная единицаСтрока таблицыJSON-документКлюч и значение
Типичная задачаСтроки по условиюПоиск по словам и фильтрамЗначение по ключу
ГруппировкиGROUP BYАгрегации и бакетыНет (кроме модулей)
СвязиJOIN, внешние ключиДенормализацияНет
ТранзакцииПолноценные, на много строкТолько на один документMULTI/EXEC, скрипты
НазначениеИсточник истиныПоиск и аналитикаКэш, сессии, счётчики, очереди

В реальном магазине их часто используют вместе:

Приложение пишет и читает заказы в PostgreSQL, ищет в Elasticsearch, хранит сессии и кэш в Redis. Синхронизация переносит данные из PostgreSQL в Elasticsearch Приложение PostgreSQL заказы и остатки Elasticsearch поиск и фильтры Redis сессии и кэш синхронизация
Цена такой схемы - синхронизация: нужен процесс, который переносит изменения из PostgreSQL в Elasticsearch, и между записью заказа и его появлением в поиске всегда есть задержка.
  • Данные, которые нельзя потерять и которые нужно менять транзакционно (заказы, деньги, остатки), храните в PostgreSQL. Это источник истины.
  • Если нужен поиск по словам с релевантностью, фасетами и агрегациями по большому объёму, добавьте Elasticsearch как производную копию, которую всегда можно перестроить из PostgreSQL.
  • Если нужно быстро получить значение по известному ключу (сессия, кэш, счётчик), используйте Redis.

И главное, что стоит унести из статьи: шард - это место, где лежит часть данных, а бакет - ответ на вопрос «на какие группы разбить найденные документы и что посчитать внутри каждой».