Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Ti invito a dare un'occhiata alla trascrizione della presentazione di fine 2019 di Aleksandr Valyalkin "Ottimizzazioni di Go in VictoriaMetrics"

VictoriaMetrics – un database rapido e scalabile per la memorizzazione e l'elaborazione di dati in forma di serie temporali (una registrazione forma il tempo e un insieme di valori corrispondenti a quel tempo, ad esempio, ottenuti attraverso il monitoraggio periodico dello stato dei sensori o la raccolta di metriche).

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Ecco il link al video di questa presentazione – https://youtu.be/MZ5P21j_HLE

Diapositive

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Vi racconterò qualcosa di me. Sono Aleksandr Valyalkin. Ecco il mio account GitHub. Mi appassiono di Go e ottimizzazione delle prestazioni. Ho scritto molte librerie utili e non. Iniziano tutte con fast, o con quick un prefisso.

Attualmente sto lavorando su VictoriaMetrics. Che cos'è e cosa ci faccio? Di questo parlerò in questa presentazione.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Il piano della presentazione è il seguente:

  • Inizierò spiegandovi cos'è VictoriaMetrics.
  • Poi parlerò delle serie temporali.
  • Quindi spiegherò come funziona un database per serie temporali.
  • Successivamente parlerò dell'architettura del database: di cosa è composta.
  • E infine passeremo alle ottimizzazioni presenti in VictoriaMetrics. Queste includono l'ottimizzazione dell'indice invertito e l'ottimizzazione per l'implementazione di bitset in Go.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Qualcuno in sala sa cos'è VictoriaMetrics? Wow, già molte persone lo sanno. Questa è una buona notizia. Per chi non lo sa – è un database per serie temporali. È basato sull'architettura di ClickHouse, su alcuni dettagli di implementazione di ClickHouse. Ad esempio, su cose come: MergeTree, computazione parallela su tutti i nuclei della CPU disponibili e ottimizzazione delle prestazioni tramite l'elaborazione di blocchi di dati che vengono messi in cache dalla CPU.

VictoriaMetrics offre la migliore compressione dei dati rispetto ad altri database per serie temporali.

Si scala verticalmente – cioè puoi aggiungere più processori, più memoria RAM su un singolo computer. VictoriaMetrics utilizzerà con successo queste risorse disponibili e aumenterà le prestazioni lineari.

Inoltre, VictoriaMetrics si espande orizzontalmente – cioè puoi aggiungere nodi aggiuntivi nel cluster di VictoriaMetrics e le sue prestazioni cresceranno quasi linearmente.

Come avete indovinato, VictoriaMetrics è un database veloce, perché non posso scrivere di altri. E poiché è scritto in Go, ne parlo in questo meetup.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Chi sa cos'è una serie temporale? Anche qui molte persone lo sanno. Una serie temporale è una serie di coppie (timestamp, valore), dove queste coppie sono ordinate nel tempo. Il valore rappresenta un numero in virgola mobile – float64.

Ogni serie temporale è identifcata in modo univoco da una chiave. Da cosa è composta questa chiave? È composta da un insieme non vuoto di coppie chiave-valore.

Ecco un esempio di serie temporale. La chiave di questa serie è un elenco di coppie: __name__="cpu_usage" – è il nome della metrica, instance="my-server" – è il computer su cui è stata raccolta questa metrica, datacenter="us-east" – è il data center dove si trova questo computer.

Abbiamo ottenuto il nome di una serie temporale composto da tre coppie chiave-valore. A questa chiave corrisponde un elenco di coppie (timestamp, value). t1, t3, t3, ..., tN – questi sono gli timestamps, 10, 20, 12, ..., 15 – i valori corrispondenti. Questo è l'uso della CPU in questo momento per questa serie.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Dove possono essere utilizzate le serie temporali? Qualcuno ha idee?

  • In DevOps si possono misurare i carichi di lavoro della CPU, RAM, rete, rps, numero di errori, ecc.
  • IoT – possiamo misurare temperatura, pressione, coordinate geografiche e altro ancora.
  • Anche in finanza – possiamo monitorare i prezzi delle azioni e delle valute.
  • Inoltre, le serie temporali possono essere utilizzate per monitorare i processi produttivi nelle fabbriche. Abbiamo utenti che utilizzano VictoriaMetrics per monitorare le turbine eoliche, per i robot.
  • Inoltre, le serie temporali sono utili per raccogliere informazioni da vari sensori. Ad esempio, per il motore; per misurare la pressione nei pneumatici; per misurare velocità, distanza; per misurare il consumo di benzina ecc.
  • Le serie temporali possono anche essere usate per monitorare gli aerei. In ogni aereo c'è una scatola nera che raccoglie serie temporali su vari parametri di stato dell'aeromobile. Le serie temporali sono utilizzate anche nell'industria aerospaziale.
  • Sanità – si tratta di pressione sanguigna, polso ecc.

Forse ci sono altre applicazioni che ho dimenticato, ma spero che abbiate capito che le serie temporali sono ampiamente utilizzate nel mondo moderno. E il volume del loro utilizzo cresce di anno in anno.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

A cosa serve un database per serie temporali? Perché non utilizzare un normale database relazionale per memorizzare le serie temporali?

Perché nei dati temporali di solito c'è una grande quantità di informazioni, che è difficile da memorizzare e gestire in normali database. Così sono nate le DB specializzate per i dati temporali. Questi database conservano efficacemente i punti (timestamp, value) con una chiave specifica. Forniscono API per leggere i dati memorizzati per chiave, per una coppia chiave-valore, o per più coppie, oppure tramite regexp. Ad esempio, se desideri trovare il carico della CPU di tutti i tuoi servizi nel data center in America, devi utilizzare una query pseudo come questa.

In genere, i database per dati temporali presentano linguaggi di query specializzati, poiché SQL non è molto adatto per i dati temporali. Sebbene ci siano database che supportano SQL, non è molto efficace. Linguaggi di query come PromQL, InfluxQL, Flux, Q. Spero che qualcuno abbia sentito parlare almeno di uno di questi linguaggi. Probabilmente molti conoscono PromQL. Questo è il linguaggio di query di Prometheus.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Ecco come appare l'architettura di un moderno database per dati temporali, prendendo come esempio VictoriaMetrics.

È composto da due parti. C'è il repository per l'indice invertito e il repository per i valori dei dati temporali. Questi repository sono separati.

Quando arriva un nuovo record nel database, prima consultiamo l'indice invertito per trovare l'identificatore del dato temporale per il set specificato di label=value per questa metrica. Troviamo questo identificatore e memorizziamo il valore nel repository dei dati.

Quando arriva una richiesta di campionamento dei dati dal TSDB, prima di tutto consultiamo l'indice invertito. Estrarre tutti i timeseries_ids i record che corrispondono a questo insieme. label=valueQuindi estraendo tutti i dati necessari dal repository dei dati, indicizzati da timeseries_ids.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Consideriamo un esempio di come un database per dati temporali gestisce una query di selezione in ingresso.

  • Innanzitutto, estrae tutti i timeseries_ids dall'indice invertito che contengono le coppie specificate label=value, oppure soddisfano l'espressione regolare specificata.
  • Successivamente estrae tutti i data points dal repository dei dati su un intervallo di tempo specificato per i trovati timeseries_ids.
  • Dopo di ciò, il database esegue alcune elaborazioni su questi data points secondo la richiesta dell'utente e poi restituisce la risposta.

In questa presentazione, vi parlerò della prima parte, ovvero la ricerca timeseries_ids nell'indice invertito. Per la seconda e terza parte, potete poi controllare il codice sorgente di VictoriaMetrics, oppure aspettare che prepari altre presentazioni 🙂

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Iniziamo con l'indice invertito. Molti potrebbero pensare che sia semplice. Chi sa cos'è un indice invertito e come funziona? Oh, non ci sono così tante persone. Cerchiamo di capire di cosa si tratta.

In realtà è tutto semplice. È semplicemente un dizionario che mappa una chiave a un valore. Cosa intendiamo per chiave? Questa coppia label=value, dove etichetta e value – sono delle stringhe. E i valori – sono un insieme timeseries_ids, che include la coppia specificata. label=value.

L'indice invertito consente di trovare rapidamente tutti i timeseries_ids, privilegiando i label=value.

E permette anche di trovare rapidamente timeseries_ids dati temporali per più coppie label=value, oppure per coppie label=regexp. Come avviene? Tramite l'intersezione di un insieme timeseries_ids per ogni coppia label=value.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Consideriamo diverse implementazioni dell'indice invertito. Iniziamo dalla più semplice e ingenua. Essa appare così.

Funzione getMetricIDs ottiene un elenco di stringhe. Ogni stringa contiene label=value. Questa funzione restituisce un elenco di metricIDs.

Come funziona? Abbiamo una variabile globale chiamata invertedIndex. È un normale dizionario (map), che mappa una stringa a un slice di interi. La stringa contiene label=value.

Implementazione della funzione: estraiamo metricIDs per il primo label=value, poi passiamo a tutti gli altri label=value, estraendo metricIDs per loro. E invochiamo la funzione intersectInts, di cui si parlerà più avanti. Questa funzione restituisce l'intersezione di queste liste.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Come potete vedere, l'implementazione dell'indice invertito non è molto complessa. Ma è un'implementazione ingenua. Quali sono i suoi svantaggi? Il principale svantaggio dell'implementazione ingenua è che tale indice invertito viene memorizzato in memoria. Dopo il riavvio dell'applicazione, perdiamo questo indice. Non c'è salvataggio di questo indice su disco. Un indice invertito di questo tipo è poco utile per un database.

Il secondo svantaggio è anch'esso legato alla memoria. L'indice invertito deve adattarsi nella memoria. Se supera il limite della memoria, è ovvio che si otterrà un errore di 'out of memory'. E il programma non funzionerà.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Questo problema può essere risolto utilizzando soluzioni già pronte, come LevelDB, oppure RocksDB.

In breve, abbiamo bisogno di un database che consenta di effettuare tre operazioni rapidamente.

  • La prima operazione consiste nell'inserimento chiave-valore in questo database. Lo fa molto rapidamente, dove chiave-valore sono stringhe arbitrarie.
  • La seconda operazione è una ricerca rapida di un valore tramite una chiave fornita.
  • E la terza operazione è una ricerca veloce di tutti i valori con un determinato prefisso.

LevelDB e RocksDB sono database sviluppati rispettivamente da Google e Facebook. Inizialmente è stato creato LevelDB. Poi, ragazzi di Facebook hanno preso LevelDB e hanno iniziato a migliorarlo, creando RocksDB. Adesso, quasi tutti i database interni di Facebook si basano su RocksDB, incluso MySQL, che è stato anch'esso convertito in RocksDB. L'hanno chiamato MyRocks.

Un indice invertito può essere implementato utilizzando LevelDB. Come fare? Salviamo come chiave label=value. E come valore, l'identificatore della serie temporale in cui è presente la coppia label=value.

Se abbiamo molte serie temporali con questa coppia label=value, ci saranno molte righe in questo database con la stessa chiave e valori diversi. timeseries_idsPer ottenere un elenco di tutti timeseries_ids, che iniziano con label=prefix, facciamo una scansione di intervallo, per la quale questo database è ottimizzato. Cioè, selezioniamo tutte le righe che iniziano con label=prefix e otteniamo i valori necessari. timeseries_ids.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Ecco un'implementazione approssimativa, come apparirebbe in Go. Abbiamo un indice invertito. Questo è LevelDB.

La funzione è la stessa di quella dell'implementazione ingenua. Segue quasi riga per riga l'implementazione ingenua. L'unico punto è che invece di fare riferimento a map ci riferiamo all'indice invertito. Estraiamo tutti i valori per la prima label=value. Poi scorrendo tutte le restanti coppie label=value estraiamo i relativi set di metricIDs per esse. Poi troviamo l'intersezione.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Sembra tutto a posto, ma ci sono degli svantaggi in questa soluzione. VictoriaMetrics inizialmente implementava un indice invertito basato su LevelDB, ma alla fine ha dovuto abbandonarlo.

Perché? Perché LevelDB è più lento dell'implementazione ingenua. Nell'implementazione ingenua, dato un determinato valore, estraiamo immediatamente l'intero slice. metricIDsQuesta è un'operazione molto veloce — l'intero slice è pronto per l'uso.

In LevelDB, invece, ad ogni chiamata della funzione GetValues dobbiamo esaminare tutte le righe che iniziano con label=value. E per ogni riga estrarre il valore timeseries_ids. Da questi timeseries_ids costruire uno slice di questi. timeseries_idsÈ evidente che questo è molto più lento rispetto a una semplice consultazione in una mappa tramite chiave.

Il secondo svantaggio è che LevelDB è scritto in C. La chiamata a funzioni in C da Go non è molto veloce. Ci vogliono centinaia di nanosecondi. Non è molto veloce, perché rispetto a una normale chiamata a una funzione scritta in Go, che richiede 1-5 nanosecondi, la differenza di prestazioni è decine di volte. Per VictoriaMetrics, questo è stato un difetto fatale 🙂

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Perciò, ho scritto la mia implementazione di un indice invertito. E l'ho chiamata mergeset.

Mergeset è basato sulla struttura dati MergeTree. Questa struttura dati è stata presa in prestito da ClickHouse. È evidente che mergeset deve essere ottimizzato per una ricerca rapida timeseries_ids con una chiave fornita. Mergeset è scritto interamente in Go. Puoi vedere il codice sorgente di VictoriaMetrics su GitHub. L'implementazione di mergeset si trova nella cartella /lib/mergeset. Puoi provare a capire cosa succede lì.

L'API di mergeset è molto simile a quella di LevelDB e RocksDB. Cioè, consente di inserire rapidamente nuove registrazioni e di effettuare rapidamente ricerche di registrazioni con un determinato prefisso.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Parleremo più avanti dei difetti di mergeset. Adesso discuteremo delle problematiche riscontrate da VictoriaMetrics in produzione durante l'implementazione dell'indice invertito.

Perché sono emerse queste problematiche?

La prima ragione è il high churn rate. In italiano, questo significa un frequente cambio delle serie temporali. Quando una serie temporale termina e inizia una nuova serie, oppure iniziano molte nuove serie. E questo avviene frequentemente.

La seconda ragione è il grande numero di serie temporali. Inizialmente, quando il monitoraggio stava guadagnando popolarità, il numero di serie temporali era ridotto. Ad esempio, per ogni computer si deve monitorare il carico della CPU, della memoria, della rete e del disco. 4 serie temporali per ogni computer. Supponiamo di avere 100 computer e 400 serie temporali. È molto poco.

Con il passare del tempo, le persone hanno pensato di misurare informazioni più dettagliate. Ad esempio, misurare il carico non dell'intero processore, ma di ciascun core del processore. Se hai 40 core, avrai quindi 40 volte più serie temporali per misurare il carico della CPU.

Ma non è tutto. Ogni core del processore può avere più stati come idle, quando è inattivo. Inoltre, lavori in user space, lavori in kernel space e altri stati. E ogni stato può essere misurato come una serie temporale separata. Questo aumenta ulteriormente il numero di righe di 7-8 volte.

Da una sola metrica, abbiamo ottenuto 40 x 8 = 320 metriche solo per un computer. Moltiplicando per 100, otteniamo 32.000 invece di 400.

Poi è arrivato Kubernetes. E la situazione è peggiorata, perché con Kubernetes possono essere ospitati molti servizi diversi. Ogni servizio in Kubernetes è composto da molti pod. Tutto questo deve essere monitorato. Inoltre, abbiamo un deployment continuo di nuove versioni dei vostri servizi. Per ogni nuova versione devono essere creati nuovi series temporali. Di conseguenza, il numero di series temporali cresce esponenzialmente e affrontiamo il problema dell'elevata cardinalità. VictoriaMetrics gestisce questo problema con successo rispetto ad altri database per series temporali.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Analizziamo più in dettaglio l'elevato tasso di abbandono. Da cosa nasce un elevato tasso di abbandono in produzione? Perché alcuni valori delle etichette e dei tag cambiano continuamente.

Ad esempio, prendiamo Kubernetes, in cui esiste il concetto di deployment, cioè quando viene rilasciata una nuova versione della vostra applicazione. Gli sviluppatori di Kubernetes hanno deciso di aggiungere l'ID del deployment nell'etichetta.

Qual è stata la conseguenza? Ogni volta che avviene un nuovo deployment, tutte le vecchie series temporali vengono interrotte e ne iniziano di nuove con il nuovo valore dell'etichetta deployment_id. Tali series possono essere centinaia di migliaia e persino milioni.

Un aspetto importante di tutto ciò è che il numero totale di series temporali cresce, ma il numero di series temporali attive, per le quali arrivano dati, rimane costante. Questa situazione è chiamata elevato tasso di abbandono.

Il problema principale dell'elevato tasso di abbandono è garantire una velocità costante di ricerca in tutte le series temporali per un dato insieme di etichette in un certo intervallo di tempo. Di solito, si tratta di un intervallo di tempo dell'ultima ora o dell'ultimo giorno.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Come risolvere questo problema? Ecco la prima opzione. Si tratta di suddividere l'indice invertito in parti indipendenti per tempo. Cioè, passa un certo intervallo di tempo, smettiamo di lavorare con l'indice invertito attuale e creiamo un nuovo indice invertito. Passa un altro intervallo di tempo, creiamo un altro indice e così via.

E durante la selezione di questi indici invertiti troviamo un insieme di indici invertiti che rientrano nell'intervallo specificato. E, di conseguenza, selezioniamo gli ID delle series temporali da lì.

Questo consente di risparmiare risorse, perché non è necessario esaminare parti che non rientrano nell'intervallo specificato. Cioè, di solito, se selezioniamo dati per l'ultima ora, saltiamo le richieste per i periodi temporali precedenti.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

C'è anche un'altra opzione per risolvere questo problema. Si tratta di mantenere per ogni giorno un elenco separato di ID delle series temporali che si sono verificate in quel giorno.

Il vantaggio di questa soluzione rispetto alla precedente è che non dupliciamo le informazioni sulle series temporali che non scompaiono nel tempo. Rimangono costantemente disponibili e non cambiano.

Lo svantaggio è che questa soluzione è più complessa da implementare e da debuggare. E VictoriaMetrics ha scelto questa soluzione. È stata una decisione storica. Questa soluzione si è dimostrata anche piuttosto efficace, rispetto alla precedente. Perché questa soluzione non è stata implementata poiché si dovevano duplicare i dati in ogni partizione per le series temporali che non cambiano, cioè che non scompaiono nel tempo. VictoriaMetrics è stata ottimizzata principalmente per il consumo di spazio su disco, e la precedente implementazione peggiorava tale consumo. Questa implementazione è invece più adatta a minimizzare il consumo di spazio su disco, quindi è stata scelta.

Abbiamo dovuto affrontarla. La lotta consisteva nel fatto che in questa implementazione è necessario comunque selezionare un numero di dati molto maggiore rispetto a quando l'indice invertito è suddiviso per tempo. timeseries_ids Come abbiamo risolto questo problema? Lo abbiamo fatto in un modo originale: salvando diversi identificatori delle series temporali in ogni record dell'indice invertito anziché un solo identificatore. Cioè, abbiamo una chiave

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

, che si trova in ogni series temporale. E ora salviamo diversi label=valuein un solo record. timeseries_ids Ecco un esempio. Prima avevamo N record, ora ne abbiamo uno il cui prefisso è lo stesso di tutti gli altri. Il precedente record conteneva tutti gli ID delle series temporali.

Questo ha consentito di aumentare la velocità di scansione di tale indice invertito fino a 10 volte. E ha permesso di ridurre il consumo di memoria per la cache, perché ora memorizziamo una stringa

Questo ha permesso di aumentare la velocità di scansione di un indice invertito fino a 10 volte. Ha anche ridotto il consumo di memoria per la cache, poiché ora memorizziamo la stringa. label=value solo una volta nella cache insieme a N volte. E questa stringa può essere lunga, se hai a lungo nei tag e nelle etichette, che Kubernetes ama inserire.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Un'altra opzione per velocizzare la ricerca tramite l'indice invertito è lo sharding. Creare più indici invertiti anziché uno e frammentare i dati tra di essi in base a una chiave. Questo è un insieme key=value di coppie. Cioè, otteniamo più indici invertiti indipendenti, che possiamo interrogare in parallelo su più processori. Le implementazioni precedenti permettevano di lavorare solo in modalità monoprocessore, cioè di scansionare i dati solo su un core. Questa soluzione consente di scansionare i dati su più core contemporaneamente, come ama fare ClickHouse. Questo intendiamo implementare.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

E ora torniamo ai nostri argomenti – alla funzione di intersezione timeseries_ids. Vediamo quali potrebbero essere le implementazioni. Questa funzione consente di trovare timeseries_ids per un insieme specifico label=value.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

La prima opzione è un'implementazione naive. Due cicli annidati. Ecco cosa otteniamo in ingresso nella funzione intersectInts due slices — a e b. In uscita dovrebbe restituire l'intersezione di questi slices.

L'implementazione naive appare così. Scorriamo tutti i valori dallo slice a, all'interno di questo ciclo scorriamo tutti i valori dallo slice b. E li confrontiamo. Se coincidono, significa che abbiamo trovato un'intersezione. E lo salviamo in result.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Quali sono gli svantaggi? La complessità quadratica — questo è il suo principale svantaggio. Ad esempio, se hai dimensioni dello slice a e b di un milione, questa funzione non ti restituirà mai una risposta. Perché dovrà fare un trilione di iterazioni, che sono davvero molte anche per i computer moderni.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

La seconda implementazione si basa su una mappa. Creiamo una mappa. Inseriamo in questa mappa tutti i valori dallo slice a. Poi passiamo con un ciclo separato attraverso lo slice b. E controlliamo – se questo valore dallo slice b è nella mappa. Se c'è, lo aggiungiamo al risultato.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Quali sono i vantaggi? Il vantaggio è che qui c'è solo complessità lineare. Cioè, la funzione verrà eseguita molto più velocemente per grandi dimensioni degli slices. Per uno slice di un milione di dimensioni, questa funzione verrà eseguita in 2 milioni di iterazioni, a differenza di un trilione di iterazioni, come nella funzione precedente.

E lo svantaggio è che questa funzione richiede più memoria per creare questa mappa.

Un secondo svantaggio è l'elevato overhead per l'hashing. Questo svantaggio non è molto evidente. E per noi non era molto evidente, quindi inizialmente l'implementazione dell'intersezione in VictoriaMetrics era tramite mappa. Ma poi il profiling ha mostrato che la maggior parte del tempo del processore veniva speso per la scrittura nella mappa e per controllare se il valore era presente in questa mappa.

Perché in questi punti viene speso tempo del processore? Perché in queste righe Go esegue l'operazione di hashing. Cioè, calcola l'hash dalla chiave, per poi accedere all'indice specificato nell'HashMap. L'operazione di calcolo dell'hash viene eseguita in decine di nanosecondi. È lenta per VictoriaMetrics.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Ho deciso di implementare un bitset, ottimizzato specificamente per questo caso. Ecco come ora appare l'intersezione di due slices. Qui creiamo un bitset. Aggiungiamo ad esso gli elementi dal primo slice. Poi verifichiamo la presenza di questi elementi nel secondo slice. E li aggiungiamo al risultato. Cioè, non differisce molto dall'esempio precedente. L'unica cosa che abbiamo qui è che abbiamo sostituito l'accesso alla mappa con funzioni personalizzate. add e has.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

A prima vista può sembrare che questo dovrebbe funzionare più lentamente, dato che prima veniva utilizzata la mappa standard e qui vengono chiamate alcune funzioni, ma il profiling mostra che questa soluzione funziona dieci volte più velocemente rispetto alla mappa standard nel caso di VictoriaMetrics.

Inoltre, utilizza molta meno memoria rispetto all'implementazione su mappa. Perché qui memorizziamo bit invece di valori a otto byte.

Lo svantaggio di tale implementazione è che non è così ovvia, non è banale.

Un altro svantaggio, che molti potrebbero non notare, è che questa implementazione può funzionare male in alcuni casi. Cioè, è ottimizzata per un caso specifico, per questo caso di intersezione degli ids delle serie temporali in VictoriaMetrics. Ciò non significa che si adatti a tutti i casi. Se viene utilizzata in modo errato, non otterremo un incremento delle prestazioni, ma un errore di esaurimento della memoria e un rallentamento delle prestazioni.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Consideriamo l'implementazione di questa struttura. Se volete vedere, si trova nel codice sorgente di VictoriaMetrics, nella cartella lib/uint64set. È ottimizzata specificamente per il caso di VictoriaMetrics, dove timeseries_id è rappresentato come un valore a 64 bit, dove i primi 32 bit sono per la maggior parte costanti e cambiano solo gli ultimi 32 bit.

Questa struttura dati non viene memorizzata su disco, funziona solo in memoria.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Ecco la sua API. Non è molto complessa. L'API è stata modellata specificamente per un esempio di utilizzo di VictoriaMetrics. Cioè, non ci sono funzioni superflue. Ci sono funzioni che sono esplicitamente utilizzate da VictoriaMetrics.

Ci sono funzioni add, che aggiunge nuovi valori. C'è una funzione has, che verifica nuovi valori. E c'è una funzione del, che rimuove valori. C'è una funzione ausiliaria len, che restituisce la dimensione dell'insieme. La funzione clone clona l'insieme. E la funzione appendto trasforma questo set in uno slice. timeseries_ids.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Ecco come appare l'implementazione di questa struttura dati. In set ci sono due elementi:

  • ItemsCount – è un campo ausiliario, per restituire rapidamente il numero di elementi nel set. Si potrebbe fare a meno di questo campo ausiliario, ma è stato necessario aggiungerlo qui, poiché VictoriaMetrics spesso interroga la lunghezza del bitset nei suoi algoritmi.

  • Il secondo campo è buckets. Questo è uno slice della struttura bucket32. In ogni struttura è memorizzato hi un campo. Questi sono i 32 bit superiori. E due slice — b16his e buckets di bucket16 strutture.

Qui sono memorizzati i 16 bit superiori della seconda parte della struttura a 64 bit. E qui sono memorizzati i bitsets per i 16 bit inferiori di ciascun byte.

Bucket64 è composto da un array di uint64.La lunghezza è calcolata utilizzando queste costanti. In uno bucket16 può essere memorizzati al massimo 2^16=65536 bit. Se lo si divide per 8, sono 8 kilobyte. Se si divide ancora una volta per 8, si ottengono 1000 uint64. valore. Cioè, Bucket16 – è una struttura da 8 kilobyte.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Consideriamo come è implementato uno dei metodi di questa struttura per l'aggiunta di un nuovo valore.

Tutto inizia con uint64. un valore. Calcoliamo i 32 bit superiori, calcoliamo i 32 bit inferiori. Scorriamo tutti buckets. Confrontiamo i 32 bit superiori in ciascun bucket con il valore da aggiungere. E se coincidono, chiamiamo la funzione add nella struttura b32. bucketsE aggiungiamo i 32 bit inferiori lì. E se restituisce true, significa che abbiamo aggiunto quel valore lì e non avevamo quel valore. Se restituisce false, significa che tale valore era già presente. Poi aumentiamo il numero di elementi nella struttura.

Se non abbiamo trovato il bucket con il valore hi corretto, chiamiamo la funzione addAlloc, che alloca un nuovo bucket, aggiungendolo nella struttura a bucket.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Questa è l'implementazione della funzione b32.add.È simile all'implementazione precedente. Calcoliamo i 16 bit superiori, i 16 bit inferiori.

Poi scorriamo tutti i 16 bit superiori. Troviamo corrispondenze. E al correlare chiamiamo il metodo add, che esamineremo nella pagina successiva per bucket16.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Ed ecco il livello più basso, che deve essere ottimizzato al massimo. Calcoliamo per uint64. l'id valore nello slice bit, così come bitmask.Questa è una maschera per il valore a 64 bit, con la quale è possibile verificare la presenza di questo bit o impostarlo. Verifichiamo la presenza di questo bit, lo impostiamo e restituiamo la presenza. Ecco come è strutturata questa implementazione, che ha permesso di accelerare l'operazione di intersezione degli ids delle serie temporali di 10 volte rispetto alle normali maps.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

In VictoriaMetrics, oltre a questa ottimizzazione, ci sono molte altre ottimizzazioni. La maggior parte di queste ottimizzazioni non è stata aggiunta a caso, ma dopo aver profilato il codice in produzione.

Questa è la regola principale dell'ottimizzazione: non aggiungere ottimizzazioni supponendo che ci sarà un collo di bottiglia, perché potrebbe rivelarsi che non ci sarà. L'ottimizzazione di solito peggiora la qualità del codice. Pertanto, è meglio ottimizzare solo dopo il profiling e idealmente in produzione, in modo da avere dati reali. Chi è interessato, può guardare i sorgenti di VictoriaMetrics e studiare altre ottimizzazioni che ci sono.

Ottimizzazioni Go in VictoriaMetrics. Aleksandr Valialkin

Ho una domanda sul bitset. Sembra molto simile all'implementazione di C++ vector bool, un bitset ottimizzato. Avete preso l'implementazione da lì?

No, non da lì. Quando ho implementato questo bitset, mi sono basato sulla conoscenza della struttura di questi ids delle serie temporali, che vengono utilizzati in VictoriaMetrics. La loro struttura è tale che i 32 bit superiori sono principalmente costanti. I 32 bit inferiori possono cambiare. Più il bit è in basso, più frequentemente può cambiare. Pertanto, questa implementazione è stata ottimizzata per questa specifica struttura dati. Per quanto ne so, l'implementazione in C++ è ottimizzata per il caso generale. Se si effettua un'ottimizzazione per il caso generale, significa che non sarà la più ottimale per il caso specifico.

Ti consiglio di guardare anche la presentazione di Alexey Milovidov. Circa un mese fa ha parlato delle ottimizzazioni in ClickHouse per specializzazioni specifiche. Dice proprio che, nel caso generale, l'implementazione in C++ o qualsiasi altra implementazione è progettata per funzionare bene in media. Potrebbe funzionare peggio rispetto a un'implementazione specializzata basata su conoscenze specifiche, come nel nostro caso, quando sappiamo che i 32 bit superiori sono principalmente costanti.

Ho una seconda domanda. Qual è la differenza fondamentale rispetto a InfluxDB?

Ci sono molte differenze fondamentali. Per quanto riguarda le prestazioni e il consumo di memoria, InfluxDB nei test mostra un consumo di memoria dieci volte superiore per serie temporali ad alta cardinalità, quando ne hai molte, ad esempio, milioni. Ad esempio, VictoriaMetrics consuma 1 GB per un milione di serie attive, mentre InfluxDB consuma 10 GB. E questa è una grande differenza.

La seconda differenza fondamentale è che in InfluxDB ci sono linguaggi di query piuttosto strani – Flux e InfluxQL. Non sono molto comodi per lavorare con le serie temporali rispetto a PromQL, che è supportato in VictoriaMetrics. PromQL è il linguaggio di query di Prometheus.

Un'altra differenza è che InfluxDB presenta un modello dati un po' strano, dove in ogni riga possono essere memorizzati più campi con set di tag diversi. Queste righe sono ulteriormente divise in varie tabelle. Questi ulteriori complessità rendono più difficile lavorare con questo database. È complicato da mantenere e capire.

In VictoriaMetrics tutto è molto più semplice. Ogni serie temporale è rappresentata come una coppia chiave-valore. Il valore è un insieme di punti – (timestamp, value), mentre la chiave è un insieme di label=value. Non c'è alcuna suddivisione in campi e misurazioni. Questo ti permette di selezionare qualsiasi dato e poi combinarlo, sommarlo, sottrarlo, moltiplicarlo e dividerlo, a differenza di InfluxDB, dove i calcoli tra diverse serie non sono ancora stati implementati, per quanto ne so. Anche se fossero realizzati, sarebbe complicato e bisognerebbe scrivere un sacco di codice.

Ho una domanda di chiarimento. Ho capito correttamente che c'era un problema, di cui parlavi, riguardo al fatto che questo indice invertito non entra in memoria, quindi lì si procede con la partizione?

Inizialmente ho mostrato un'implementazione naif dell'indice invertito su una mappa standard di Go. Tale implementazione non è adatta per i database, perché questo indice invertito non è persistente su disco, mentre un database deve salvare su disco affinché questi dati rimangano accessibili al riavvio. In questa implementazione, quando si riavvia l'applicazione, l'indice invertito scompare. E perderai l'accesso a tutti i dati, perché non potrai trovarli.

Buongiorno! Grazie per la tua presentazione! Mi chiamo Pavel. Vengo dall'azienda Wildberries. Ho alcune domande per te. Prima domanda. Cosa ne pensi? Se avessi scelto un principio diverso nella costruzione dell'architettura della tua applicazione e avessi partizionato i dati per tempo, sarebbe stato possibile fare intersezioni dei dati in base esclusivamente al fatto che in una partizione ci sono dati per un dato intervallo di tempo, cioè per un tempo specifico, senza dover preoccuparti del fatto che hai pezzi sparsi? Seconda domanda: visto che implementi un algoritmo simile con bitset e tutto il resto, hai mai provato a utilizzare istruzioni del processore? Forse hai provato tali ottimizzazioni?

Rispondo subito alla seconda domanda. Non ci siamo ancora arrivati. Ma se sarà necessario, ci arriveremo. E la prima, qual era la domanda?

Hai discusso due scenari. E hai detto che hai scelto il secondo con un'implementazione più complessa. E non hai preferito il primo, dove i dati sono partizionati per tempo.

Sì. Nel primo caso, il volume complessivo dell'indice sarebbe stato maggiore, perché in ogni partizione dovremmo memorizzare dati duplicati per quelle serie temporali che si estendono attraverso tutte queste partizioni. E se hai un basso tasso di churn nelle serie temporali, cioè le stesse righe vengono utilizzate continuamente, nel primo caso perderemmo molto di più in termini di spazio disco rispetto al secondo caso.

Così com'è, sì, la partizione per tempo è una buona opzione. È ciò che utilizza Prometheus. Ma in Prometheus c'è un altro inconveniente. Durante la fusione di questi pezzi di dati, ha bisogno di mantenere in memoria le informazioni sui metadati per tutte le etichette e le serie temporali. Pertanto, se i pezzi di dati sono grandi che sta unendo, il consumo di memoria cresce notevolmente durante la fusione, a differenza di VictoriaMetrics. Durante la fusione, VictoriaMetrics non consuma praticamente memoria, ci vogliono solo pochi kilobyte, indipendentemente dalle dimensioni dei pezzi di dati da unire.

L'algoritmo che stai usando consuma memoria. In essa vengono contrassegnate le etichette delle serie temporali, dove ci sono valori. E in questo modo verifichi la presenza congiunta in un array di dati e in un altro. E capisci così se si è verificata un'intersezione o meno. Di solito nei database si implementano i cursori, gli iteratori, che tengono traccia del loro stato corrente e scorrono attraverso i dati ordinati grazie ai quali hai una complessità semplice operazioni.

Perché non usiamo cursori per l'intersezione dei dati?

Sì.

In LevelDB or in mergeset, we store exactly the sorted lines. We can walk through with a cursor and find intersections. But why don’t we use it? Because it’s slow. Because cursors imply that a function needs to be called for each line. Calling a function takes 5 nanoseconds. And if you have 100,000,000 lines, it means we spend half a second just for the function call.

Yes, that exists. And I have one last question. This question might sound a bit strange. Why can't we calculate all necessary aggregates when the data comes in and store them in the required form? Why save huge volumes in systems like VictoriaMetrics, ClickHouse, etc., to later spend a lot of time processing them?

Let me give you an example for clarity. How does a small toy speedometer work? It records the distance you’ve traveled, continuously adding it to one value, and another for time. It divides and gets the average speed. You can do something similar. Aggregate all necessary facts on the fly.

Okay, I understand the question. Your example is valid. If you know what aggregates you need, that's the best implementation. The problem is that people store these metrics and some data in ClickHouse, and they don’t yet know how they will aggregate or filter them in the future, so they have to save all raw data. But if you know you need to calculate something average, why not calculate it instead of saving a bunch of raw values? But this only works if you know exactly what you need.

By the way, time series databases support aggregate calculations. For example, Prometheus supports recording rules. So this can be done if you know what aggregates you will need. VictoriaMetrics doesn’t have this yet, but it’s usually placed before Prometheus, where this can be done using recording rules.

For example, in my previous job, we needed to count events in a sliding window for the last hour. The problem was that we had to create a custom implementation in Go, meaning a service for counting this thing. This service turned out to be non-trivial because it's complicated to calculate. The implementation can be simple if you need to count aggregates at fixed intervals. However, if you want to count events in a sliding window, it’s not as easy as it seems. I think it hasn’t been implemented yet in ClickHouse or time series databases because it's challenging to implement.

And one more question. We were discussing averaging, and I remembered a thing called Graphite with a Carbon backend. It was capable of thinning out old data, leaving one point per minute, one point per hour, etc. This is quite convenient, as we might need raw data, so to speak, for a month, while everything else can be thinned out. But Prometheus and VictoriaMetrics don’t support that functionality. Is there a plan to support it? If not, why?

Thank you for the question. Our users periodically ask it. They want to know when we will add support for downsampling. There are several issues. First, every user has their own definition of downsampling what it means: some want any arbitrary point at a given interval, while others want maximum, minimum, or average values. If multiple systems are writing data to your database, you can’t just apply a one-size-fits-all approach. It may turn out that different thinning methods are needed for each system. And that’s hard to implement.

Secondly, VictoriaMetrics, like ClickHouse, is optimized for working with large volumes of raw data, so it can process a billion lines in less than a second if you have many cores in your system. Scanning time series points in VictoriaMetrics is 50,000,000 points per second per core. And this performance scales with the available cores. So if you have 20 cores, for example, you'll get a billion points scanned per second. This characteristic of VictoriaMetrics and ClickHouse reduces the need for downsampling.

Another property is that VictoriaMetrics compresses this data efficiently. Compression averages between 0.4 to 0.8 bytes per point in production. Each point consists of a timestamp and a value. It compresses to less than one byte on average.

Sergey. I have a question. What is the minimum time quantum for recording?

Un millisecondo. Recentemente abbiamo avuto una conversazione con altri sviluppatori di database per le serie temporali. Il loro quantum di tempo minimo è un secondo. Anche in Graphite, per esempio, è un secondo. In OpenTSDB è anch'esso un secondo. In InfluxDB, la precisione è in nanosecondi. In VictoriaMetrics, è un millisecondo, perché in Prometheus è di un millisecondo. VictoriaMetrics è stata progettata inizialmente come storage remoto per Prometheus. Ma ora può conservare dati anche da altri sistemi.

La persona con cui ho parlato dice che la loro precisione è di un secondo: è sufficiente per loro, perché dipende dal tipo di dati memorizzati nel database delle serie temporali. Se si tratta di dati DevOps o dati sull'infrastruttura, dove li raccogli con un intervallo di 30 secondi o un minuto, allora un secondo di precisione è sufficiente, meno non è necessario. Ma se raccogli questi dati dai sistemi di trading ad alta frequenza, allora è necessaria la precisione in nanosecondi.

La precisione in millisecondi in VictoriaMetrics è adatta sia per i casi DevOps, sia per la maggior parte dei casi che ho menzionato all'inizio della relazione. L'unico caso per cui potrebbe non essere adatta è per i sistemi di trading ad alta frequenza.

Grazie! E un'altra domanda. Qual è la compatibilità con PromQL?

Compatibilità totale. VictoriaMetrics supporta completamente PromQL. Inoltre, aggiunge anche un'ulteriore funzionalità avanzata a PromQL, chiamata MetricsQL. Riguardo a questa funzionalità avanzata, c'è una relazione su YouTube. Ho parlato al Monitoring Meetup in primavera a San Pietroburgo.

canale Telegram VictoriaMetrics.

Solo gli utenti registrati possono partecipare al sondaggio. Accedi, per favore.

Cosa ti impedisce di passare a VictoriaMetrics come archivio a lungo termine per Prometheus? (Scrivi nei commenti, aggiungerò al sondaggio))

  • 71,4%Non utilizzo Prometheus5

  • 28,6%Non sapevo di VictoriaMetrics2

Hanno votato 7 utenti. 12 utenti si sono astenuti.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS 🔥 Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS | ProHoster