Оптимізація продуктивності 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"
}
При масовому завантаженню даних — тимчасово відключити:
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-записів для видалених docs.
Не запускайте forcemerge на активно індексуємих індексах — створює величезну IO навантаження.
Реплики при індексації
Реплика — синхронна копія шарда на іншому вузлі. Кожний записаний документ індексується в primary + усі replica шарди. При 2 репліках — утроєне навантаження на диск при індексації.
При масовому завантаженню даних у новий індекс:
// 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
Індексувати по одному документу — антипаттерн. Розмір пакету залежить від розміру документів: ціль — пакети по 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,
}
# Паралельний 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 дні.







