Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Je vous suggère de lire la transcription du rapport de fin 2019 d'Alexander Valyalkin « Go optimisations dans VictoriaMetrics »

VictoriaMetrics — un SGBD rapide et évolutif permettant de stocker et de traiter des données sous forme de séries temporelles (l'enregistrement forme le temps et un ensemble de valeurs correspondant à ce temps, par exemple, obtenu par interrogation périodique de l'état des capteurs ou collecte de métrique).

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Voici un lien vers la vidéo de ce rapport - https://youtu.be/MZ5P21j_HLE

diaporama

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Parlez nous de vous. Je m'appelle Alexandre Valyalkin. Ici mon compte GitHub. Je suis passionné par Go et l'optimisation des performances. J'ai écrit beaucoup de bibliothèques utiles et moins utiles. Ils commencent par soit fast, ou avec quick préfixe.

Je travaille actuellement sur VictoriaMetrics. Qu'est-ce que c'est et qu'est-ce que je fais là ? J'en parlerai dans cette présentation.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Les grandes lignes du rapport sont les suivantes :

  • Tout d’abord, je vais vous dire ce qu’est VictoriaMetrics.
  • Ensuite, je vais vous dire ce que sont les séries chronologiques.
  • Ensuite, je vais vous expliquer comment fonctionne une base de données de séries chronologiques.
  • Ensuite, je vais vous parler de l'architecture de la base de données : en quoi elle consiste.
  • Et puis passons aux optimisations dont dispose VictoriaMetrics. Il s'agit d'une optimisation pour l'index inversé et d'une optimisation pour l'implémentation du jeu de bits dans Go.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Est-ce que quelqu'un dans le public sait ce qu'est VictoriaMetrics ? Wow, beaucoup de gens le savent déjà. C'est une bonne nouvelle. Pour ceux qui ne le savent pas, il s’agit d’une base de données de séries chronologiques. Il est basé sur l'architecture ClickHouse, sur certains détails de l'implémentation ClickHouse. Par exemple, sur comme : MergeTree, calcul parallèle sur tous les coeurs de processeur disponibles et optimisation des performances en travaillant sur les blocs de données qui sont placés dans le cache du processeur.

VictoriaMetrics offre une meilleure compression des données que les autres bases de données de séries chronologiques.

Il évolue verticalement, c'est-à-dire que vous pouvez ajouter plus de processeurs et plus de RAM sur un ordinateur. VictoriaMetrics utilisera avec succès ces ressources disponibles et améliorera la productivité linéaire.

VictoriaMetrics évolue également horizontalement, c'est-à-dire que vous pouvez ajouter des nœuds supplémentaires au cluster VictoriaMetrics et ses performances augmenteront presque linéairement.

Comme vous l'avez deviné, VictoriaMetrics est une base de données rapide, car je ne peux pas en écrire d'autres. Et c’est écrit en Go, donc j’en parle lors de cette rencontre.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Qui sait ce qu’est une série chronologique ? Il connaît aussi beaucoup de monde. Une série temporelle est une série de paires (timestamp, значение), où ces paires sont triées par temps. La valeur est un nombre à virgule flottante – float64.

Chaque série chronologique est identifiée de manière unique par une clé. En quoi consiste cette clé ? Il consiste en un ensemble non vide de paires clé-valeur.

Voici un exemple de série chronologique. La clé de cette série est une liste de paires : __name__="cpu_usage" est le nom de la métrique, instance="my-server" - il s'agit de l'ordinateur sur lequel cette métrique est collectée, datacenter="us-east" - il s'agit du centre de données où se trouve cet ordinateur.

Nous nous sommes retrouvés avec un nom de série chronologique composé de trois paires clé-valeur. Cette clé correspond à une liste de paires (timestamp, value). t1, t3, t3, ..., tN - ce sont des horodatages, 10, 20, 12, ..., 15 — les valeurs correspondantes. Il s'agit de l'utilisation du processeur à un moment donné pour une série donnée.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Où les séries chronologiques peuvent-elles être utilisées ? Est-ce que quelqu'un a une idée?

  • Dans DevOps, vous pouvez mesurer le CPU, la RAM, le réseau, le rps, le nombre d'erreurs, etc.
  • IoT - nous pouvons mesurer la température, la pression, les coordonnées géographiques et autre chose.
  • Finance également – ​​nous pouvons surveiller les prix de toutes sortes d’actions et de devises.
  • De plus, les séries chronologiques peuvent être utilisées pour surveiller les processus de production dans les usines. Nous avons des utilisateurs qui utilisent VictoriaMetrics pour surveiller les éoliennes, pour les robots.
  • Les séries chronologiques sont également utiles pour collecter des informations provenant des capteurs de divers appareils. Par exemple, pour un moteur ; pour mesurer la pression des pneus ; pour mesurer la vitesse, la distance ; pour mesurer la consommation d'essence, etc.
  • Les séries chronologiques peuvent également être utilisées pour surveiller les avions. Chaque avion possède une boîte noire qui collecte des séries chronologiques pour divers paramètres de la santé de l'avion. Les séries chronologiques sont également utilisées dans l’industrie aérospatiale.
  • Les soins de santé concernent la tension artérielle, le pouls, etc.

Il y a peut-être d'autres applications que j'ai oubliées, mais j'espère que vous comprenez que les séries chronologiques sont activement utilisées dans le monde moderne. Et le volume de leur utilisation augmente chaque année.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Pourquoi avez-vous besoin d’une base de données de séries chronologiques ? Pourquoi ne pouvez-vous pas utiliser une base de données relationnelle classique pour stocker des séries chronologiques ?

Parce que les séries chronologiques contiennent généralement une grande quantité d’informations, difficiles à stocker et à traiter dans les bases de données conventionnelles. Par conséquent, des bases de données spécialisées pour les séries chronologiques sont apparues. Ces bases stockent efficacement des points (timestamp, value) avec la clé donnée. Ils fournissent une API pour lire les données stockées par clé, par une seule paire clé-valeur, ou par plusieurs paires clé-valeur, ou par expression rationnelle. Par exemple, vous souhaitez connaître la charge CPU de tous vos services dans un data center en Amérique, alors vous devez utiliser cette pseudo-requête.

Généralement, les bases de données de séries chronologiques fournissent des langages de requête spécialisés, car le SQL des séries chronologiques n'est pas très bien adapté. Bien qu'il existe des bases de données prenant en charge SQL, cela n'est pas très adapté. Langages de requête tels que PromQL, InfluxQL, Flux, Q. J'espère que quelqu'un a entendu au moins une de ces langues. Beaucoup de gens ont probablement entendu parler de PromQL. Il s'agit du langage de requête Prometheus.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Voici à quoi ressemble une architecture de base de données de séries chronologiques moderne en utilisant VictoriaMetrics comme exemple.

Il se compose de deux parties. Il s'agit du stockage de l'index inversé et du stockage des valeurs de séries chronologiques. Ces référentiels sont séparés.

Lorsqu'un nouvel enregistrement arrive dans la base de données, nous accédons d'abord à l'index inversé pour trouver l'identifiant de série chronologique pour un ensemble donné. label=value pour une métrique donnée. Nous trouvons cet identifiant et enregistrons la valeur dans le magasin de données.

Lorsqu'une requête vient pour récupérer des données de TSDB, nous passons d'abord à l'index inversé. Prenons tout timeseries_ids enregistrements qui correspondent à cet ensemble label=value. Et puis nous obtenons toutes les données nécessaires de l'entrepôt de données, indexées par timeseries_ids.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Examinons un exemple de la façon dont une base de données de séries chronologiques traite une requête de sélection entrante.

  • Tout d'abord, elle obtient tout timeseries_ids à partir d'un index inversé contenant les paires données label=value, ou satisfaire une expression régulière donnée.
  • Ensuite, il récupère tous les points de données du stockage de données à un intervalle de temps donné pour ceux trouvés. timeseries_ids.
  • Après cela, la base de données effectue quelques calculs sur ces points de données, selon la demande de l'utilisateur. Et après cela, il renvoie la réponse.

Dans cette présentation je vais vous parler de la première partie. Ceci est une recherche timeseries_ids par index inversé. Vous pourrez regarder la deuxième et la troisième partie plus tard. Sources VictoriaMetrics, ou attendez que je prépare d'autres rapports :)

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Passons à l'index inversé. Beaucoup pourraient penser que c’est simple. Qui sait ce qu’est un index inversé et comment il fonctionne ? Oh, il n'y a plus beaucoup de monde. Essayons de comprendre ce que c'est.

C'est en fait simple. C'est simplement un dictionnaire qui mappe une clé à une valeur. Qu'est-ce qu'une clé ? Ce couple label=valuelabel и value - ce sont des lignes. Et les valeurs sont un ensemble timeseries_ids, qui inclut la paire donnée label=value.

L'index inversé vous permet de tout trouver rapidement timeseries_ids, qui ont donné label=value.

Cela vous permet également de trouver rapidement timeseries_ids série chronologique pour plusieurs paires label=value, ou pour les couples label=regexp. Comment cela peut-il arriver? En trouvant l'intersection de l'ensemble timeseries_ids pour chaque paire label=value.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Examinons différentes implémentations de l'index inversé. Commençons par l’implémentation naïve la plus simple. Elle ressemble à ça.

Fonction getMetricIDs obtient une liste de chaînes. Chaque ligne contient label=value. Cette fonction renvoie une liste metricIDs.

Comment ça fonctionne? Ici nous avons une variable globale appelée invertedIndex. Ceci est un dictionnaire classique (map), qui mappera la chaîne pour découper les entiers. La ligne contient label=value.

Implémentation de la fonction : obtenir metricIDs pour la première label=value, puis nous passons en revue tout le reste label=value, nous avons compris metricIDs pour eux. Et appelez la fonction intersectInts, qui sera discuté ci-dessous. Et cette fonction renvoie l'intersection de ces listes.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Comme vous pouvez le constater, implémenter un index inversé n’est pas très compliqué. Mais c'est une implémentation naïve. Quels inconvénients présente-t-il ? Le principal inconvénient de l’implémentation naïve est qu’un tel index inversé est stocké dans la RAM. Après avoir redémarré l'application, nous perdons cet index. Il n'y a pas de sauvegarde de cet index sur le disque. Il est peu probable qu’un tel index inversé convienne à une base de données.

Le deuxième inconvénient est également lié à la mémoire. L'index inversé doit tenir dans la RAM. S'il dépasse la taille de la RAM, nous obtiendrons évidemment une erreur de mémoire insuffisante. Et le programme ne fonctionnera pas.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Ce problème peut être résolu en utilisant des solutions toutes faites telles que NiveauDBOu RochesDB.

Bref, nous avons besoin d'une base de données qui nous permette de faire trois opérations rapidement.

  • La première opération est l'enregistrement ключ-значение à cette base de données. Elle le fait très rapidement, où ключ-значение sont des chaînes arbitraires.
  • La deuxième opération est une recherche rapide d'une valeur à l'aide d'une clé donnée.
  • Et la troisième opération est une recherche rapide de toutes les valeurs par un préfixe donné.

LevelDB et RocksDB - ces bases de données ont été développées par Google et Facebook. Vint d’abord LevelDB. Ensuite, les gars de Facebook ont ​​pris LevelDB et ont commencé à l'améliorer, ils ont créé RocksDB. Désormais, presque toutes les bases de données internes fonctionnent sur RocksDB au sein de Facebook, y compris celles qui ont été transférées vers RocksDB et MySQL. Ils l'ont nommé MesRocks.

Un index inversé peut être implémenté à l'aide de LevelDB. Comment faire? Nous enregistrons comme clé label=value. Et la valeur est l'identifiant de la série temporelle où la paire est présente label=value.

Si nous avons plusieurs séries temporelles avec une paire donnée label=value, alors il y aura plusieurs lignes dans cette base de données avec la même clé et des valeurs différentes timeseries_ids. Pour obtenir une liste de tous timeseries_ids, qui commence par ceci label=prefix, nous effectuons un range scan pour lequel cette base de données est optimisée. Autrement dit, nous sélectionnons toutes les lignes commençant par label=prefix et obtenez le nécessaire timeseries_ids.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Voici un exemple d’implémentation de ce à quoi cela ressemblerait dans Go. Nous avons un index inversé. Il s'agit de LevelDB.

La fonction est la même que pour l’implémentation naïve. Il répète l’implémentation naïve presque ligne par ligne. Le seul point est qu'au lieu de se tourner vers map on accède à l'index inversé. On obtient toutes les valeurs du premier label=value. Ensuite, nous passons en revue toutes les paires restantes label=value et obtenez les ensembles correspondants de metricIDs pour eux. Ensuite, nous trouvons l'intersection.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Tout semble bien se passer, mais cette solution présente des inconvénients. VictoriaMetrics a initialement implémenté un index inversé basé sur LevelDB. Mais j’ai finalement dû y renoncer.

Pourquoi? Parce que LevelDB est plus lent que l'implémentation naïve. Dans une implémentation naïve, étant donné une clé donnée, on récupère immédiatement la tranche entière metricIDs. C'est une opération très rapide : la tranche entière est prête à l'emploi.

Dans LevelDB, chaque fois qu'une fonction est appelée GetValues vous devez parcourir toutes les lignes qui commencent par label=value. Et obtenez la valeur pour chaque ligne timeseries_ids. De telle timeseries_ids récupérez-en une tranche timeseries_ids. Évidemment, cela est beaucoup plus lent que le simple accès à une carte classique par clé.

Le deuxième inconvénient est que LevelDB est écrit en C. L’appel de fonctions C depuis Go n’est pas très rapide. Cela prend des centaines de nanosecondes. Ce n'est pas très rapide, car par rapport à un appel de fonction classique écrit en go, qui prend 1 à 5 nanosecondes, la différence de performances est des dizaines de fois. Pour VictoriaMetrics, c'était un défaut fatal :)

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

J'ai donc écrit ma propre implémentation de l'index inversé. Et il l'a appelée ensemble de fusion.

Mergeset est basé sur la structure de données MergeTree. Cette structure de données est empruntée à ClickHouse. Évidemment, le mergeset doit être optimisé pour une recherche rapide timeseries_ids selon la clé donnée. Mergeset est entièrement écrit en Go. Tu peux voir Sources VictoriaMetrics sur GitHub. L'implémentation de mergeset est dans le dossier /lib/mergeset. Vous pouvez essayer de comprendre ce qui se passe là-bas.

L'API mergeset est très similaire à LevelDB et RocksDB. Autrement dit, il vous permet d'y enregistrer rapidement de nouveaux enregistrements et de sélectionner rapidement des enregistrements par un préfixe donné.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Nous parlerons des inconvénients du mergeset plus tard. Parlons maintenant des problèmes survenus avec VictoriaMetrics en production lors de la mise en œuvre d'un index inversé.

Pourquoi sont-ils apparus ?

La première raison est le taux de désabonnement élevé. Traduit en russe, il s'agit d'un changement fréquent dans les séries chronologiques. C'est à ce moment-là qu'une série chronologique se termine et qu'une nouvelle série commence, ou que de nombreuses nouvelles séries chronologiques commencent. Et cela arrive souvent.

La deuxième raison est le grand nombre de séries chronologiques. Au début, lorsque la surveillance gagnait en popularité, le nombre de séries chronologiques était faible. Par exemple, pour chaque ordinateur, vous devez surveiller la charge du processeur, de la mémoire, du réseau et du disque. 4 séries chronologiques par ordinateur. Disons que vous disposez de 100 ordinateurs et de 400 séries temporelles. C'est très peu.

Au fil du temps, les gens ont compris qu’ils pouvaient mesurer des informations plus granulaires. Par exemple, mesurez la charge non pas de l'ensemble du processeur, mais séparément de chaque cœur de processeur. Si vous disposez de 40 cœurs de processeur, vous disposez de 40 fois plus de séries temporelles pour mesurer la charge du processeur.

Mais ce n'est pas tout. Chaque cœur de processeur peut avoir plusieurs états, comme inactif, lorsqu'il est inactif. Et travaillez également dans l'espace utilisateur, travaillez dans l'espace noyau et dans d'autres états. Et chacun de ces états peut également être mesuré comme une série chronologique distincte. Cela augmente en outre le nombre de lignes de 7 à 8 fois.

À partir d'une métrique, nous avons obtenu 40 x 8 = 320 métriques pour un seul ordinateur. Multipliez par 100, nous obtenons 32 000 au lieu de 400.

Puis Kubernetes est arrivé. Et la situation a empiré car Kubernetes peut héberger de nombreux services différents. Chaque service dans Kubernetes se compose de nombreux pods. Et tout cela doit être surveillé. De plus, nous avons un déploiement constant de nouvelles versions de vos services. Pour chaque nouvelle version, de nouvelles séries temporelles doivent être créées. En conséquence, le nombre de séries chronologiques augmente de façon exponentielle et nous sommes confrontés au problème d’un grand nombre de séries chronologiques, appelé haute cardinalité. VictoriaMetrics y parvient avec succès par rapport à d'autres bases de données de séries chronologiques.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Examinons de plus près le taux de désabonnement élevé. Qu’est-ce qui cause un taux de désabonnement élevé en production ? Parce que certaines significations des étiquettes et des balises changent constamment.

Par exemple, prenons Kubernetes, qui a le concept deployment, c'est-à-dire lorsqu'une nouvelle version de votre application est déployée. Pour une raison quelconque, les développeurs de Kubernetes ont décidé d'ajouter l'identifiant de déploiement à l'étiquette.

A quoi cela a-t-il conduit ? De plus, à chaque nouveau déploiement, toutes les anciennes séries temporelles sont interrompues et à leur place, de nouvelles séries temporelles commencent avec une nouvelle valeur d'étiquette. deployment_id. Il peut y avoir des centaines de milliers, voire des millions de lignes de ce type.

La chose importante dans tout cela est que le nombre total de séries chronologiques augmente, mais le nombre de séries chronologiques actuellement actives et recevant des données reste constant. Cet état est appelé taux de désabonnement élevé.

Le principal problème d’un taux de désabonnement élevé est d’assurer une vitesse de recherche constante pour toutes les séries temporelles pour un ensemble donné d’étiquettes sur un certain intervalle de temps. Il s'agit généralement de l'intervalle de temps de la dernière heure ou du dernier jour.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Comment résoudre ce problème? Voici la première option. Il s'agit de diviser l'index inversé en parties indépendantes au fil du temps. Autrement dit, après un certain intervalle de temps, nous finissons de travailler avec l'index inversé actuel. Et créez un nouvel index inversé. Un autre intervalle de temps passe, on en crée un autre et un autre.

Et lors de l'échantillonnage à partir de ces indices inversés, nous trouvons un ensemble d'indices inversés qui se situent dans l'intervalle donné. Et, en conséquence, nous sélectionnons l'identifiant de la série chronologique à partir de là.

Cela permet d'économiser des ressources car nous n'avons pas besoin d'examiner les pièces qui ne correspondent pas à l'intervalle donné. Autrement dit, si nous sélectionnons des données pour la dernière heure, nous ignorons les requêtes pour les intervalles de temps précédents.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Il existe une autre option pour résoudre ce problème. Il s'agit de stocker pour chaque jour une liste distincte d'identifiants de séries chronologiques survenues ce jour-là.

L’avantage de cette solution par rapport à la solution précédente est que nous ne dupliquons pas les informations de séries chronologiques qui ne disparaissent pas avec le temps. Ils sont constamment présents et ne changent pas.

L’inconvénient est qu’une telle solution est plus difficile à mettre en œuvre et plus difficile à déboguer. Et VictoriaMetrics a choisi cette solution. C’est ainsi que cela s’est passé historiquement. Cette solution est également performante par rapport à la précédente. Car cette solution n'a pas été mise en œuvre du fait qu'il est nécessaire de dupliquer les données dans chaque partition pour des séries temporelles qui ne changent pas, c'est-à-dire qui ne disparaissent pas dans le temps. VictoriaMetrics était principalement optimisé pour la consommation d'espace disque, et la mise en œuvre précédente a aggravé la consommation d'espace disque. Mais cette implémentation est mieux adaptée pour minimiser la consommation d’espace disque, c’est pourquoi elle a été choisie.

J'ai dû la combattre. Le problème était que dans cette implémentation, vous deviez toujours choisir un nombre beaucoup plus grand. timeseries_ids pour les données que lorsque l'index inversé est partitionné dans le temps.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Comment avons-nous résolu ce problème ? Nous l'avons résolu de manière originale : en stockant plusieurs identifiants de séries chronologiques dans chaque entrée d'index inversé au lieu d'un seul identifiant. Autrement dit, nous avons une clé label=value, qui se produit dans chaque série chronologique. Et maintenant nous en économisons plusieurs timeseries_ids en une seule entrée.

Voici un exemple. Auparavant, nous avions N entrées, mais maintenant nous avons une entrée dont le préfixe est le même que tous les autres. Pour l’entrée précédente, la valeur contient tous les identifiants de séries chronologiques.

Cela a permis d'augmenter la vitesse de numérisation d'un tel index inversé jusqu'à 10 fois. Et cela nous a permis de réduire la consommation de mémoire pour le cache, car maintenant nous stockons la chaîne label=value une seule fois dans le cache ensemble N fois. Et cette ligne peut être longue si vous stockez de longues lignes dans vos balises et étiquettes, que Kubernetes aime y insérer.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Une autre option pour accélérer la recherche sur un index inversé est le partitionnement. Création de plusieurs index inversés au lieu d'un seul et partage des données entre eux par clé. Ceci est un ensemble key=value vapeur. Autrement dit, nous obtenons plusieurs index inversés indépendants, que nous pouvons interroger en parallèle sur plusieurs processeurs. Les implémentations précédentes n'autorisaient qu'un fonctionnement en mode monoprocesseur, c'est-à-dire en analysant les données sur un seul cœur. Cette solution permet de scanner les données sur plusieurs cœurs à la fois, comme aime le faire ClickHouse. C’est ce que nous envisageons de mettre en œuvre.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Revenons maintenant à nos moutons - à la fonction d'intersection timeseries_ids. Considérons quelles implémentations il peut y avoir. Cette fonction vous permet de trouver timeseries_ids pour un ensemble donné label=value.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

La première option est une implémentation naïve. Deux boucles imbriquées. Ici, nous obtenons l'entrée de la fonction intersectInts deux tranches - a и b. En sortie, il devrait nous renvoyer l'intersection de ces tranches.

Une implémentation naïve ressemble à ceci. Nous parcourons toutes les valeurs de slice a, à l'intérieur de cette boucle on parcourt toutes les valeurs de slice b. Et nous les comparons. S'ils correspondent, alors nous avons trouvé une intersection. Et enregistrez-le dans result.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Quels sont les inconvénients ? La complexité quadratique est son principal inconvénient. Par exemple, si vos dimensions sont slice a и b un million à la fois, alors cette fonction ne vous retournera jamais de réponse. Parce qu’il faudra effectuer mille milliards d’itérations, ce qui est beaucoup, même pour les ordinateurs modernes.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

La deuxième implémentation est basée sur la carte. Nous créons une carte. Nous mettons toutes les valeurs de slice dans cette carte a. Ensuite, nous passons par slice dans une boucle séparée b. Et on vérifie si cette valeur vient de slice b dans la carte. S'il existe, ajoutez-le au résultat.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Quels sont les bénéfices? L’avantage est qu’il n’y a qu’une complexité linéaire. Autrement dit, la fonction s’exécutera beaucoup plus rapidement pour les tranches plus grandes. Pour une tranche de la taille d'un million, cette fonction s'exécutera en 2 millions d'itérations, contre les billions d'itérations de la fonction précédente.

L'inconvénient est que cette fonction nécessite plus de mémoire pour créer cette carte.

Le deuxième inconvénient est la surcharge importante liée au hachage. Cet inconvénient n'est pas très évident. Et pour nous, ce n'était pas non plus très évident, donc au début, dans VictoriaMetrics, la mise en œuvre de l'intersection se faisait via une carte. Mais le profilage a ensuite montré que le temps du processeur principal était consacré à l'écriture sur la carte et à la vérification de la présence d'une valeur dans cette carte.

Pourquoi le temps CPU est-il perdu à ces endroits ? Car Go effectue une opération de hachage sur ces lignes. Autrement dit, il calcule le hachage de la clé afin d'y accéder ensuite à un index donné dans le HashMap. L’opération de calcul de hachage s’effectue en dizaines de nanosecondes. C'est lent pour VictoriaMetrics.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

J'ai décidé d'implémenter un jeu de bits optimisé spécifiquement pour ce cas. Voici à quoi ressemble désormais l’intersection de deux tranches. Ici, nous créons un jeu de bits. Nous y ajoutons des éléments de la première tranche. Puis on vérifie la présence de ces éléments dans la deuxième tranche. Et ajoutez-les au résultat. Autrement dit, ce n’est presque pas différent de l’exemple précédent. La seule chose ici est que nous avons remplacé l'accès à la carte par des fonctions personnalisées add и has.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

À première vue, il semble que cela devrait fonctionner plus lentement si auparavant une carte standard y était utilisée, puis d'autres fonctions sont appelées, mais le profilage montre que cette chose fonctionne 10 fois plus rapidement qu'une carte standard dans le cas de VictoriaMetrics.

De plus, il utilise beaucoup moins de mémoire que l’implémentation cartographique. Parce que nous stockons ici des bits au lieu de valeurs de huit octets.

L’inconvénient de cette implémentation est qu’elle n’est pas si évidente, ni triviale.

Un autre inconvénient que beaucoup ne remarqueront peut-être pas est que cette implémentation peut ne pas fonctionner correctement dans certains cas. Autrement dit, il est optimisé pour un cas spécifique, pour ce cas d'intersection des identifiants de séries chronologiques VictoriaMetrics. Cela ne veut pas dire qu’il convient à tous les cas. S'il est mal utilisé, nous n'obtiendrons pas une augmentation des performances, mais une erreur de manque de mémoire et un ralentissement des performances.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Considérons la mise en œuvre de cette structure. Si vous voulez regarder, il se trouve dans les sources VictoriaMetrics, dans le dossier lib/uint64set. Il est optimisé spécifiquement pour le cas VictoriaMetrics, où timeseries_id est une valeur de 64 bits, où les 32 premiers bits sont fondamentalement constants et seuls les 32 derniers bits changent.

Cette structure de données n'est pas stockée sur disque, elle fonctionne uniquement en mémoire.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Voici son API. Ce n'est pas très compliqué. L'API est spécifiquement adaptée à un exemple spécifique d'utilisation de VictoriaMetrics. Autrement dit, il n'y a pas de fonctions inutiles ici. Voici les fonctions explicitement utilisées par VictoriaMetrics.

Il y a des fonctions add, ce qui ajoute de nouvelles valeurs. Il y a une fonction has, qui vérifie les nouvelles valeurs. Et il y a une fonction del, qui supprime les valeurs. Il y a une fonction d'assistance len, qui renvoie la taille de l'ensemble. Fonction clone clone beaucoup. Et fonction appendto convertit cet ensemble en tranche timeseries_ids.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Voici à quoi ressemble la mise en œuvre de cette structure de données. l'ensemble comporte deux éléments :

  • ItemsCount est un champ d'assistance pour renvoyer rapidement le nombre d'éléments dans un ensemble. Il serait possible de se passer de ce champ auxiliaire, mais il a fallu l'ajouter ici car VictoriaMetrics interroge souvent la longueur du jeu de bits dans ses algorithmes.

  • Le deuxième champ est buckets. C'est une tranche de la structure bucket32. Chaque structure stocke hi champ. Ce sont les 32 bits supérieurs. Et deux tranches - b16his и buckets de bucket16 structures.

Les 16 premiers bits de la deuxième partie de la structure 64 bits sont stockés ici. Et ici, les jeux de bits sont stockés pour les 16 bits inférieurs de chaque octet.

Bucket64 se compose d'un tableau uint64. La longueur est calculée à l'aide de ces constantes. Dans une bucket16 le maximum peut être stocké 2^16=65536 peu. Si vous divisez cela par 8, cela fait 8 kilo-octets. Si tu divises encore par 8, ça fait 1000 uint64 signification. C'est Bucket16 – c'est notre structure de 8 kilo-octets.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Voyons comment est implémentée l'une des méthodes de cette structure pour ajouter une nouvelle valeur.

Tout commence par uint64 significations. Nous calculons les 32 bits supérieurs, nous calculons les 32 bits inférieurs. Passons en revue tout buckets. Nous comparons les 32 premiers bits de chaque compartiment avec la valeur ajoutée. Et s'ils correspondent, alors nous appelons la fonction add dans la structure b32 buckets. Et ajoutez-y les 32 bits inférieurs. Et s'il revenait true, alors cela signifie que nous y avons ajouté une telle valeur et que nous n'avions pas une telle valeur. S'il revient false, alors une telle signification existait déjà. Ensuite, nous augmentons le nombre d'éléments dans la structure.

Si nous n'avons pas trouvé celui dont vous avez besoin bucket avec la valeur élevée requise, alors nous appelons la fonction addAlloc, qui en produira un nouveau bucket, en l'ajoutant à la structure du compartiment.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

C'est l'implémentation de la fonction b32.add. C’est similaire à l’implémentation précédente. On calcule les 16 bits de poids fort, les 16 bits de poids faible.

Ensuite, nous passons en revue tous les 16 bits supérieurs. Nous trouvons des correspondances. Et s’il y a une correspondance, nous appelons la méthode add, que nous considérerons à la page suivante pour bucket16.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

Et voici le niveau le plus bas, qu'il convient d'optimiser au maximum. Nous calculons pour uint64 valeur d'identification en bit de tranche et également bitmask. Il s'agit d'un masque pour une valeur 64 bits donnée, qui peut être utilisé pour vérifier la présence de ce bit, ou le positionner. Nous vérifions si ce bit est activé, le définissons et renvoyons la présence. Il s'agit de notre implémentation, qui nous a permis d'accélérer de 10 fois le fonctionnement des identifiants croisés des séries temporelles par rapport aux cartes conventionnelles.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

En plus de cette optimisation, VictoriaMetrics dispose de nombreuses autres optimisations. La plupart de ces optimisations ont été ajoutées pour une raison, mais après avoir profilé le code en production.

C'est la règle principale de l'optimisation : n'ajoutez pas d'optimisation en supposant qu'il y aura un goulot d'étranglement ici, car il se peut qu'il n'y ait pas de goulot d'étranglement à cet endroit. L'optimisation dégrade généralement la qualité du code. Par conséquent, il vaut la peine d'optimiser uniquement après le profilage et de préférence en production, afin qu'il s'agisse de données réelles. Si quelqu'un est intéressé, vous pouvez consulter le code source de VictoriaMetrics et explorer les autres optimisations disponibles.

Accédez aux optimisations dans VictoriaMetrics. Alexandre Valyalkine

J'ai une question sur les bitset. Très similaire à l'implémentation booléenne vectorielle C++, jeu de bits optimisé. Avez-vous pris la mise en œuvre à partir de là ?

Non, pas de là. Lors de la mise en œuvre de cet ensemble de bits, j'ai été guidé par la connaissance de la structure de ces séries temporelles d'identifiants, qui sont utilisées dans VictoriaMetrics. Et leur structure est telle que les 32 bits supérieurs sont fondamentalement constants. Les 32 bits inférieurs sont susceptibles de changer. Plus le bit est bas, plus il peut changer souvent. Par conséquent, cette implémentation est spécifiquement optimisée pour cette structure de données. L'implémentation C++, pour autant que je sache, est optimisée pour le cas général. Si vous optimisez pour le cas général, cela signifie que ce ne sera pas le plus optimal pour un cas spécifique.

Je vous conseille également de regarder le reportage d'Alexeï Milovid. Il y a environ un mois, il a parlé d'optimisation dans ClickHouse pour des spécialisations spécifiques. Il dit simplement que dans le cas général, une implémentation C++ ou une autre implémentation est conçue pour bien fonctionner en moyenne dans un hôpital. Elle peut être moins performante qu'une implémentation spécifique aux connaissances comme la nôtre, où nous savons que les 32 premiers bits sont pour la plupart constants.

J'ai une deuxième question. Quelle est la différence fondamentale avec InfluxDB ?

Il existe de nombreuses différences fondamentales. En termes de performances et de consommation de mémoire, InfluxDB dans les tests montre une consommation de mémoire 10 fois supérieure pour les séries temporelles à cardinalité élevée, lorsque vous en avez beaucoup, par exemple des millions. Par exemple, VictoriaMetrics consomme 1 Go par million de lignes actives, tandis qu'InfluxDB consomme 10 Go. Et c'est une grande différence.

La deuxième différence fondamentale est qu'InfluxDB possède des langages de requête étranges - Flux et InfluxQL. Ils ne sont pas très pratiques pour travailler avec des séries chronologiques par rapport à PromQL, qui est pris en charge par VictoriaMetrics. PromQL est un langage de requête de Prometheus.

Et une autre différence est qu'InfluxDB a un modèle de données légèrement étrange, dans lequel chaque ligne peut stocker plusieurs champs avec un ensemble de balises différent. Ces lignes sont ensuite divisées en différents tableaux. Ces complications supplémentaires compliquent le travail ultérieur avec cette base de données. C’est difficile à soutenir et à comprendre.

Dans VictoriaMetrics, tout est beaucoup plus simple. Là, chaque série temporelle est une valeur-clé. La valeur est un ensemble de points - (timestamp, value), et la clé est l'ensemble label=value. Il n'y a pas de séparation entre les champs et les mesures. Il vous permet de sélectionner n'importe quelle donnée puis de combiner, ajouter, soustraire, multiplier, diviser, contrairement à InfluxDB où les calculs entre différentes lignes ne sont toujours pas implémentés à ma connaissance. Même s’ils sont implémentés, c’est difficile, il faut écrire beaucoup de code.

J'ai une question de clarification. Ai-je bien compris qu'il y avait une sorte de problème dont vous avez parlé, que cet index inversé ne rentre pas dans la mémoire, donc il y a un partitionnement là-bas ?

Tout d’abord, j’ai montré une implémentation naïve d’un index inversé sur une carte Go standard. Cette implémentation n'est pas adaptée aux bases de données car cet index inversé n'est pas enregistré sur le disque, et la base de données doit être enregistrée sur le disque pour que ces données restent disponibles au redémarrage. Dans cette implémentation, lorsque vous redémarrez l'application, votre index inversé disparaîtra. Et vous perdrez l’accès à toutes les données car vous ne pourrez pas les retrouver.

Bonjour! Merci pour le rapport! Je m'appelle Pavel. Je viens de Wildberries. J'ai quelques questions pour vous. Question une. Pensez-vous que si vous aviez choisi un principe différent lors de la construction de l'architecture de votre application et partitionné les données au fil du temps, vous auriez peut-être pu recouper les données lors de la recherche, en vous basant uniquement sur le fait qu'une partition contient des données pour une autre. période de temps, c'est-à-dire dans un intervalle de temps et vous n'auriez pas à vous soucier du fait que vos pièces sont dispersées différemment ? Question numéro 2 - puisque vous implémentez un algorithme similaire avec bitset et tout le reste, alors peut-être avez-vous essayé d'utiliser les instructions du processeur ? Peut-être avez-vous essayé de telles optimisations ?

Je répondrai tout de suite à la deuxième. Nous n’en sommes pas encore là. Mais s’il le faut, nous y arriverons. Et la première, quelle était la question ?

Vous avez évoqué deux scénarios. Et ils ont dit qu'ils avaient choisi le second avec une mise en œuvre plus complexe. Et ils n’ont pas préféré la première, où les données sont partitionnées par temps.

Oui. Dans le premier cas, le volume total de l'index serait plus grand, car dans chaque partition, nous devrions stocker des données en double pour les séries temporelles qui se poursuivent dans toutes ces partitions. Et si le taux de désabonnement de vos séries chronologiques est faible, c'est-à-dire que les mêmes séries sont constamment utilisées, alors dans le premier cas, nous perdrions beaucoup plus en quantité d'espace disque occupé par rapport au second cas.

Et donc – oui, le partage du temps est une bonne option. Prométhée l'utilise. Mais Prometheus présente un autre inconvénient. Lors de la fusion de ces éléments de données, il doit conserver en mémoire les méta-informations pour toutes les étiquettes et séries temporelles. Par conséquent, si les données fusionnées sont volumineuses, la consommation de mémoire augmente considérablement lors de la fusion, contrairement à VictoriaMetrics. Lors de la fusion, VictoriaMetrics ne consomme pas du tout de mémoire ; seuls quelques kilo-octets sont consommés, quelle que soit la taille des éléments de données fusionnés.

L'algorithme que vous utilisez utilise de la mémoire. Il marque les balises de séries temporelles qui contiennent des valeurs. Et de cette façon, vous vérifiez la présence de paires dans un tableau de données et dans un autre. Et vous comprenez si l'intersection s'est produite ou non. En règle générale, les bases de données implémentent des curseurs et des itérateurs qui stockent leur contenu actuel et parcourent les données triées en raison de la simple complexité de ces opérations.

Pourquoi n'utilisons-nous pas de curseurs pour parcourir les données ?

Oui.

Nous stockons les lignes triées dans LevelDB ou mergeset. Nous pouvons déplacer le curseur et trouver l'intersection. Pourquoi ne l'utilisons-nous pas ? Parce que c'est lent. Parce que les curseurs signifient que vous devez appeler une fonction pour chaque ligne. Un appel de fonction dure 5 nanosecondes. Et si vous avez 100 000 000 de lignes, il s'avère que nous passons une demi-seconde simplement à appeler la fonction.

Une telle chose existe, oui. Et ma dernière question. La question peut paraître un peu étrange. Pourquoi n'est-il pas possible de lire tous les agrégats nécessaires au moment où les données arrivent et de les sauvegarder sous la forme requise ? Pourquoi économiser d'énormes volumes dans certains systèmes comme VictoriaMetrics, ClickHouse, etc., puis y consacrer beaucoup de temps ?

Je vais donner un exemple pour que ce soit plus clair. Disons, comment fonctionne un petit compteur de vitesse jouet ? Il enregistre la distance que vous avez parcourue, en l'ajoutant tout le temps à une valeur et à la seconde fois. Et divise. Et obtient une vitesse moyenne. Vous pouvez faire à peu près la même chose. Additionnez tous les faits nécessaires à la volée.

D'accord, je comprends la question. Votre exemple a sa place. Si vous savez de quels agrégats vous avez besoin, c'est la meilleure implémentation. Mais le problème est que les gens sauvegardent ces métriques, certaines données dans ClickHouse et ne savent pas encore comment ils les agrégeront et les filtreront à l’avenir, ils doivent donc sauvegarder toutes les données brutes. Mais si vous savez que vous devez calculer quelque chose en moyenne, alors pourquoi ne pas le calculer au lieu d'y stocker un tas de valeurs brutes ? Mais ce n’est que si vous savez exactement ce dont vous avez besoin.

À propos, les bases de données de stockage de séries chronologiques prennent en charge le comptage des agrégats. Par exemple, Prometheus prend en charge règles d'enregistrement. Autrement dit, cela peut être fait si vous savez de quelles unités vous aurez besoin. VictoriaMetrics ne l'a pas encore, mais il est généralement précédé de Prometheus, dans lequel cela peut être fait dans les règles de recodage.

Par exemple, dans mon emploi précédent, je devais compter le nombre d'événements dans une fenêtre glissante au cours de la dernière heure. Le problème est que j'ai dû créer une implémentation personnalisée dans Go, c'est-à-dire un service pour compter cette chose. Cette prestation n’était finalement pas anodine, car difficilement calculable. La mise en œuvre peut être simple si vous devez compter certains agrégats à intervalles de temps fixes. Si vous souhaitez compter les événements dans une fenêtre glissante, ce n'est pas aussi simple qu'il y paraît. Je pense que cela n'a pas encore été implémenté dans ClickHouse ou dans les bases de données de séries temporelles, car c'est difficile à mettre en œuvre.

Et encore une question. Nous parlions juste de moyenne, et je me suis souvenu qu'il existait autrefois quelque chose comme le graphite avec un backend carbone. Et il savait comment affiner les anciennes données, c'est-à-dire laisser un point par minute, un point par heure, etc. En principe, c'est assez pratique si nous avons besoin de données brutes, relativement parlant, pour un mois, et tout le reste peut être éclairci. Mais Prometheus et VictoriaMetrics ne prennent pas en charge cette fonctionnalité. Est-il prévu de le soutenir ? Si non, pourquoi pas ?

Merci pour la question. Nos utilisateurs posent cette question périodiquement. Ils demandent quand nous ajouterons la prise en charge du sous-échantillonnage. Il y a plusieurs problèmes ici. Premièrement, chaque utilisateur comprend downsampling quelque chose de différent : quelqu'un veut obtenir n'importe quel point arbitraire sur un intervalle donné, quelqu'un veut des valeurs maximales, minimales et moyennes. Si de nombreux systèmes écrivent des données dans votre base de données, vous ne pouvez pas tout regrouper. Il se peut que chaque système nécessite un éclaircissement différent. Et c'est difficile à mettre en œuvre.

Et la deuxième chose est que VictoriaMetrics, comme ClickHouse, est optimisé pour travailler sur de grandes quantités de données brutes, de sorte qu'il peut parcourir un milliard de lignes en moins d'une seconde si vous avez plusieurs cœurs dans votre système. Analyse des points de séries chronologiques dans VictoriaMetrics – 50 000 000 de points par seconde par cœur. Et ces performances s’adaptent aux cœurs existants. Autrement dit, si vous disposez de 20 cœurs, par exemple, vous analyserez un milliard de points par seconde. Et cette propriété de VictoriaMetrics et ClickHouse réduit le besoin de sous-échantillonnage.

Une autre fonctionnalité est que VictoriaMetrics compresse efficacement ces données. La compression en moyenne en production est de 0,4 à 0,8 octets par point. Chaque point est un horodatage + une valeur. Et il est compressé en moins d’un octet en moyenne.

Sergueï. J'ai une question. Quelle est la durée minimale d’enregistrement ?

Une milliseconde. Nous avons récemment eu une conversation avec d'autres développeurs de bases de données de séries chronologiques. Leur tranche de temps minimale est d'une seconde. Et dans Graphite, par exemple, c’est aussi une seconde. Dans OpenTSDB, c'est également une seconde. InfluxDB a une précision à la nanoseconde. Dans VictoriaMetrics, c'est une milliseconde, car dans Prometheus, c'est une milliseconde. Et VictoriaMetrics a été initialement développé comme stockage distant pour Prometheus. Mais il peut désormais sauvegarder les données d’autres systèmes.

La personne à qui j'ai parlé dit qu'ils ont une précision à la seconde près - cela leur suffit car cela dépend du type de données stockées dans la base de données de séries chronologiques. S'il s'agit de données DevOps ou de données provenant d'une infrastructure, où vous les collectez à des intervalles de 30 secondes par minute, alors une précision à la seconde suffit, vous n'avez besoin de rien de moins. Et si vous collectez ces données à partir de systèmes de trading à haute fréquence, vous avez besoin d’une précision à la nanoseconde.

La précision à la milliseconde de VictoriaMetrics convient également au cas DevOps et peut convenir à la plupart des cas que j'ai mentionnés au début du rapport. La seule chose pour laquelle il peut ne pas convenir est les systèmes de trading à haute fréquence.

Merci! Et une autre question. Qu’est-ce que la compatibilité dans PromQL ?

Compatibilité ascendante totale. VictoriaMetrics prend entièrement en charge PromQL. De plus, il ajoute des fonctionnalités avancées supplémentaires dans PromQL, appelées MétriquesQL. On parle sur YouTube de cette fonctionnalité étendue. J'ai pris la parole lors du Monitoring Meetup au printemps à Saint-Pétersbourg.

Chaîne de télégramme VictoriaMetrics.

Seuls les utilisateurs enregistrés peuvent participer à l'enquête. se connecters'il te plait.

Qu'est-ce qui vous empêche de passer à VictoriaMetrics comme stockage à long terme pour Prometheus ? (Écrivez dans les commentaires, je l'ajouterai au sondage))

  • 71,4%Je n'utilise pas Prometheus5

  • 28,6%Je ne connaissais pas VictoriaMetrics2

7 utilisateurs ont voté. 12 utilisateurs se sont abstenus.

Source: habr.com

Ajouter un commentaire