Маппинг: явный, динамический и шаблоны индексов
Когда вы кладёте документ в Elasticsearch, он должен знать, как именно хранить каждое поле — в инвертированный индекс, числовую структуру или поле для сортировки. Эту схему называют маппингом (mapping). Маппинг — аналог CREATE TABLE в SQL, только для JSON-документов.
Маппинг бывает динамическим (ES угадывает типы сам по первым данным) и явным (вы задаёте схему заранее). Понять разницу — значит избежать большей части классических ошибок при работе с ES в продакшне.
Динамический маппинг: ES учится на лету
Если вы создаёте индекс и сразу кладёте документ без предварительных настроек, ES проанализирует каждое поле и автоматически выведет его тип. Это и есть dynamic mapping.
Положим первый документ в индекс catalog:
POST /catalog/_doc/1
{
"title": "Клавиатура механическая",
"price": 7500,
"in_stock": true,
"created_at": "2024-01-15T10:00:00Z"
}ES посмотрит на значения и создаст такой маппинг:
| Поле | Значение | Выведенный тип |
|---|---|---|
title | строка | text + keyword (multi-field) |
price | целое число | long |
in_stock | булев | boolean |
created_at | строка ISO-8601 | date |
Посмотреть, что получилось:
GET /catalog/_mappingСтроковое поле title автоматически получило multi-field — сразу text (для полнотекстового поиска) и keyword (для точных совпадений и сортировки). Это умолчание ES для строк, и оно весьма удобно.
flowchart TD
A["Новый документ"] --> B{"Поле уже есть в маппинге?"}
B -- Да --> C["Используем существующий тип"]
B -- Нет --> D{"dynamic: true?"}
D -- Нет --> E["Поле игнорируется"]
D -- Да --> F{"Определяем тип по значению"}
F --> G["строка → text + keyword"]
F --> H["целое число → long"]
F --> I["дробное → float"]
F --> J["true/false → boolean"]
F --> K["ISO-строка → date"]
G --> L["Добавляем поле в маппинг, индексируем документ"]
H --> L
I --> L
J --> L
K --> L
C --> M["Документ проиндексирован"]
L --> MГде динамический маппинг ломается
Динамика удобна для старта, но в продакшне приносит неприятности.
Неправильный тип при первой записи. Если первый документ содержит "rating": "5" (строка), ES создаст rating как text. Следующий документ с "rating": 4.5 (число) вызовет mapper_parsing_exception — ES не кладёт число в текстовое поле.
Взрывное число полей. Если документы содержат непредсказуемые ключи (динамические атрибуты, произвольные теги), маппинг разрастается до тысяч полей, что тормозит кластер. Ограничение по умолчанию — index.mapping.total_fields.limit: 1000.
Строка вместо даты. Если первый документ содержит "created_at": "вчера", поле станет text. Следующий документ с ISO-датой упадёт с ошибкой разбора.
Явный маппинг: задаём схему заранее
Явный маппинг создаётся при создании индекса командой PUT /<index> с секцией "mappings":
PUT /catalog
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "russian",
"fields": {
"keyword": { "type": "keyword" }
}
},
"price": { "type": "integer" },
"in_stock": { "type": "boolean" },
"created_at": { "type": "date" },
"description": { "type": "text", "analyzer": "russian" },
"category": { "type": "keyword" }
}
}
}Что здесь важно:
titleанализируется анализаторомrussianдля полнотекстового поиска и параллельно хранится какkeywordдля сортировки и агрегаций;price— типinteger, а неlong(достаточно, если цены не превышают $2 \times 10^9$);category—keyword, потому что по нему будем фильтровать и группировать, а не искать по словам.
Почему маппинг нельзя изменить задним числом
Это один из самых важных фактов про ES: изменить тип существующего поля невозможно. Если поле создано как text, сделать его keyword без пересоздания индекса нельзя.
Причина — Lucene физически строит разные структуры для разных типов. Уже проиндексированные документы записаны под одну структуру; изменить её без повторной записи данных не получится.
Что можно сделать с существующим маппингом:
- Добавлять новые поля в индекс.
- Добавлять новые
fields(multi-fields) к уже существующему полю. - Изменять некоторые параметры, например
ignore_aboveдляkeyword.
Что нельзя:
- Менять
typeсуществующего поля. - Удалять поле из маппинга.
- Менять анализатор для уже существующего
text-поля (исторические документы уже проиндексированы по-старому).
Когда всё-таки нужно изменить маппинг, единственный путь — reindex:
POST /_reindex
{
"source": { "index": "catalog" },
"dest": { "index": "catalog_v2" }
}Создаёте catalog_v2 с правильным маппингом, переносите туда данные через _reindex, затем переключаете алиас. Это штатная практика, не исключение.
Multi-fields: одно поле — несколько представлений
ES позволяет индексировать одно поле сразу в нескольких форматах через секцию fields. Это не хранит дублирующую копию исходного текста — просто строит несколько индексных структур из одного значения.
"title": {
"type": "text",
"analyzer": "russian",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 },
"english": { "type": "text", "analyzer": "english" }
}
}Теперь одно поле можно использовать тремя способами:
matchпоtitle— полнотекстовый поиск с русской морфологией;sortпоtitle.keyword— лексикографическая сортировка;matchпоtitle.english— поиск с английским стеммингом (для двуязычного каталога).
Index templates: единообразие без повторения
Если вы создаёте много похожих индексов — например, логи по дням: logs-2024-01-01, logs-2024-01-02… — прописывать маппинг для каждого вручную не стоит. Для этого есть index templates — шаблоны, которые ES автоматически применяет при создании нового индекса, если его имя совпадает с паттерном.
В ES 8 используются composable index templates (рекомендованный подход начиная с ES 7.8):
PUT /_index_template/logs_template
{
"index_patterns": ["logs-*"],
"priority": 100,
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1
},
"mappings": {
"properties": {
"timestamp": { "type": "date" },
"level": { "type": "keyword" },
"message": { "type": "text" },
"service": { "type": "keyword" }
}
}
}
}Теперь при создании любого индекса, чьё имя начинается с logs-, ES автоматически применит этот маппинг и настройки.
Посмотреть список всех шаблонов:
GET /_index_templateПроверить, что именно применится к конкретному имени (полезно при отладке):
POST /_index_template/_simulate_index/logs-2024-01-01Component templates — дополнительный слой для переиспользования. Выносите общие поля в отдельный компонент и ссылаетесь на него из нескольких index templates:
PUT /_component_template/common_fields
{
"template": {
"mappings": {
"properties": {
"timestamp": { "type": "date" },
"service": { "type": "keyword" }
}
}
}
}
PUT /_index_template/logs_template
{
"index_patterns": ["logs-*"],
"composed_of": ["common_fields"],
"template": {
"mappings": {
"properties": {
"level": { "type": "keyword" },
"message": { "type": "text" }
}
}
}
}Итоговый маппинг индекса — слияние всех компонентов и секции template. Общие поля (временные метки, сервис, хост) держите в одном компоненте и переиспользуйте в шаблонах для разных типов индексов.
Практический порядок работы
Хороший workflow при создании нового индекса:
1. Набросайте структуру документа — какие поля нужны, какие типы логичны.
2. Создайте явный маппинг через PUT /<index> — не надейтесь на динамику в продакшне.
3. Проверьте через _analyze (для текстовых полей), что анализатор токенизирует строки так, как ожидаете.
4. Оберните в index template, если таких индексов будет несколько.
5. Тестируйте на малом объёме перед массовой загрузкой через Bulk API.
Если в процессе нашли ошибку в маппинге — создайте <index>_v2 с исправленным маппингом и перенесите данные через _reindex. Это штатная практика.