Ottimizzazione delle Query Elasticsearch Lente: Best Practice per il Tuning delle Performance

Diagnostica e migliora le query Elasticsearch lente con una migliore forma della query, paginazione, caching, mapping e l'API Profile.

Ottimizzazione delle Query Elasticsearch Lente: Best Practice per il Tuning delle Performance

Le query Elasticsearch lente di solito derivano da una di quattro cause: la query richiede troppi dati, il mapping rende la query costosa, il cluster ha risorse insufficienti, o l'applicazione ripete ricerche costose che dovrebbero essere memorizzate nella cache o riprogettate. La soluzione dipende da quale di queste cause è vera.

Prima di riscrivere tutto, cattura una richiesta lenta reale con il suo indice, filtri, ordinamento, aggregazioni, profondità di pagina, dimensione della risposta e tempi. Un'aggregazione di dashboard, una query di autocompletamento e un job di esportazione stressano Elasticsearch in modo diverso.

Comprensione dei Colli di Bottiglia delle Performance delle Query

Prima di immergerci nelle soluzioni, è utile comprendere le ragioni comuni dietro le query Elasticsearch lente. Queste spesso includono:

  • Query Complesse: Query con clausole bool multiple, query nidificate o operazioni costose come wildcard o regexp su grandi set di dati.
  • Recupero Inefficiente dei Dati: Recuperare _source inutilmente, o recuperare un gran numero di documenti per la paginazione.
  • Vincoli di Risorse: CPU, memoria o I/O del disco insufficienti sui nodi dati.
  • Mapping Subottimali: Utilizzare tipi di dati errati o non sfruttare doc_values per le aggregazioni.
  • Squilibrio o Sovraccarico degli Shard: Troppi shard, troppo pochi shard o distribuzione non uniforme degli shard/dati.
  • Cache Miss o Cache Inadatta: Ripetere ricerche costose senza utilizzare la cache delle richieste, il contesto di filtro o la cache a livello di applicazione dove appropriato.

Ottimizzazione della Struttura delle Query

Il modo in cui costruisci le tue query ha un impatto profondo sulle loro performance. Piccole modifiche possono portare a miglioramenti significativi.

1. Recupera Solo i Campi Necessari (Filtraggio _source e stored_fields)

Per impostazione predefinita, Elasticsearch restituisce l'intero campo _source per ogni documento corrispondente. Se i tuoi documenti sono grandi e l'interfaccia utente ha bisogno solo di un titolo, un ID e un timestamp, recuperare l'intero documento spreca larghezza di banda di rete e tempo di parsing.

  • Filtraggio _source: Usa il parametro _source per specificare un array di campi da includere o escludere.

    GET /mio-indice/_search
    {
      "_source": ["titolo", "autore", "data_pubblicazione"],
      "query": {
        "match": {
          "contenuto": "performance Elasticsearch"
        }
      }
    }
    
  • stored_fields: Se hai esplicitamente memorizzato campi specifici nel tuo mapping ("store": true), puoi recuperarli con stored_fields. La maggior parte delle distribuzioni non memorizza molti campi in questo modo, quindi il filtraggio _source è la soluzione più comune.

    GET /mio-indice/_search
    {
      "stored_fields": ["titolo", "autore"],
      "query": {
        "match": {
          "contenuto": "performance Elasticsearch"
        }
      }
    }
    

2. Preferisci Tipi di Query Efficienti

Alcuni tipi di query sono intrinsecamente più intensivi in termini di risorse rispetto ad altri.

  • Evita Wildcard Iniziali e Regexp Ampie: Le query wildcard e regexp possono essere costose, specialmente con wildcard iniziali come *test. Le query di prefisso sono solitamente più gestibili delle ricerche con wildcard iniziali, ma richiedono comunque mapping sensati e input limitati.

    # Inefficiente - evita wildcard iniziali
    {
      "query": {
        "wildcard": {
          "nome.keyword": {
            "value": "*ricerca"
          }
        }
      }
    }
    
    # Meglio - se conosci il prefisso
    {
      "query": {
        "prefix": {
          "nome.keyword": {
            "value": "Elastic"
          }
        }
      }
    }
    
  • Usa match_phrase per l'intento di frase: Se l'utente sta cercando una frase esatta, match_phrase esprime meglio quell'intento rispetto a diverse clausole match non correlate. Non è sempre più economico, ma evita di restituire documenti che contengono le parole molto distanti tra loro.

  • Contesto di filtro per condizioni sì/no: Quando ti interessa solo se un documento corrisponde a una condizione, metti quella condizione nel contesto filter o usa constant_score. Questo evita lavoro di scoring non necessario ed è più amico della cache.

    GET /mio-indice/_search
    {
      "query": {
        "constant_score": {
          "filter": {
            "term": {
              "stato": "attivo"
            }
          }
        }
      }
    }
    

3. Ottimizza le Query Booleane

  • Usa filtri per vincoli strutturati: Metti ID tenant, valori di stato, intervalli di date e tag esatti in filter, non in must, a meno che non abbiano bisogno di scoring. Elasticsearch può riordinare e ottimizzare le clausole internamente, quindi non fare affidamento sull'ordine JSON come strumento principale di performance.
  • Usa minimum_should_match intenzionalmente: Può migliorare la pertinenza e ridurre le corrispondenze ampie, ma impostarlo troppo alto può nascondere risultati validi.

4. Paginazione Efficiente (search_after e scroll)

La paginazione tradizionale from/size diventa molto inefficiente per pagine profonde (es. from: 10000, size: 10). Elasticsearch deve recuperare e ordinare tutti i documenti fino a from + size su ogni shard, poi scartare from documenti.

  • search_after: Per la paginazione profonda in tempo reale, search_after è raccomandato. Usa l'ordine di ordinamento dell'ultimo documento della pagina precedente per trovare il successivo set di risultati, simile ai cursori nei database tradizionali. È senza stato e scala meglio.

    # Prima richiesta
    GET /mio-indice/_search
    {
      "size": 10,
      "query": {"match_all": {}},
      "sort": [{"timestamp": "asc"}, {"_id": "asc"}]
    }
    
    # Richiesta successiva usando i valori di ordinamento dell'ultimo documento della prima richiesta
    GET /mio-indice/_search
    {
      "size": 10,
      "query": {"match_all": {}},
      "search_after": [1678886400000, "doc_id_XYZ"],
      "sort": [{"timestamp": "asc"}, {"_id": "asc"}]
    }
    
  • API scroll: Per il recupero bulk di grandi set di dati, come reindicizzazione o esportazioni, scroll può ancora essere utile. Per versioni più recenti di Elasticsearch e scansioni complete di indici a lunga esecuzione, considera anche point-in-time più search_after. Scroll non è adatto per la paginazione in tempo reale rivolta all'utente.

5. Ottimizzazione delle Aggregazioni

Le aggregazioni possono essere intensive in termini di risorse, specialmente su campi ad alta cardinalità.

  • Pre-calcolo delle Aggregazioni: Considera l'esecuzione di aggregazioni complesse non in tempo reale durante l'indicizzazione o su base programmata per pre-calcolare i risultati e memorizzarli in un indice separato.
  • doc_values: Assicurati che i campi utilizzati nelle aggregazioni abbiano doc_values abilitati (che è l'impostazione predefinita per la maggior parte dei campi non di testo). Questo permette a Elasticsearch di caricare i dati per le aggregazioni in modo efficiente senza caricare _source.
  • eager_global_ordinals: Per i campi keyword usati frequentemente nelle aggregazioni terms, impostare eager_global_ordinals: true nel mapping può migliorare le performance pre-costruendo gli ordinali globali. Questo comporta un costo al momento del refresh dell'indice ma accelera le aggregazioni al momento della query.

Sfruttare le Tecniche di Caching

Elasticsearch offre diversi livelli di caching che possono accelerare significativamente le query ripetute.

1. Cache delle Query del Nodo

  • Meccanismo: Memorizza nella cache i risultati delle clausole di filtro all'interno delle query bool che vengono utilizzate frequentemente. È una cache in memoria a livello di nodo.
  • Efficacia: Più efficace per clausole di filtro ripetute. Non fare affidamento su di essa per ogni query; Elasticsearch decide cosa vale la pena memorizzare nella cache.
  • Configurazione: Abilitata per impostazione predefinita. Puoi controllarne la dimensione con indices.queries.cache.size (default 10% dell'heap).

2. Cache delle Richieste dello Shard

  • Meccanismo: Memorizza nella cache i risultati di ricerca a livello di shard, più comunemente per richieste pesanti di aggregazioni con size=0. È particolarmente adatto per query di dashboard ripetute su dati che non cambiano ogni secondo.

  • Efficacia: Eccellente per query di dashboard o applicazioni analitiche dove la stessa richiesta (incluse le aggregazioni) viene eseguita ripetutamente con parametri identici.

  • Come usarla: Abilitala esplicitamente nella tua query usando "request_cache": true.

    GET /mio-indice/_search?request_cache=true
    {
      "size": 0,
      "query": {
        "bool": {
          "filter": [
            {"term": {"stato.keyword": "attivo"}},
            {"range": {"timestamp": {"gte": "now-1h"}}}
          ]
        }
      },
      "aggs": {
        "messaggi_al_minuto": {
          "date_histogram": {
            "field": "timestamp",
            "fixed_interval": "1m"
          }
        }
      }
    }
    
  • Avvertenze: La cache viene invalidata ogni volta che uno shard viene aggiornato (nuovi documenti indicizzati o esistenti aggiornati). Utile solo per query che restituiscono risultati identici frequentemente.

3. Cache del Filesystem (a livello di OS)

  • Meccanismo: La cache del filesystem del sistema operativo gioca un ruolo critico. Elasticsearch si basa fortemente su di essa per memorizzare nella cache i segmenti di indice frequentemente acceduti.
  • Efficacia: Cruciale per le performance delle query. Se i segmenti di indice sono in RAM, l'I/O del disco viene bypassato completamente, portando a un'esecuzione delle query molto più veloce.
  • Best Practice: Lascia RAM sostanziale per la cache del filesystem. Un punto di partenza comune è mantenere l'heap JVM circa la metà della memoria di sistema, tenendo a mente i soliti limiti dell'heap di Elasticsearch, poi validare con il tuo carico di lavoro.

4. Caching a Livello di Applicazione

  • Meccanismo: Implementare una cache a livello di applicazione (es. usando Redis, Memcached o una cache in memoria) per i risultati di ricerca richiesti frequentemente.
  • Efficacia: Può fornire i tempi di risposta più veloci bypassando completamente Elasticsearch per richieste ripetute. Migliore per risultati di ricerca statici o che cambiano lentamente.
  • Considerazioni: La strategia di invalidazione della cache è fondamentale. Richiede una progettazione attenta per garantire la coerenza dei dati.

Utilizzo dell'API Profile per l'Identificazione dei Colli di Bottiglia

L'API Profile è uno strumento prezioso per capire esattamente come Elasticsearch esegue una query e dove viene speso il tempo. Suddivide il tempo di esecuzione per ogni componente della tua query e aggregazione.

Come Usare l'API Profile

Aggiungi semplicemente "profile": true al corpo della tua richiesta di ricerca.

GET /mio-indice/_search
{
  "profile": true,
  "query": {
    "bool": {
      "must": [
        {"match": {"titolo": "Elasticsearch"}},
        {"term": {"stato.keyword": "pubblicato"}}
      ],
      "filter": [
        {"range": {"data_pubblicazione": {"gte": "2023-01-01"}}}
      ]
    }
  },
  "aggs": {
    "autori_popolari": {
      "terms": {
        "field": "autore.keyword",
        "size": 10
      }
    }
  }
}

Interpretazione dei Risultati dell'API Profile

La risposta includerà una sezione profile che dettaglia l'esecuzione della query e dell'aggregazione su ogni shard. Le metriche chiave da cercare includono:

  • description: Il componente specifico della query o dell'aggregazione.
  • time_in_nanos: Il tempo speso per eseguire questo componente.
  • breakdown: Sotto-metriche dettagliate come build_scorer_time, collect_time, set_weight_time per le query e reduce_time per le aggregazioni.
  • children: Componenti nidificati, che mostrano come il tempo è distribuito all'interno di query complesse.

Esempio di Interpretazione:

Se vedi un time_in_nanos alto per un WildcardQuery, conferma che questa è una parte costosa della tua query. Se collect_time è alto, suggerisce che il recupero e l'elaborazione dei documenti dopo una corrispondenza è un collo di bottiglia, possibilmente a causa del parsing di _source o della paginazione profonda. Un reduce_time alto nelle aggregazioni potrebbe indicare un carico pesante durante la fase finale di merge.

Esaminando queste metriche, puoi individuare clausole di query specifiche o campi di aggregazione che consumano più risorse e quindi applicare le tecniche di ottimizzazione discusse in precedenza.

Best Practice Generali per le Performance

Oltre alle ottimizzazioni specifiche delle query, diverse best practice a livello di cluster e di indice contribuiscono alle performance complessive di ricerca.

1. Mapping degli Indici Ottimali

  • text vs. keyword: Usa text per la ricerca full-text e keyword per il matching esatto, l'ordinamento e le aggregazioni. Tipi non corrispondenti possono portare a query inefficienti.
  • doc_values: Assicurati che doc_values siano abilitati per i campi su cui intendi ordinare o aggregare. Sono abilitati per impostazione predefinita per la maggior parte dei tipi di campo che supportano ordinamento e aggregazioni, come keyword, numerici, data, booleani e IP. I campi text semplici sono per la ricerca full-text; usa un sottocampo keyword quando hai bisogno di matching esatto o aggregazione.
  • norms: Disabilita norms ("norms": false) per i campi dove non hai bisogno della normalizzazione della lunghezza del documento (es. campi ID). Questo risparmia spazio su disco e migliora la velocità di indicizzazione, con un impatto minimo sulle performance delle query per query non di scoring.
  • index_options: Per i campi text, usa index_options: docs se hai solo bisogno di sapere se un termine esiste in un documento, e index_options: positions (il default) se hai bisogno di query di frase e ricerche di prossimità.

2. Monitora la Salute del Cluster e le Risorse

  • Stato del Cluster: Verde è l'obiettivo. Giallo significa che uno o più shard replica non sono assegnati; le ricerche possono ancora funzionare, ma la resilienza è ridotta e le performance potrebbero risentirne. Rosso significa che gli shard primari mancano e alcuni dati non sono disponibili.
  • Monitoraggio delle Risorse: Monitora regolarmente CPU, RAM, I/O del disco e utilizzo della rete sui tuoi nodi dati. Picchi in queste metriche spesso sono correlati a query lente.
  • Heap JVM: Tieni d'occhio l'utilizzo dell'heap JVM. Un'elevata utilizzazione può portare a frequenti pause di garbage collection, rendendo le query lente. Ottimizza le query per ridurre la pressione sull'heap.

3. Allocazione Corretta degli Shard

  • Troppi Shard: Ogni shard consuma risorse. Molti shard piccoli creano overhead. Shard nell'ordine delle decine di gigabyte sono comuni, ma la dimensione giusta dipende dall'heap, dal pattern delle query, dagli obiettivi di recupero e dall'hardware.
  • Troppo Pochi Shard: Limita il parallelismo. Le query contro un indice con troppo pochi shard non saranno in grado di sfruttare efficientemente tutti i nodi dati disponibili.

4. Strategia di Indicizzazione

  • Intervallo di Refresh: Un refresh_interval più basso (default 1 secondo) rende i dati visibili più velocemente ma aumenta l'overhead di indicizzazione. Per carichi di lavoro pesanti in ricerca, considera di aumentarlo leggermente (es. 5-10 secondi) per ridurre la pressione del refresh.

Il flusso di lavoro pratico è semplice: trova la vera query lenta, profilala, riduci la quantità di dati che tocca e fai sì che il mapping corrisponda al modo in cui gli utenti cercano. Se la query è già pulita, guarda il layout degli shard, la pressione sull'heap, la cache del filesystem e l'I/O del disco. Elasticsearch è veloce quando il design dell'indice, la forma della query e le risorse del cluster sono in accordo tra loro.