Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ti suggerisco di leggere la trascrizione del rapporto di fine 2019 di Alexander Valyalkin “Go optimizations in VictoriaMetrics”

VictoriaMetrics — un DBMS veloce e scalabile per l'archiviazione e l'elaborazione dei dati sotto forma di serie temporali (il record forma l'ora e un insieme di valori corrispondenti a questo tempo, ad esempio, ottenuti mediante interrogazione periodica dello stato dei sensori o raccolta di metrica).

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ecco un collegamento al video di questo rapporto - https://youtu.be/MZ5P21j_HLE

Slides

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Parlaci di te. Sono Alexander Valyalkin. Qui il mio account GitHub. Sono appassionato di Go e dell'ottimizzazione delle prestazioni. Ho scritto molte librerie utili e non così utili. Iniziano con entrambi fasto con quick prefisso.

Attualmente sto lavorando su VictoriaMetrics. Cos'è e cosa ci faccio lì? Ne parlerò in questa presentazione.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Lo schema del rapporto è il seguente:

  • Innanzitutto, ti dirò cos'è VictoriaMetrics.
  • Poi ti dirò cosa sono le serie temporali.
  • Poi ti dirò come funziona un database di serie temporali.
  • Successivamente ti parlerò dell’architettura del database: in cosa consiste.
  • E poi passiamo alle ottimizzazioni di cui dispone VictoriaMetrics. Si tratta di un'ottimizzazione per l'indice invertito e di un'ottimizzazione per l'implementazione del bitset in Go.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Qualcuno tra il pubblico sa cos'è VictoriaMetrics? Wow, molti lo sanno già. È una buona notizia Per coloro che non lo sanno, questo è un database di serie temporali. Si basa sull'architettura ClickHouse, su alcuni dettagli dell'implementazione ClickHouse. Ad esempio, su: MergeTree, calcolo parallelo su tutti i core del processore disponibili e ottimizzazione delle prestazioni lavorando sui blocchi di dati che vengono inseriti nella cache del processore.

VictoriaMetrics fornisce una migliore compressione dei dati rispetto ad altri database di serie temporali.

Si ridimensiona verticalmente, ovvero puoi aggiungere più processori e più RAM su un computer. VictoriaMetrics utilizzerà con successo queste risorse disponibili e migliorerà la produttività lineare.

VictoriaMetrics è scalabile anche orizzontalmente, ovvero puoi aggiungere ulteriori nodi al cluster VictoriaMetrics e le sue prestazioni aumenteranno in modo quasi lineare.

Come hai intuito, VictoriaMetrics è un database veloce, perché non posso scriverne altri. Ed è scritto in Go, quindi ne parlerò in questo incontro.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Chi sa cos'è una serie temporale? Conosce anche molte persone. Una serie temporale è una serie di coppie (timestamp, значение), dove queste coppie sono ordinate in base al tempo. Il valore è un numero in virgola mobile – float64.

Ogni serie temporale è identificata univocamente da una chiave. In cosa consiste questa chiave? Consiste in 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" - questo è il computer su cui viene raccolta questa metrica, datacenter="us-east" - questo è il data center in cui si trova questo computer.

Alla fine abbiamo ottenuto un nome di serie temporale composto da tre coppie chiave-valore. Questa chiave corrisponde ad un elenco di coppie (timestamp, value). t1, t3, t3, ..., tN - questi sono timestamp, 10, 20, 12, ..., 15 — i valori corrispondenti. Questo è l'utilizzo della CPU in un dato momento per una determinata serie.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Dove possono essere utilizzate le serie temporali? Qualcuno ha qualche idea?

  • In DevOps puoi misurare CPU, RAM, rete, rps, numero di errori, ecc.
  • IoT: possiamo misurare temperatura, pressione, coordinate geografiche e qualcos'altro.
  • Anche finanza: possiamo monitorare i prezzi di tutti i tipi di azioni e 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.
  • Le serie temporali sono utili anche per raccogliere informazioni dai sensori di vari dispositivi. Ad esempio, per un motore; per misurare la pressione dei pneumatici; per misurare velocità, distanza; per misurare il consumo di benzina, ecc.
  • Le serie temporali possono essere utilizzate anche per monitorare gli aerei. Ogni aereo ha una scatola nera che raccoglie serie temporali per vari parametri di salute dell'aereo. Le serie temporali vengono utilizzate anche nel settore aerospaziale.
  • L’assistenza sanitaria è la pressione sanguigna, il polso, ecc.

Potrebbero esserci più applicazioni di cui mi ero dimenticato, ma spero che tu capisca che le serie temporali vengono utilizzate attivamente nel mondo moderno. E il volume del loro utilizzo cresce ogni anno.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Perché hai bisogno di un database di serie temporali? Perché non è possibile utilizzare un normale database relazionale per archiviare le serie temporali?

Perché le serie temporali contengono solitamente una grande quantità di informazioni, che è difficile da archiviare ed elaborare nei database convenzionali. Pertanto sono comparsi database specializzati per le serie temporali. Queste basi memorizzano effettivamente i punti (timestamp, value) con la chiave data. Forniscono un'API per leggere i dati archiviati per chiave, per una singola coppia di valori-chiave o per più coppie di valori-chiave o per regexp. Ad esempio, se desideri trovare il carico della CPU di tutti i tuoi servizi in un data center in America, devi utilizzare questa pseudo-query.

In genere i database delle serie temporali forniscono linguaggi di query specializzati perché l'SQL delle serie temporali non è molto adatto. Sebbene esistano database che supportano SQL, non è molto adatto. Linguaggi di query come PromQL, AfflussoQL, Flusso, Q. Spero che qualcuno abbia sentito almeno una di queste lingue. Molte persone probabilmente hanno sentito parlare di PromQL. Questo è il linguaggio di query di Prometheus.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ecco come appare una moderna architettura di database di serie temporali utilizzando VictoriaMetrics come esempio.

Si compone di due parti. Si tratta dell'archiviazione per l'indice invertito e dell'archiviazione per i valori delle serie temporali. Questi repository sono separati.

Quando un nuovo record arriva nel database, accediamo prima all'indice invertito per trovare l'identificatore della serie temporale per un dato insieme label=value per una determinata metrica. Troviamo questo identificatore e salviamo il valore nell'archivio dati.

Quando arriva una richiesta per recuperare dati da TSDB, andiamo prima all'indice invertito. Prendiamo tutto timeseries_ids record che corrispondono a questo set label=value. E poi otteniamo tutti i dati necessari dal data warehouse, indicizzati da timeseries_ids.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Diamo un'occhiata a un esempio di come un database di serie temporali elabora una query di selezione in entrata.

  • Prima di tutto ottiene tutto timeseries_ids da un indice invertito che contiene le coppie indicate label=valueo soddisfare una determinata espressione regolare.
  • Quindi recupera tutti i punti dati dall'archivio dati in un dato intervallo di tempo per quelli trovati timeseries_ids.
  • Successivamente, il database esegue alcuni calcoli su questi punti dati, secondo la richiesta dell'utente. E dopo restituisce la risposta.

In questa presentazione vi parlerò della prima parte. Questa è una ricerca timeseries_ids mediante indice invertito. Potrai guardare la seconda e la terza parte più tardi Fonti VictoriaMetrics, oppure aspetta finché non preparo altri report :)

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Passiamo all'indice invertito. Molti potrebbero pensare che sia semplice. Chi sa cos'è un indice invertito e come funziona? Oh, non più così tante persone. Cerchiamo di capire di cosa si tratta.

In realtà è semplice. È semplicemente un dizionario che mappa una chiave su un valore. Cos'è una chiave? Questa coppia label=valueDove label и value - queste sono linee. E i valori sono un insieme timeseries_ids, che include la coppia data label=value.

L'indice invertito ti consente di trovare rapidamente tutto timeseries_ids, che hanno dato label=value.

Ti consente anche di trovare rapidamente timeseries_ids serie temporali per più coppie label=valueo per le coppie label=regexp. Come avviene questo? Trovando l'intersezione dell'insieme timeseries_ids per ogni coppia label=value.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Diamo un'occhiata alle varie implementazioni dell'indice invertito. Cominciamo con l'implementazione ingenua più semplice. Sembra così.

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

Come funziona? Qui abbiamo una variabile globale chiamata invertedIndex. Questo è un normale dizionario (map), che mapperà la stringa per suddividere gli int. La riga contiene label=value.

Implementazione della funzione: get metricIDs per la prima label=value, poi esamineremo tutto il resto label=value, ce l'abbiamo metricIDs per loro. E chiama la funzione intersectInts, di cui si parlerà di seguito. E questa funzione restituisce l'intersezione di questi elenchi.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Come puoi vedere, implementare un indice invertito non è molto complicato. Ma questa è un'implementazione ingenua. Quali svantaggi presenta? Lo svantaggio principale dell'implementazione ingenua è che tale indice invertito viene archiviato nella RAM. Dopo aver riavviato l'applicazione perdiamo questo indice. Non è possibile salvare questo indice su disco. È improbabile che un indice così invertito sia adatto per un database.

Anche il secondo inconveniente è legato alla memoria. L'indice invertito deve rientrare nella RAM. Se supera la dimensione della RAM, ovviamente otterremo un errore di memoria insufficiente. E il programma non funzionerà.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Questo problema può essere risolto utilizzando soluzioni già pronte come Livello DBO RocksDB.

Insomma, abbiamo bisogno di un database che ci permetta di fare tre operazioni velocemente.

  • La prima operazione è la registrazione ключ-значение a questo database. Lo fa molto velocemente, dove ключ-значение sono stringhe arbitrarie.
  • La seconda operazione è una ricerca rapida di un valore utilizzando una determinata chiave.
  • E la terza operazione è una rapida ricerca di tutti i valori​​ tramite un determinato prefisso.

LevelDB e RocksDB: questi database sono stati sviluppati da Google e Facebook. Per primo è arrivato LevelDB. Poi i ragazzi di Facebook hanno preso LevelDB e hanno iniziato a migliorarlo, hanno creato RocksDB. Ora quasi tutti i database interni funzionano su RocksDB all'interno di Facebook, compresi quelli che sono stati trasferiti su RocksDB e MySQL. Gli hanno dato un nome MyRocks.

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

Se abbiamo molte serie temporali con una determinata coppia label=value, allora ci saranno molte righe in questo database con la stessa chiave e diverse timeseries_ids. Per ottenere un elenco di tutti timeseries_ids, che iniziano con questo label=prefix, eseguiamo una scansione dell'intervallo per il quale questo database è ottimizzato. Cioè, selezioniamo tutte le righe che iniziano con label=prefix e procurati il ​​necessario timeseries_ids.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ecco un esempio di implementazione di come apparirebbe in Go. Abbiamo un indice invertito. Questo è LevelDB.

La funzione è la stessa dell'implementazione naive. Ripete l'implementazione ingenua quasi riga per riga. L'unico punto è che invece di rivolgersi a map accediamo all'indice invertito. Otteniamo tutti i valori per il primo label=value. Quindi esaminiamo tutte le coppie rimanenti label=value e ottenere i relativi set di metricID corrispondenti. Poi troviamo l'intersezione.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Sembra che tutto vada bene, ma questa soluzione presenta degli svantaggi. VictoriaMetrics inizialmente ha implementato un indice invertito basato su LevelDB. Ma alla fine ho dovuto rinunciare.

Perché? Perché LevelDB è più lento dell'implementazione ingenua. In un'implementazione naive, data una determinata chiave, recuperiamo immediatamente l'intera fetta metricIDs. Si tratta di un'operazione molto veloce: l'intera fetta è pronta per l'uso.

In LevelDB, ogni volta che viene chiamata una funzione GetValues devi seguire tutte le righe che iniziano con label=value. E ottieni il valore per ogni riga timeseries_ids. Di tale timeseries_ids raccogli una fetta di questi timeseries_ids. Ovviamente, questo è molto più lento del semplice accesso a una normale mappa tramite chiave.

Il secondo inconveniente è che LevelDB è scritto in C. Chiamare le funzioni C da Go non è molto veloce. Ci vogliono centinaia di nanosecondi. Questo non è molto veloce, perché rispetto a una normale chiamata di funzione scritta in go, che impiega 1-5 nanosecondi, la differenza di prestazioni è decine di volte. Per VictoriaMetrics questo è stato un difetto fatale :)

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Quindi ho scritto la mia implementazione dell'indice invertito. E lui l'ha chiamata mergeset.

Mergeset si basa sulla struttura dati MergeTree. Questa struttura dati è presa in prestito da ClickHouse. Ovviamente, il mergeset dovrebbe essere ottimizzato per la ricerca veloce timeseries_ids secondo la chiave data. Mergeset è scritto interamente in Go. Puoi vedere Fonti VictoriaMetrics su GitHub. L'implementazione di mergeset si trova nella cartella /lib/mergeset. Puoi provare a capire cosa sta succedendo lì.

L'API mergeset è molto simile a LevelDB e RocksDB. Cioè, ti consente di salvare rapidamente nuovi record lì e selezionare rapidamente i record in base a un determinato prefisso.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Parleremo degli svantaggi del mergeset più tardi. Ora parliamo di quali problemi sono sorti con VictoriaMetrics in produzione durante l'implementazione di un indice invertito.

Perché sono sorti?

Il primo motivo è l’elevato tasso di abbandono. Tradotto in russo, questo è un cambiamento frequente nelle serie temporali. Questo è il momento in cui una serie temporale termina e ne inizia una nuova o iniziano molte nuove serie temporali. E questo accade spesso.

La seconda ragione è l’elevato numero di serie temporali. All’inizio, quando il monitoraggio stava guadagnando popolarità, il numero di serie temporali era piccolo. Ad esempio, per ciascun computer è necessario monitorare il carico della CPU, della memoria, della rete e del disco. 4 serie temporali per computer. Supponiamo che tu abbia 100 computer e 400 serie temporali. Questo è molto poco.

Nel corso del tempo, le persone hanno capito che potevano misurare informazioni più granulari. Ad esempio, misurare il carico non dell'intero processore, ma separatamente di ciascun core del processore. Se disponi di 40 core del processore, hai a disposizione 40 volte più serie temporali per misurare il carico del processore.

Ma non è tutto. Ciascun core del processore può avere diversi stati, ad esempio inattivo, quando è inattivo. E lavora anche nello spazio utente, lavora nello spazio del kernel e in altri stati. E ciascuno di questi stati può anche essere misurato come una serie temporale separata. Ciò aumenta ulteriormente il numero di righe di 7-8 volte.

Da una metrica abbiamo ottenuto 40 x 8 = 320 metriche per un solo computer. Moltiplicando per 100 otteniamo 32 invece di 000.

Poi è arrivato Kubernetes. E le cose sono peggiorate perché Kubernetes può ospitare molti servizi diversi. Ogni servizio in Kubernetes è costituito da molti pod. E tutto questo deve essere monitorato. Inoltre, disponiamo costantemente di nuove versioni dei vostri servizi. Per ogni nuova versione è necessario creare nuove serie temporali. Di conseguenza, il numero di serie temporali cresce in modo esponenziale e ci troviamo di fronte al problema di un gran numero di serie temporali, chiamato ad alta cardinalità. VictoriaMetrics affronta questo problema con successo rispetto ad altri database di serie temporali.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Diamo uno sguardo più da vicino all'elevato tasso di abbandono. Cosa causa un elevato tasso di abbandono della produzione? Perché alcuni significati di etichette e tag cambiano costantemente.

Ad esempio, prendi Kubernetes, che ha il concetto deployment, ovvero quando viene lanciata una nuova versione della tua applicazione. Per qualche motivo, gli sviluppatori Kubernetes hanno deciso di aggiungere l'ID di distribuzione all'etichetta.

A cosa ha portato questo? Inoltre, con ogni nuova distribuzione, tutte le vecchie serie temporali vengono interrotte e al loro posto iniziano nuove serie temporali con un nuovo valore di etichetta deployment_id. Possono esserci centinaia di migliaia e persino milioni di tali righe.

La cosa importante in tutto questo è che il numero totale di serie temporali cresce, ma il numero di serie temporali attualmente attive e che ricevono dati rimane costante. Questo stato è chiamato tasso di abbandono elevato.

Il problema principale dell'elevato tasso di abbandono è garantire una velocità di ricerca costante per tutte le serie temporali per un dato insieme di etichette in un determinato intervallo di tempo. In genere questo è l'intervallo di tempo per l'ultima ora o l'ultimo giorno.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Come risolvere questo problema? Ecco la prima opzione. Questo per dividere l'indice invertito in parti indipendenti nel tempo. Cioè, trascorso un certo intervallo di tempo, finiamo di lavorare con l'indice invertito corrente. E crea un nuovo indice invertito. Passa un altro intervallo di tempo, ne creiamo un altro e un altro ancora.

E quando si campiona da questi indici invertiti, troviamo una serie di indici invertiti che rientrano nell'intervallo dato. E, di conseguenza, selezioniamo da lì l'ID della serie temporale.

Ciò consente di risparmiare risorse perché non dobbiamo guardare le parti che non rientrano nell'intervallo specificato. Cioè, di solito, se selezioniamo i dati dell'ultima ora, per gli intervalli di tempo precedenti saltiamo le richieste.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

C'è un'altra opzione per risolvere questo problema. Questo serve per memorizzare per ogni giorno un elenco separato di ID delle serie temporali che si sono verificate in quel giorno.

Il vantaggio di questa soluzione rispetto alla soluzione precedente è che non duplichiamo le informazioni delle serie temporali che non scompaiono nel tempo. Sono costantemente presenti e non cambiano.

Lo svantaggio è che tale soluzione è più difficile da implementare e da eseguire il debug. E VictoriaMetrics ha scelto questa soluzione. Così è avvenuto storicamente. Anche questa soluzione si comporta bene rispetto alla precedente. Perché questa soluzione non è stata implementata perché è necessario duplicare i dati in ogni partizione per serie temporali che non cambiano, cioè che non scompaiono nel tempo. VictoriaMetrics è stato ottimizzato principalmente per il consumo di spazio su disco e l'implementazione precedente ha peggiorato il consumo di spazio su disco. Ma questa implementazione è più adatta a ridurre al minimo il consumo di spazio su disco, quindi è stata scelta.

Ho dovuto combatterla. La difficoltà era che in questa implementazione era ancora necessario scegliere un numero molto più grande timeseries_ids per i dati rispetto a quando l'indice invertito è partizionato nel tempo.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Come abbiamo risolto questo problema? Abbiamo risolto il problema in modo originale, memorizzando diversi identificatori di serie temporali in ciascuna voce dell'indice invertito invece di un identificatore. Cioè, abbiamo una chiave label=value, che si verifica in ogni serie temporale. E ora ne salviamo diversi timeseries_ids in una voce.

Ecco un esempio. Prima avevamo N voci, ora abbiamo una voce il cui prefisso è uguale a tutte le altre. Per la voce precedente, il valore contiene tutti gli ID delle serie temporali.

Ciò ha permesso di aumentare la velocità di scansione di un indice così invertito fino a 10 volte. E ci ha permesso di ridurre il consumo di memoria per la cache, perché ora memorizziamo la stringa label=value solo una volta nella cache insieme N volte. E questa riga può essere grande se memorizzi righe lunghe nei tag e nelle etichette, che a Kubernetes piace inserire lì.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Un'altra opzione per velocizzare la ricerca su un indice invertito è lo sharding. Creazione di diversi indici invertiti invece di uno e condivisione dei dati tra di loro tramite chiave. Questo è un insieme key=value vapore. Otteniamo cioè diversi indici invertiti indipendenti, che possiamo interrogare in parallelo su più processori. Le implementazioni precedenti consentivano solo il funzionamento in modalità processore singolo, ovvero la scansione dei dati su un solo core. Questa soluzione ti consente di scansionare i dati su più core contemporaneamente, come piace fare a ClickHouse. Questo è ciò che intendiamo implementare.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ora torniamo alle nostre pecore, alla funzione di intersezione timeseries_ids. Consideriamo quali implementazioni potrebbero esserci. Questa funzione ti consente di trovare timeseries_ids per un dato insieme label=value.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

La prima opzione è un'implementazione ingenua. Due cicli nidificati. Qui otteniamo l'input della funzione intersectInts due fette - a и b. In uscita dovrebbe restituirci l'intersezione di queste fette.

Un'implementazione ingenua assomiglia a questa. Iteriamo su tutti i valori da slice a, all'interno di questo ciclo esaminiamo tutti i valori di slice b. E li confrontiamo. Se corrispondono, abbiamo trovato un'intersezione. E salvalo result.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Quali sono gli svantaggi? La complessità quadratica è il suo principale svantaggio. Ad esempio, se le tue dimensioni sono slice a и b un milione alla volta, questa funzione non ti restituirà mai una risposta. Perché dovrà fare un trilione di iterazioni, che è molto anche per i computer moderni.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

La seconda implementazione è basata sulla mappa. Creiamo la mappa. Inseriamo tutti i valori di slice in questa mappa a. Quindi esaminiamo la sezione in un ciclo separato b. E controlliamo se questo valore proviene da slice b nella mappa. Se esiste, aggiungilo al risultato.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Quali sono i vantaggi? Il vantaggio è che c’è solo complessità lineare. Cioè, la funzione verrà eseguita molto più velocemente per le sezioni più grandi. Per una fetta di un milione di dimensioni, questa funzione verrà eseguita in 2 milioni di iterazioni, rispetto ai trilioni di iterazioni della funzione precedente.

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

Il secondo inconveniente è il grande sovraccarico per l'hashing. Questo inconveniente non è molto evidente. E anche per noi non era molto ovvio, quindi all'inizio in VictoriaMetrics l'implementazione dell'intersezione avveniva attraverso una mappa. Ma poi la profilazione ha mostrato che il tempo del processore principale viene impiegato scrivendo sulla mappa e controllando la presenza di un valore in questa mappa.

Perché il tempo della CPU viene sprecato in questi luoghi? Perché Go esegue un'operazione di hashing su queste righe. Cioè calcola l'hash della chiave per poi accedervi in ​​un dato indice nell'HashMap. L’operazione di calcolo dell’hash si completa in decine di nanosecondi. Questo è lento per VictoriaMetrics.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ho deciso di implementare un bitset ottimizzato appositamente per questo caso. Ecco come appare ora l'intersezione di due fette. Qui creiamo un bitset. Aggiungiamo ad esso gli elementi della prima fetta. Quindi controlliamo la presenza di questi elementi nella seconda fetta. E aggiungili al risultato. Cioè, non è quasi diverso dall'esempio precedente. L'unica cosa qui è che abbiamo sostituito l'accesso alla mappa con funzioni personalizzate add и has.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

A prima vista, sembra che questo dovrebbe funzionare più lentamente, se in precedenza veniva utilizzata una mappa standard, e poi venivano chiamate alcune altre funzioni, ma la profilazione mostra che questa cosa funziona 10 volte più velocemente della mappa standard nel caso di VictoriaMetrics.

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

Lo svantaggio di questa implementazione è che non è così ovvia, né banale.

Un altro svantaggio che molti potrebbero non notare è che in alcuni casi questa implementazione potrebbe non funzionare bene. Cioè, è ottimizzato per un caso specifico, per questo caso di intersezione degli ID delle serie temporali VictoriaMetrics. Ciò non significa che sia adatto a tutti i casi. Se viene utilizzato in modo errato non otterremo un aumento delle prestazioni, ma un errore di memoria insufficiente e un rallentamento delle prestazioni.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Consideriamo l'implementazione di questa struttura. Se vuoi guardare, si trova nei sorgenti VictoriaMetrics, nella cartella lib/uint64set. È ottimizzato specificamente per il caso VictoriaMetrics, dove timeseries_id è un valore a 64 bit, in cui i primi 32 bit sono sostanzialmente costanti e cambiano solo gli ultimi 32 bit.

Questa struttura dati non è archiviata su disco, funziona solo in memoria.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ecco la sua API. Non è molto complicato. L'API è adattata specificamente a un esempio specifico di utilizzo di VictoriaMetrics. Cioè, non ci sono funzioni non necessarie qui. Ecco le funzioni utilizzate esplicitamente da VictoriaMetrics.

Ci sono funzioni add, che aggiunge nuovi valori. C'è una funzione has, che verifica la presenza di nuovi valori. E c'è una funzione del, che rimuove i valori. C'è una funzione di aiuto len, che restituisce la dimensione del set. Funzione clone clona molto. E funzione appendto converte questo set in slice timeseries_ids.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ecco come appare l'implementazione di questa struttura dati. il set ha due elementi:

  • ItemsCount è un campo di supporto per restituire rapidamente il numero di elementi in un set. Sarebbe possibile fare a meno di questo campo ausiliario, ma è stato necessario aggiungerlo qui perché VictoriaMetrics spesso interroga la lunghezza del bitset nei suoi algoritmi.

  • Il secondo campo è buckets. Questa è una fetta della struttura bucket32. Ogni struttura memorizza hi campo. Questi sono i 32 bit superiori. E due fette - b16his и buckets di bucket16 strutture.

Qui vengono memorizzati i primi 16 bit della seconda parte della struttura a 64 bit. E qui vengono memorizzati i bitset per i 16 bit inferiori di ciascun byte.

Bucket64 è costituito da un array uint64. La lunghezza viene calcolata utilizzando queste costanti. In uno bucket16 massimo può essere memorizzato 2^16=65536 morso. Se lo dividi per 8, saranno 8 kilobyte. Se dividi nuovamente per 8, ottieni 1000 uint64 Senso. Questo è Bucket16 – questa è la nostra struttura da 8 kilobyte.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Diamo un'occhiata a come viene implementato uno dei metodi di questa struttura per aggiungere un nuovo valore.

Tutto inizia con uint64 significati. Calcoliamo i 32 bit superiori, calcoliamo i 32 bit inferiori. Esaminiamo tutto buckets. Confrontiamo i primi 32 bit in ciascun bucket con il valore aggiunto. E se corrispondono, chiamiamo la funzione add nella struttura b32 buckets. E aggiungi lì i 32 bit inferiori. E se tornasse true, allora questo significa che abbiamo aggiunto un tale valore lì e non avevamo un tale valore. Se ritorna false, allora un tale significato esisteva già. Quindi aumentiamo il numero di elementi nella struttura.

Se non abbiamo trovato quello di cui hai bisogno bucket con il valore elevato richiesto, quindi chiamiamo la funzione addAlloc, che ne produrrà uno nuovo bucket, aggiungendolo alla struttura del bucket.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Questa è l'implementazione della funzione b32.add. È simile all'implementazione precedente. Calcoliamo i 16 bit più significativi, i 16 bit meno significativi.

Quindi esaminiamo tutti i 16 bit superiori. Troviamo corrispondenze. E se c'è una corrispondenza, chiamiamo il metodo add, di cui parleremo nella pagina successiva bucket16.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ed ecco il livello più basso, che dovrebbe essere ottimizzato il più possibile. Calcoliamo per uint64 valore id nel bit slice e anche bitmask. Questa è una maschera per un dato valore a 64 bit, che può essere utilizzata per verificare la presenza di questo bit o impostarlo. Controlliamo se questo bit è impostato, lo impostiamo e restituiamo la presenza. Questa è la nostra implementazione, che ci ha permesso di velocizzare l'operazione di intersezione degli ID delle serie temporali di 10 volte rispetto alle mappe convenzionali.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Oltre a questa ottimizzazione, VictoriaMetrics ha molte altre ottimizzazioni. La maggior parte di queste ottimizzazioni sono state aggiunte per un motivo preciso, ma dopo aver profilato il codice in produzione.

Questa è la regola principale dell'ottimizzazione: non aggiungere l'ottimizzazione dando per scontato che ci sarà un collo di bottiglia qui, perché potrebbe risultare che non ci sarà un collo di bottiglia lì. L'ottimizzazione di solito degrada la qualità del codice. Vale quindi la pena ottimizzare solo dopo la profilazione e preferibilmente in produzione, in modo che si tratti di dati reali. Se qualcuno è interessato, può guardare il codice sorgente VictoriaMetrics ed esplorare altre ottimizzazioni presenti.

Vai alle ottimizzazioni in VictoriaMetrics. Alexander Valalkin

Ho una domanda sul bitset. Molto simile all'implementazione bool vettoriale C++, bitset ottimizzato. Hai preso l'implementazione da lì?

No, non da lì. Durante l'implementazione di questo bitset, sono stato guidato dalla conoscenza della struttura di queste serie temporali ID, utilizzate in VictoriaMetrics. E la loro struttura è tale che i 32 bit superiori sono sostanzialmente costanti. I 32 bit inferiori sono soggetti a modifiche. Più basso è il bit, più spesso può cambiare. Pertanto, questa implementazione è specificamente ottimizzata per questa struttura dati. L'implementazione C++, per quanto ne so, è ottimizzata per il caso generale. Se ottimizzi per il caso generale, significa che non sarà il più ottimale per un caso specifico.

Ti consiglio anche di guardare il rapporto di Alexey Milovid. Circa un mese fa ha parlato di ottimizzazione in ClickHouse per specializzazioni specifiche. Dice semplicemente che, nel caso generale, un'implementazione C++ o qualche altra implementazione è adattata per funzionare bene in media in un ospedale. Potrebbe funzionare peggio di un'implementazione specifica della conoscenza come la nostra, dove sappiamo che i primi 32 bit sono per lo più costanti.

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

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

La seconda differenza fondamentale è che InfluxDB ha strani linguaggi di query: Flux e InfluxQL. Non sono molto convenienti per lavorare con le serie temporali rispetto a PromQL, che è supportato da VictoriaMetrics. PromQL è un linguaggio di query di Prometheus.

E un'altra differenza è che InfluxDB ha un modello di dati un po' strano, in cui ogni riga può memorizzare diversi campi con un diverso set di tag. Tali righe sono ulteriormente suddivise in varie tabelle. Queste ulteriori complicazioni complicano il lavoro successivo con questo database. È difficile da sostenere e comprendere.

In VictoriaMetrics tutto è molto più semplice. Lì, ogni serie temporale è un valore-chiave. Il valore è un insieme di punti - (timestamp, value), e la chiave è il set label=value. Non esiste separazione tra campi e misurazioni. Ti consente di selezionare qualsiasi dato e quindi combinare, aggiungere, sottrarre, moltiplicare, dividere, a differenza di InfluxDB dove i calcoli tra righe diverse non sono ancora implementati, per quanto ne so. Anche se vengono implementati, è difficile, devi scrivere molto codice.

Ho una domanda chiarificatrice. Ho capito bene che c'era qualche tipo di problema di cui hai parlato, che questo indice invertito non si adatta alla memoria, quindi lì c'è il partizionamento?

Innanzitutto, ho mostrato un'implementazione ingenua di un indice invertito su una mappa Go standard. Questa implementazione non è adatta ai database perché questo indice invertito non viene salvato su disco e il database deve salvarlo su disco in modo che questi dati rimangano disponibili al riavvio. In questa implementazione, quando riavvii l'applicazione, l'indice invertito scomparirà. E perderai l'accesso a tutti i dati perché non sarai in grado di trovarli.

Ciao! Grazie per la segnalazione! Mi chiamo Paolo. Vengo da Wildberry. Ho un po 'di domande per te. Domanda uno. Pensi che se avessi scelto un principio diverso quando costruivi l'architettura della tua applicazione e avessi partizionato i dati nel tempo, forse saresti stato in grado di intersecare i dati durante la ricerca, basandoti solo sul fatto che una partizione contiene dati per uno? periodo di tempo, cioè in un intervallo di tempo e non dovresti preoccuparti del fatto che i tuoi pezzi siano sparsi in modo diverso? Domanda numero 2: poiché stai implementando un algoritmo simile con bitset e tutto il resto, forse hai provato a utilizzare le istruzioni del processore? Forse hai provato tali ottimizzazioni?

Rispondo subito alla seconda. Non siamo ancora arrivati ​​a quel punto. Ma se necessario, ci arriveremo. E la prima: qual era la domanda?

Hai discusso due scenari. E hanno detto di aver scelto la seconda con un'implementazione più complessa. E non hanno preferito la prima, in cui i dati sono suddivisi in base al tempo.

SÌ. Nel primo caso, il volume totale dell'indice sarebbe maggiore, perché in ciascuna partizione dovremmo archiviare dati duplicati per le serie temporali che continuano attraverso tutte queste partizioni. E se il tasso di abbandono delle serie temporali è piccolo, ovvero vengono utilizzate costantemente le stesse serie, nel primo caso perderemmo molto di più nella quantità di spazio su disco occupato rispetto al secondo caso.

E quindi sì, il partizionamento del tempo è una buona opzione. Prometeo lo usa. Ma Prometeo ha un altro inconveniente. Quando si uniscono questi dati, è necessario mantenere in memoria le metainformazioni per tutte le etichette e le serie temporali. Pertanto, se i dati che unisce sono grandi, il consumo di memoria aumenta molto durante l'unione, a differenza di VictoriaMetrics. Durante l'unione, VictoriaMetrics non consuma memoria; vengono consumati solo un paio di kilobyte, indipendentemente dalla dimensione dei dati uniti.

L'algoritmo che stai utilizzando utilizza la memoria. Contrassegna i tag delle serie temporali che contengono valori. E in questo modo controlli la presenza accoppiata in un array di dati e in un altro. E capisci se l'intersezione è avvenuta o meno. In genere, i database implementano cursori e iteratori che memorizzano il contenuto corrente ed eseguono i dati ordinati a causa della semplice complessità di queste operazioni.

Perché non usiamo i cursori per attraversare i dati?

Sì.

Archiviamo le righe ordinate in LevelDB o mergeset. Possiamo spostare il cursore e trovare l'intersezione. Perché non lo usiamo? Perché è lento. Perché i cursori significano che devi chiamare una funzione per ogni riga. Una chiamata di funzione dura 5 nanosecondi. E se hai 100 di righe, risulta che impieghiamo mezzo secondo solo per chiamare la funzione.

Esiste una cosa del genere, sì. E la mia ultima domanda. La domanda può sembrare un po’ strana. Perché non è possibile leggere tutti gli aggregati necessari nel momento in cui arrivano i dati e salvarli nella forma richiesta? Perché risparmiare enormi volumi in alcuni sistemi come VictoriaMetrics, ClickHouse, ecc., e poi dedicarci molto tempo?

Faccio un esempio per renderlo più chiaro. Diciamo come funziona un piccolo tachimetro giocattolo? Registra la distanza percorsa, aggiungendola continuamente a un valore e al secondo tempo. E divide. E ottiene una velocità media. Puoi fare più o meno la stessa cosa. Somma tutti i fatti necessari al volo.

Ok, capisco la domanda. Il tuo esempio ha il suo posto. Se sai di quali aggregati hai bisogno, questa è la migliore implementazione. Ma il problema è che le persone salvano queste metriche, alcuni dati in ClickHouse e non sanno ancora come aggregarli e filtrarli in futuro, quindi devono salvare tutti i dati grezzi. Ma se sai che devi calcolare qualcosa in media, allora perché non calcolarlo invece di archiviare lì un mucchio di valori grezzi? Ma questo è solo se sai esattamente di cosa hai bisogno.

A proposito, i database per l'archiviazione delle serie temporali supportano il conteggio degli aggregati. Ad esempio, Prometeo sostiene regole di registrazione. Cioè, questo può essere fatto se sai di quali unità avrai bisogno. VictoriaMetrics non dispone ancora di questo, ma di solito è preceduto da Prometheus, in cui ciò può essere fatto nelle regole di ricodifica.

Ad esempio, nel mio lavoro precedente avevo bisogno di contare il numero di eventi in una finestra scorrevole nell'ultima ora. Il problema è che ho dovuto realizzare un'implementazione personalizzata in Go, ovvero un servizio per contare questa cosa. Questo servizio in definitiva non era banale, perché difficile da calcolare. L'implementazione può essere semplice se è necessario contare alcuni aggregati a intervalli di tempo fissi. Se vuoi contare gli eventi in una finestra scorrevole, non è così semplice come sembra. Penso che questo non sia stato ancora implementato in ClickHouse o nei database di serie temporali, perché è difficile da implementare.

E un'altra domanda. Stavamo solo parlando della media e mi sono ricordato che una volta esisteva qualcosa come Graphite con un backend in carbonio. E sapeva come sfoltire i vecchi dati, cioè lasciare un punto al minuto, un punto all'ora, ecc. In linea di principio, questo è abbastanza conveniente se abbiamo bisogno di dati grezzi, relativamente parlando, per un mese, e tutto il resto può essere diluito. Ma Prometheus e VictoriaMetrics non supportano questa funzionalità. È previsto un sostegno? Se no, perché no?

Grazie per la domanda I nostri utenti pongono periodicamente questa domanda. Chiedono quando aggiungeremo il supporto per il downsampling. Ci sono diversi problemi qui. Innanzitutto, ogni utente capisce downsampling qualcosa di diverso: qualcuno vuole ottenere qualsiasi punto arbitrario su un dato intervallo, qualcuno vuole valori massimi, minimi e medi. Se molti sistemi scrivono dati nel tuo database, non puoi raggrupparli tutti insieme. Può darsi che ogni sistema richieda un diradamento diverso. E questo è difficile da implementare.

E la seconda cosa è che VictoriaMetrics, come ClickHouse, è ottimizzato per lavorare su grandi quantità di dati grezzi, quindi può spalare un miliardo di righe in meno di un secondo se hai molti core nel tuo sistema. Scansione di punti di serie temporali in VictoriaMetrics: 50 di punti al secondo per core. E queste prestazioni si adattano ai core esistenti. Cioè, se hai 000 core, ad esempio, scansionerai un miliardo di punti al secondo. E questa proprietà di VictoriaMetrics e ClickHouse riduce la necessità di downsamling.

Un'altra caratteristica è che VictoriaMetrics comprime efficacemente questi dati. La compressione media in produzione va da 0,4 a 0,8 byte per punto. Ogni punto è un timestamp + valore. Ed è compresso in media in meno di un byte.

Sergey. Ho una domanda. Qual è il tempo minimo di registrazione?

Un millisecondo. Recentemente abbiamo avuto una conversazione con altri sviluppatori di database di serie temporali. Il loro intervallo di tempo minimo è di un secondo. E in Graphite, ad esempio, è anche un secondo. In OpenTSDB è anche un secondo. InfluxDB ha una precisione al nanosecondo. In VictoriaMetrics è un millisecondo, perché in Prometheus è un millisecondo. E VictoriaMetrics è stato originariamente sviluppato come archivio remoto per Prometheus. Ma ora può salvare dati da altri sistemi.

La persona con cui ho parlato dice che hanno una precisione al secondo: per loro è sufficiente perché dipende dal tipo di dati archiviati nel database delle serie temporali. Se si tratta di dati DevOps o di dati provenienti dall'infrastruttura, dove li raccogli a intervalli di 30 secondi al minuto, la precisione al secondo è sufficiente, non ti serve niente di meno. E se raccogli questi dati da sistemi di trading ad alta frequenza, allora hai bisogno di una precisione al nanosecondo.

La precisione al millisecondo in VictoriaMetrics è adatta anche al caso DevOps e può essere adatta per la maggior parte dei casi che ho menzionato all'inizio del rapporto. L'unica cosa per la quale potrebbe non essere adatto sono i sistemi di trading ad alta frequenza.

Grazie! E un'altra domanda. Cos'è la compatibilità in PromQL?

Piena compatibilità con le versioni precedenti. VictoriaMetrics supporta completamente PromQL. Inoltre, aggiunge ulteriori funzionalità avanzate a PromQL, chiamate MetricheQL. Si parla su YouTube di questa funzionalità estesa. Ho parlato al Monitoring Meetup in primavera a San Pietroburgo.

Canale di Telegram VictoriaMetrics.

Solo gli utenti registrati possono partecipare al sondaggio. AccediPer favore.

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

  • 71,4%Non uso Prometeo5

  • 28,6%Non sapevo di VictoriaMetrics2

7 utenti hanno votato. 12 utenti si sono astenuti.

Fonte: habr.com

Aggiungi un commento