Оптимизация производительности Elasticsearch (шарды, реплики, refresh_interval)
Медленный Elasticsearch — почти всегда результат неправильных настроек, а не недостатка железа. Лишние шарды убивают производительность надёжнее, чем слабые процессоры. Слишком частый refresh делает индексацию в 3–5 раз медленнее, чем нужно. Оптимизация Elasticsearch — это прежде всего правильное проектирование, а потом уже тюнинг железа.
Шарды: главная точка оптимизации
Каждый шард — это отдельный экземпляр Lucene индекса со своими файловыми дескрипторами, JVM объектами, overhead на heap. На кластере с 5 узлами держать 500 маленьких индексов по 50 шардов каждый = 25 000 шардов = кластер ползает.
Правило: 1 шард = 10–50 GB данных. Меньше — шарды слишком маленькие (overhead доминирует над данными). Больше — шард сложно перебалансировать при добавлении узла.
Максимум шардов на 1 GB heap: ~20 шардов. При heap 16 GB = не более 320 шардов на узел.
Проверить статистику шардов:
# Размер шардов и их распределение
curl -u elastic:pw "http://localhost:9200/_cat/shards?v&h=index,shard,prirep,state,docs,store,node"
# Сколько шардов на узел
curl -u elastic:pw "http://localhost:9200/_cat/nodes?v&h=name,shards,diskUsed,heapPercent"
Уменьшение числа шардов через shrink API:
# Сначала отключить запись и переместить все шарды на один узел
PUT /products/_settings
{
"settings": {
"index.routing.allocation.require._name": "es-node-01",
"index.blocks.write": true
}
}
# Shrink до 1 шарда
POST /products/_shrink/products_shrunk
{
"settings": {
"index.number_of_shards": 1,
"index.number_of_replicas": 1,
"index.routing.allocation.require._name": null,
"index.blocks.write": null
}
}
refresh_interval
Elasticsearch по умолчанию делает refresh каждую секунду — создаёт новый сегмент Lucene из буфера в памяти и делает документы доступными для поиска. Каждый refresh — файловые операции, создание сегмента, нагрузка на IO.
Для real-time поиска (чат, уведомления) — оставить 1s.
Для аналитики, логов, ETL — увеличить до 30s–300s:
PUT /logs-*/_settings
{
"index.refresh_interval": "60s"
}
При bulk-загрузке данных — отключить на время:
PUT /products/_settings
{
"index.refresh_interval": "-1"
}
# Загружаем данные...
PUT /products/_settings
{
"index.refresh_interval": "1s"
}
POST /products/_refresh
Прирост скорости индексации при refresh_interval: -1 vs 1s — 3–5x.
Merge Policy и forcemerge
Lucene периодически объединяет мелкие сегменты в крупные (merge). Это освобождает место от удалённых документов и ускоряет поиск (меньше сегментов = меньше итераций). По умолчанию происходит в фоне, но создаёт IO нагрузку.
Для read-only индексов (архивные данные, завершённые rolling-индексы) — форсировать merge до 1 сегмента:
POST /logs-2024.01.01/_forcemerge?max_num_segments=1
После forcemerge поиск по индексу значительно быстрее, а размер уменьшается на 20–40% за счёт удаления tombstone-записей для deleted docs.
Не запускать forcemerge на активно индексируемых индексах — создаёт огромную IO нагрузку.
Реплики при индексации
Реплика — синхронная копия шарда на другом узле. Каждый записанный документ индексируется в primary + все replica шарды. При 2 репликах — утроенная нагрузка на диск при индексации.
При bulk-загрузке данных в новый индекс:
// 1. Устанавливаем 0 реплик на время загрузки
PUT /products/_settings
{ "index.number_of_replicas": 0 }
// 2. Загружаем данные
// ...
// 3. Восстанавливаем реплики
PUT /products/_settings
{ "index.number_of_replicas": 1 }
Прирост скорости — 2–3x при 1 реплике, 3–4x при 2 репликах.
Bulk API
Индексировать по одному документу — антипаттерн. Batch-размер зависит от размера документов: цель — пакеты по 5–15 MB.
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk, parallel_bulk
es = Elasticsearch([...])
def generate_actions(data):
for item in data:
yield {
"_index": "products",
"_id": item["id"],
"_source": item,
}
# Parallel bulk с несколькими потоками
success, errors = 0, 0
for ok, info in parallel_bulk(
es,
generate_actions(data),
thread_count=4,
chunk_size=500,
max_chunk_bytes=10 * 1024 * 1024, # 10 MB
raise_on_error=False
):
if ok:
success += 1
else:
errors += 1
print(f"Error: {info}")
Оптимизация запросов
Filter vs. Query: использовать filter везде, где не нужен score. Фильтры кэшируются на уровне шарда, не влияют на scoring.
// Медленно (scoring + no cache)
{
"query": {
"term": { "is_active": true }
}
}
// Быстро (no scoring + cached)
{
"query": {
"bool": {
"filter": [
{ "term": { "is_active": true } }
]
}
}
}
Wildcard и regexp — дорогостоящие операции, особенно с leading wildcard (*term). Избегать или заменять на edge N-gram анализ.
Deep pagination: from: 10000 — дорого. ES должен собрать 10 000 + size документов с каждого шарда. Использовать search_after для пагинации:
{
"query": { "match_all": {} },
"sort": [
{ "created_at": "desc" },
{ "_id": "asc" }
],
"search_after": ["2024-01-15T10:00:00", "abc123"],
"size": 20
}
Мониторинг производительности запросов
Profile API — детальный разбор выполнения запроса:
POST /products/_search
{
"profile": true,
"query": {
"match": { "title": "ноутбук" }
}
}
В ответе — breakdown по каждому шарду: время создания запроса, scoring, fetch. Позволяет найти узкое место.
Hot Threads API — что делает JVM:
curl -u elastic:pw "http://localhost:9200/_nodes/hot_threads"
Heap и GC
При heap > 85% включается агрессивный GC, запросы начинают тормозить. Признаки: GCOverheadLimit исключения в логах, резкое снижение throughput.
Смотреть GC статистику:
curl -u elastic:pw "http://localhost:9200/_nodes/stats/jvm?pretty" | \
jq '.nodes[] | {name: .name, heap_used_percent: .jvm.mem.heap_used_percent, gc_young: .jvm.gc.collectors.young.collection_time_in_millis}'
G1GC (по умолчанию в JDK 14+) — лучший выбор для ES. В jvm.options:
-XX:+UseG1GC
-XX:G1ReservePercent=25
-XX:InitiatingHeapOccupancyPercent=30
Сроки
Аудит конфигурации существующего кластера с рекомендациями — 1 рабочий день. Оптимизация шардинга, refresh_interval, bulk-индексации — 2–3 дня. Глубокая оптимизация запросов с profiling на реальной нагрузке — дополнительно 1–2 дня.







