Ich empfehle Ihnen, sich mit der Zusammenfassung des Berichts von Alexander Valyalkin aus dem Jahr 2019 "Go-Optimierungen in VictoriaMetrics" vertraut zu machen.
— eine schnelle und skalierbare DBMS zur Speicherung und Verarbeitung von Daten in Form von Zeitreihen (ein Datensatz besteht aus einem Zeitpunkt und einer Menge von Werten, die diesem Zeitpunkt entsprechen, beispielsweise durch regelmäßige Abfragen von Sensordaten oder das Sammeln von Metriken erhalten).

Hier ist der Link zum Video dieses Berichts —

Ich erzähle Ihnen ein wenig über mich. Ich bin Alexander Valyalkin. Hier ist . Ich interessiere mich für Go und Leistungsoptimierung. Ich habe viele nützliche und weniger nützliche Bibliotheken geschrieben. Diese beginnen entweder mit fast, oder mit quick als Präfix.
Derzeit arbeite ich an VictoriaMetrics. Was ist das und was mache ich dort? Darüber werde ich in dieser Präsentation berichten.

Der Plan des Berichts ist folgender:
- Zuerst erzähle ich Ihnen, was VictoriaMetrics ist.
- Dann erkläre ich, was Zeitreihen sind.
- Danach erkläre ich, wie die Zeitreihendatenbank funktioniert.
- Anschließend werde ich die Architektur der Datenbank erläutern: Woraus sie besteht.
- Und dann kommen wir zu den Optimierungen, die in VictoriaMetrics vorhanden sind. Dazu gehören die Optimierung des invertierten Index und die Optimierung für die Bitset-Implementierung in Go.

Was ist VictoriaMetrics? Weiß jemand aus dem Publikum Bescheid? Wow, viele Leute wissen already darüber Bescheid. Das ist eine gute Nachricht. Für diejenigen, die es nicht wissen – es ist eine Datenbank für Zeitreihen. Sie basiert auf der Architektur von ClickHouse, mit einigen Details der Implementierung von ClickHouse. Zum Beispiel: MergeTree, parallele Berechnungen auf allen verfügbaren Prozessor-Kernen und Leistungsoptimierung durch die Arbeit mit Datenblöcken, die im Prozessor-Cache platziert werden.
VictoriaMetrics bietet die beste Datenkompression im Vergleich zu anderen Datenbanken für Zeitreihen.
Sie kann vertikal skalieren – das heißt, Sie können zusätzliche Prozessoren oder mehr Arbeitsspeicher auf einem Computer hinzufügen. VictoriaMetrics wird diese verfügbaren Ressourcen erfolgreich ausnutzen und die lineare Leistung erhöhen.
VictoriaMetrics kann auch horizontal skalieren – das heißt, Sie können zusätzliche Knoten zum VictoriaMetrics-Cluster hinzufügen, und ihre Leistung wird nahezu linear zunehmen.
Wie Sie wahrscheinlich erraten haben, ist VictoriaMetrics eine schnelle Datenbank, denn ich kann über keine anderen sprechen. Außerdem ist sie in Go geschrieben, weshalb ich bei diesem Meetup darüber referiere.

Wer weiß, was eine Zeitreihe ist? Viele Menschen wissen das ebenfalls. Eine Zeitreihe ist eine Serie von Paaren (Zeitstempel, Wert), bei denen diese Paare zeitlich sortiert sind. Der Wert ist eine Fließkomma-Zahl – float64.
Jede Zeitreihe wird eindeutig durch einen Schlüssel identifiziert. Woraus besteht dieser Schlüssel? Er besteht aus einer nicht leeren Menge von Schlüssel-Wert-Paaren.
Hier ist ein Beispiel für eine Zeitreihe. Der Schlüssel dieser Reihe ist eine Liste von Paaren: __name__="cpu_usage" – das ist der Name der Metrik, instance="my-server" – das ist der Computer, auf dem diese Metrik gesammelt wurde, datacenter="us-east" – das ist das Rechenzentrum, in dem dieser Computer steht.
Wir haben den Namen einer Zeitreihe erhalten, der aus drei Schlüssel-Wert-Paaren besteht. Zu diesem Schlüssel gehört eine Liste von Paaren (Zeitstempel, Wert). t1, t3, t3, ..., tN – das sind Zeitstempel, 10, 20, 12, ..., 15 – und die entsprechenden Werte. Das ist die cpu-usage zu diesem Zeitpunkt für diese Reihe.

Wo können Zeitreihen verwendet werden? Hat jemand Ideen?
- In DevOps können die CPU-, RAM-, Netzwerkbelastungen, rps, die Anzahl der Fehler usw. gemessen werden.
- IoT – wir können Temperatur, Druck, geografische Koordinaten und noch einiges mehr messen.
- Auch im Finanzbereich – wir können die Preise von Aktien und Währungen überwachen.
- Darüber hinaus können Zeitreihen zur Überwachung von Produktionsprozessen in Fabriken verwendet werden. Wir haben Nutzer, die VictoriaMetrics zur Überwachung von Windkraftanlagen und Robotern einsetzen.
- Zeitreihen sind auch nützlich, um Informationen von Sensoren verschiedener Geräte zu sammeln. Zum Beispiel für den Motor; zur Messung des Reifendrucks; zur Messung von Geschwindigkeit, Entfernung; zur Erfassung des Benzinverbrauchs usw.
- Außerdem können Zeitreihen zur Überwachung von Flugzeugen verwendet werden. In jedem Flugzeug gibt es einen schwarzen Kasten, der Zeitreihen zu verschiedenen Gesundheitsparametern des Flugzeugs sammelt. Zeitreihen werden auch in der Luft- und Raumfahrtindustrie eingesetzt.
- Healthcare – das umfasst Blutdruck, Puls usw.
Es gibt vielleicht noch weitere Anwendungen, die ich vergessen habe, aber ich hoffe, Sie haben verstanden, dass Zeitreihen in der modernen Welt aktiv genutzt werden. Und das Volumen ihrer Verwendung wächst von Jahr zu Jahr.

Wofür wird eine Datenbank für Zeitreihen benötigt? Warum kann man keine herkömmliche relationale Datenbank zur Speicherung von Zeitreihen verwenden?
Das liegt daran, dass Zeitreihen in der Regel große Datenmengen enthalten, die in herkömmlichen Datenbanken schwer zu speichern und zu verarbeiten sind. Daher wurden spezialisierte Datenbanken für Zeitreihen entwickelt. Diese Datenbanken speichern effizient Punkte (Zeitstempel, Wert) mit einem bestimmten Schlüssel. Sie bieten eine API zum Lesen der gespeicherten Daten nach Schlüssel, entweder für ein Paar Schlüssel-Wert, für mehrere solcher Paare oder nach regexp. Wenn Sie beispielsweise die CPU-Auslastung all Ihrer Dienste im Rechenzentrum in den USA finden möchten, müssen Sie eine solche Pseudodatenanfrage verwenden.
Normalerweise bieten Zeitreihendatenbanken spezialisierte Abfragesprachen an, da SQL für Zeitreihen nicht sehr gut geeignet ist. Obwohl es Datenbanken gibt, die SQL unterstützen, ist es nicht optimal. Besser geeignete Abfragesprachen sind , , , . Ich hoffe, dass jemand zumindest von einer dieser Sprachen gehört hat. Von PromQL haben wahrscheinlich viele gehört. Das ist die Abfragesprache von Prometheus.

So sieht die Architektur einer modernen Zeitreihendatenbank am Beispiel von VictoriaMetrics aus.
Sie besteht aus zwei Teilen: einem Speicher für den invertierten Index und einem Speicher für die Werte der Zeitreihen. Diese Speicher sind voneinander getrennt.
Wenn ein neuer Datensatz in die Datenbank eingeht, greifen wir zuerst auf den invertierten Index zu, um die ID der Zeitreihe anhand eines bestimmten Satzes zu finden. label=value für die betreffende Kennzahl. Wir finden diese ID und speichern den Wert im Datenspeicher.
Wenn ein Abfrageantrag an die TSDB gestellt wird, greifen wir zunächst auf den invertierten Index zu. Wir holen alle timeseries_ids Datensätze, die dem angegebenen Satz entsprechen. label=valueDann holen wir alle erforderlichen Daten aus dem nach timeseries_ids.

Lassen Sie uns ein Beispiel betrachten, wie eine Zeitreihendatenbank einen eingehenden select-Antrag verarbeitet.
- Zuerst werden alle
timeseries_idsaus dem invertierten Index abgerufen, die die angegebenen Paare enthaltenlabel=valueoder einem bestimmten regulären Ausdruck entsprechen. - Dann werden alle Datenpunkte aus dem Datenspeicher für den angegebenen Zeitraum für die gefundenen IDs abgerufen.
timeseries_ids. - Danach führt die Datenbank Berechnungen auf diesen Datenpunkten durch, entsprechend der Benutzeranfrage. Anschließend wird die Antwort zurückgegeben.
In dieser Präsentation möchte ich Ihnen den ersten Teil vorstellen. Es geht um die Suche timeseries_ids im umgekehrten Index. Den zweiten und dritten Teil können Sie sich später ansehen , oder warten, bis ich weitere Vorträge vorbereitet habe 🙂

Lassen Sie uns mit dem umgekehrten Index beginnen. Viele könnten denken, dass dies einfach ist. Wer weiß, was ein umgekehrter Index ist und wie er funktioniert? Oh, es sind nicht mehr so viele Leute. Lassen Sie uns versuchen zu verstehen, was es ist.
Eigentlich ist alles ganz einfach. Es handelt sich um ein Wörterbuch, das Schlüssel auf Werte abbildet. Was ist ein Schlüssel? Dieses Paar label=value, wobei Bezeichnung und value ist ein String-Paar. Und die Werte sind eine Menge timeseries_ids, die das gegebene Paar enthält. label=value.
Der umgekehrte Index ermöglicht ein schnelles Auffinden aller timeseries_ids, die bestimmte enthalten. label=value.
Außerdem ermöglicht er das schnelle Finden von timeseries_ids Zeitreihen für mehrere Paare label=value, oder für Paare label=regexp. Wie geschieht das? Durch das Finden der Schnittmenge der Menge timeseries_ids für jedes Paar. label=value.

Betrachten wir verschiedene Implementierungen eines Inverted Index. Beginnen wir mit der einfachsten naiven Implementierung. Sie sieht so aus.
Die Funktion getMetricIDs erhält eine Liste von Zeilen. Jede Zeile enthält label=value. Diese Funktion gibt eine Liste zurück metricIDs.
Wie funktioniert das? Hier haben wir eine globale Variable, die genannt wird invertedIndex. Das ist ein gewöhnliches Wörterbuch (map), das eine Zeichenfolge in eine Slice von int-Werten abbildet. Die Zeichenfolge enthält label=value.
Implementierung der Funktion: wir holen metricIDs für das erste label=value, dann gehen wir alle anderen durch label=value, holen wir metricIDs für sie. Und wir rufen die Funktion intersectInts, über die später gesprochen wird. Diese Funktion gibt die Schnittmenge dieser Listen zurück.

Wie Sie sehen, ist die Implementierung des Inverted Index nicht besonders kompliziert. Aber es ist eine naive Implementierung. Welche Nachteile hat sie? Der Hauptnachteil der naiven Implementierung besteht darin, dass ein solcher Inverted Index im Arbeitsspeicher gespeichert wird. Nach dem Neustart der Anwendung verlieren wir diesen Index. Es gibt keine Speicherung dieses Index auf der Festplatte. Für eine Datenbank ist ein solcher Inverted Index wahrscheinlich ungeeignet.
Ein weiteres Problem hängt ebenfalls mit dem Speicher zusammen. Der invertierte Index muss im Arbeitsspeicher abgelegt werden. Wenn er den Arbeitsspeicher übersteigt, erhalten wir einen Out-of-Memory-Fehler. In diesem Fall wird das Programm nicht funktionieren.

Dieses Problem kann mit fertigen Lösungen wie , oder .
Zusammengefasst benötigen wir eine Datenbank, die schnell drei Operationen ermöglicht.
- Die erste Operation ist das Einfügen
Schlüssel-Wert-Paarin diese Datenbank. Dies geschieht sehr schnell, wobeiSchlüssel-Wert-Paarwillkürliche Zeichenfolgen sind. - Die zweite Operation ist die schnelle Suche nach einem Wert anhand eines angegebenen Schlüssels.
- Und die dritte Operation ist die schnelle Suche nach allen Werten anhand eines angegebenen Präfixes.
LevelDB und RocksDB – diese Datenbanken wurden bei Google und Facebook entwickelt. Zuerst gab es LevelDB. Dann haben die Entwickler von Facebook LevelDB übernommen und damit begonnen, RocksDB zu verbessern. Heute laufen fast alle internen Datenbanken bei Facebook auf RocksDB, einschließlich der Migration von MySQL zu RocksDB. Sie haben es genannt .
Der invertierte Index kann mit LevelDB implementiert werden. Wie geht das? Wir speichern als Schlüssel label=value. Und als Wert den Identifier der Zeitreihe, in der das Paar vorhanden ist. label=value.
Wenn wir viele Zeitreihen mit diesem Paar haben label=value, dann gibt es viele Zeilen in dieser Datenbank mit demselben Schlüssel und unterschiedlichen timeseries_ids. Um eine Liste aller timeseries_ids, die mit diesem beginnen label=prefix, führen wir einen Bereichsscan durch, für den diese Datenbank optimiert ist. Das heißt, wir wählen alle Zeilen aus, die mit label=prefix beginnen und erhalten die benötigten timeseries_ids.

Hier ist eine ungefähre Implementierung, wie sie in Go aussehen würde. Wir haben einen invertierten Index. Das ist LevelDB.
Die Funktion ist dieselbe wie für die naïve Implementierung. Sie spiegelt die naïve Implementierung fast zeilenweise wider. Der einzige Unterschied besteht darin, dass wir anstelle eines Zugriffs auf map auf den invertierten Index zugreifen. Wir ziehen alle Werte für das erste label=value. Dann gehen wir alle verbleibenden Paare durch label=value und holen die entsprechenden Mengen von metricIDs für sie. Anschließend finden wir die Schnittmenge.

Es scheint alles gut zu sein, aber es gibt Nachteile in dieser Lösung. VictoriaMetrics implementierte zunächst den invertierten Index auf Basis von LevelDB. Letztendlich musste man jedoch davon absehen.
Warum? Weil LevelDB langsamer ist als die naïve Implementierung. In der naïven Implementierung ziehen wir sofort den gesamten Slice für den angegebenen Schlüssel. metricIDs. Diese sehr schnelle Operation – der gesamte Slice ist einsatzbereit.
In LevelDB hingegen muss bei jedem Funktionsaufruf GetValues durch alle Zeilen gegangen werden, die mit label=valuebeginnen. Und für jede Zeile muss der Wert extrahiert werden timeseries_ids. Aus diesen timeseries_ids wird ein Slice dieser timeseries_ids. Offensichtlich ist das viel langsamer als der Zugriff auf eine normale Map über den Schlüssel.
Ein weiterer Nachteil ist, dass LevelDB in C geschrieben ist. Der Zugriff auf C-Funktionen aus Go ist nicht besonders schnell. Er dauert Hunderte von Nanosekunden. Das ist nicht sehr schnell, da im Vergleich zu einem normalen Funktionsaufruf, der in Go geschrieben ist und 1-5 Nanosekunden dauert, der Leistungsunterschied mehrere Zehnerfache beträgt. Für VictoriaMetrics war das ein fataler Nachteil 🙂

Deshalb habe ich meine eigene Implementierung eines invertierten Index geschrieben. Ich nannte sie .
Mergeset basiert auf der Datenstruktur MergeTree. Diese Datenstruktur stammt von ClickHouse. Offensichtlich muss mergeset für schnelle Suchen timeseries_ids nach einem bestimmten Schlüssel optimiert sein. Mergeset ist vollständig in Go geschrieben. Sie können die einsehen. Die Implementierung von mergeset befindet sich im Ordner . Sie können versuchen, herauszufinden, was dort passiert.
Die API-Mergeset ist sehr ähnlich wie LevelDB und RocksDB. Das bedeutet, dass es ermöglicht, neue Einträge schnell zu speichern und schnell Einträge anhand eines bestimmten Präfixes auszuwählen.

Über die Nachteile von Mergeset werden wir später sprechen. Jetzt besprechen wir die Probleme, die bei der Implementierung des invertierten Index in VictoriaMetrics in der Produktion aufgetreten sind.
Warum sind sie aufgetreten?
Der erste Grund ist die hohe Fluktuation. Dies bedeutet, dass es häufige Wechsel zwischen Zeitserien gibt. Wenn eine Zeitreihe endet und eine neue beginnt oder viele neue Zeitserien anfangen. Und das geschieht oft.
Der zweite Grund ist die große Anzahl an Zeitserien. Zunächst, als das Monitoring populär wurde, war die Anzahl der Zeitserien gering. Zum Beispiel muss die Auslastung von CPU, Speicher, Netzwerk und Festplatte für jeden Computer überwacht werden. 4 Zeitserien pro Computer. Angenommen, Sie haben 100 Computer und 400 Zeitserien. Das ist sehr wenig.
Im Laufe der Zeit haben die Menschen herausgefunden, dass man detailliertere Informationen messen kann. Zum Beispiel kann man nicht nur die Auslastung des gesamten Prozessors, sondern auch die jedes einzelnen Prozessorkerns messen. Wenn Sie 40 Prozessorkerne haben, stehen Ihnen entsprechend 40-mal mehr Zeitreihen zur Verfügung, um die Belastung des Prozessors zu messen.
Aber das ist noch nicht alles. Jeder Prozessorkern kann mehrere Zustände haben, wie zum Beispiel idle, wenn er inaktiv ist. Dazu zählen auch die Nutzung im User-Space, die Arbeit im Kernel-Space und andere Zustände. Und jeder dieser Zustände kann ebenfalls als separate Zeitreihe gemessen werden. Dies erhöht die Anzahl der Zeitreihen um das 7- bis 8-fache.
Aus einer einzigen Metrik haben wir 40 x 8 = 320 Metriken nur für einen Computer erhalten. Multiplizieren wir das mit 100, erhalten wir 32.000 statt 400.
Dann kam Kubernetes. Und es wurde sogar komplizierter, da in Kubernetes viele verschiedene Dienste gehostet werden können. Jeder Dienst in Kubernetes besteht aus vielen Pods, und all dies muss überwacht werden. Darüber hinaus erfolgt ständig der Rollout neuer Versionen Ihrer Dienste. Für jede neue Version müssen neue Zeitreihen erstellt werden. Infolgedessen wächst die Anzahl der Zeitreihen exponentiell, und wir stehen vor dem Problem einer großen Anzahl von Zeitreihen, das als High-Cardinality bekannt ist. VictoriaMetrics bewältigt dies im Vergleich zu anderen Zeitreihendatenbanken erfolgreich.

Lassen Sie uns den hohen Churn-Rate genauer betrachten. Was verursacht eine hohe Churn-Rate in der Produktion? Weil einige Werte von Labels und Tags sich ständig ändern.
Nehmen wir beispielsweise Kubernetes, wo es das Konzept gibt deployment, d. h. wenn eine neue Version Ihrer Anwendung ausgerollt wird. Die Entwickler von Kubernetes haben scheinbar beschlossen, die ID des Deployments in das Label einzufügen.
Was hat das zur Folge? Bei jedem neuen Deployment werden alle alten Zeitreihen unterbrochen, und stattdessen beginnen neue Zeitreihen mit einem neuen Wert des Labels deployment_id. Solche Zeitreihen können Hunderttausende oder sogar Millionen betragen.
Eine wichtige Eigenschaft dabei ist, dass die Gesamtzahl der Zeitreihen wächst, aber die Anzahl der momentan aktiven Zeitreihen, für die Daten empfangen werden, konstant bleibt. Ein solcher Zustand wird als – hoher Wechselraten bezeichnet.
Das Hauptproblem der hohen Wechselrate besteht darin, eine konstante Suchgeschwindigkeit für alle Zeitreihen anhand eines bestimmten Satzes von Labels über einen bestimmten Zeitraum zu gewährleisten. Normalerweise ist dies der Zeitraum der letzten Stunde oder des letzten Tages.

Wie kann man dieses Problem lösen? Hier ist die erste Möglichkeit. Man teilt den umgekehrten Index in unabhängige Teile nach Zeit. D. h. ein bestimmter Zeitraum vergeht, wir beenden die Arbeit mit dem aktuellen umgekehrten Index. Und erstellen einen neuen umgekehrten Index. Ein weiterer Zeitraum vergeht, wir erstellen einen weiteren und noch einen.
Und bei der Abfrage dieser umgekehrten Indizes finden wir eine Reihe von umgekehrten Indizes, die in den angegebenen Zeitraum fallen. Und entsprechend wählen wir die IDs der Zeitreihen daraus aus.
Das spart Ressourcen, da wir keine Teile durchsehen müssen, die nicht in den angegebenen Zeitraum fallen. Das bedeutet, wenn wir Daten für die letzte Stunde auswählen, überspringen wir normalerweise die Anfragen für die vorherigen Zeitintervalle.

Es gibt eine weitere Lösung für dieses Problem. Diese besteht darin, für jeden Tag eine separate Liste von IDs der Zeitreihen zu speichern, die an diesem Tag aufgetreten sind.
Der Vorteil dieser Lösung im Vergleich zur vorherigen liegt darin, dass wir keine Informationen über Zeitreihen duplizieren, die im Laufe der Zeit nicht verschwinden. Sie sind konstant vorhanden und ändern sich nicht.
Der Nachteil dabei ist, dass eine solche Lösung komplexer umzusetzen und schwieriger zu debuggen ist. Außerdem hat VictoriaMetrics diese Lösung gewählt, was historisch bedingt ist. Diese Lösung schneidet ebenfalls gut ab im Vergleich zur vorherigen. Der Grund dafür ist, dass diese Lösung nicht umgesetzt wurde, weil man die Daten in jeder Partition für Zeitreihen duplizieren muss, die sich nicht ändern, d. h. die im Laufe der Zeit nicht verschwinden. VictoriaMetrics wurde in erster Linie auf den Verbrauch von Speicherplatz optimiert, und die vorherige Implementierung verschlechterte diesen. Diese Umsetzung eignet sich besser zur Minimierung des Speicherplatzverbrauchs, weshalb sie gewählt wurde.
Wir mussten uns damit auseinandersetzen. Der Kampf bestand darin, dass in dieser Implementierung immer noch eine viel größere Menge timeseries_ids an Daten ausgewählt werden muss als wenn der invertierte Index zeitlich getrennt ist.

Wie haben wir dieses Problem gelöst? Wir haben es auf originelle Weise gelöst – indem wir mehrere Identifikatoren von Zeitreihen in jedem Eintrag des invertierten Index anstelle eines einzigen Identifikators gespeichert haben. D. h. wir haben einen Schlüssel label=value, der in jeder Zeitreihe vorkommt. Und jetzt speichern wir mehrere timeseries_ids in einem Datensatz.
Hier ist ein Beispiel. Früher hatten wir N Datensätze, und jetzt haben wir einen Datensatz, dessen Präfix mit dem aller anderen übereinstimmt. Der vorherige Datensatz enthielt alle IDs der Zeitreihen.
Dadurch konnte die Geschwindigkeit des Scans eines solchen invertierten Indexes um das 10-fache gesteigert werden. Außerdem wurde der Speicherverbrauch für den Cache reduziert, da wir jetzt die Zeile label=value nur einmal im Cache speichern, statt N-mal. Und diese Zeile kann groß sein, wenn in Ihren Tags und Labels lange Zeilen gespeichert werden, die Kubernetes gerne dort unterbringt.

Eine weitere Möglichkeit, die Suche über den invertierten Index zu beschleunigen, ist das Sharding. Die Erstellung mehrerer invertierter Indizes anstelle eines einzelnen und das Sharding der Daten zwischen ihnen nach Schlüssel. Dies ist eine Sammlung key=value Das bedeutet, dass wir mehrere unabhängige invertierte Indizes erzeugen, die wir parallel auf mehreren Prozessoren abfragen können. Frühere Implementierungen erlaubten nur den Betrieb im Einprozessor-Modus, das heißt, die Daten konnten nur auf einem Kern gescannt werden. Diese Lösung ermöglicht es, Daten gleichzeitig auf mehreren Kernen zu scannen, so wie es ClickHouse bevorzugt. Das planen wir umzusetzen.

Kommen wir nun zu unserem Thema – der Schnittmengenfunktion timeseries_ids. Lassen Sie uns anschauen, welche Implementierungen möglich sind. Diese Funktion ermöglicht es, timeseries_ids für eine gegebene Menge label=value.

Die erste Variante ist eine naive Implementierung. Zwei verschachtelte Schleifen. Hier erhalten wir als Eingabe der Funktionen intersectInts zwei Slices — a und b. Im Ergebnis sollte sie uns die Schnittmenge dieser Slices zurückgeben.
Die naive Implementierung sieht so aus. Wir gehen alle Werte aus dem Slice adurch, und innerhalb dieser Schleife gehen wir alle Werte aus dem Slice bdurch. Und vergleichen sie. Wenn sie übereinstimmen, haben wir die Schnittmenge gefunden. Und speichern sie in result.

Welche Nachteile gibt es? Die quadratische Komplexität ist ihr größter Nachteil. Beispielsweise, wenn Sie die Größen der Slices haben. a und b Bei einer Million würde diese Funktion Ihnen niemals eine Antwort zurückgeben. Denn sie müsste eine Billion Iterationen durchführen, was selbst für moderne Computer sehr viel ist.

Die zweite Implementierung basiert auf einer Map. Wir erstellen eine Map und fügen alle Werte aus dem Slice hinzu. aAnschließend durchlaufen wir das Slice mit einer separaten Schleife. bUnd überprüfen, ob dieser Wert aus dem Slice b in der Map vorhanden ist. Wenn ja, fügen wir ihn zum Ergebnis hinzu.

Welche Vorteile bietet das? Der Vorteil liegt darin, dass hier nur eine lineare Komplexität besteht. Das heißt, die Funktion wird für große Größen des Slices viel schneller ausgeführt. Für eine Slice-Größe von einer Million wird diese Funktion in zwei Millionen Iterationen durchgeführt, im Gegensatz zu einer Billion Iterationen wie in der vorherigen Funktion.
Ein Nachteil ist jedoch, dass diese Funktion mehr Speicher benötigt, um diese Map zu erstellen.
Ein weiterer Nachteil ist der hohe Overhead beim Hashing. Dieser Nachteil ist nicht sehr offensichtlich. Und auch für uns war er nicht sehr offensichtlich, weshalb die Implementierung der Schnittmenge (intersection) in VictoriaMetrics zunächst über eine Map erfolgte. Das Profiling zeigte jedoch später, dass die meiste CPU-Zeit für das Schreiben in die Map und die Überprüfung der Existenz eines Wertes in dieser Map aufgewendet wird.
Warum wird in diesen Bereichen CPU-Zeit verbraucht? Weil Go in diesen Zeilen eine Hash-Berechnung durchführt. Das bedeutet, es berechnet den Hash des Schlüssels, um dann über den angegebenen Index im HashMap zuzugreifen. Die Berechnung des Hashs erfolgt in nur wenigen Nanosekunden. Das ist für VictoriaMetrics langsam.

Ich habe beschlossen, ein für diesen Fall optimiertes Bitset zu implementieren. So sieht jetzt die Schnittstelle zwischen zwei Slices aus. Hier erstellen wir ein Bitset. Wir fügen ihm Elemente aus dem ersten Slice hinzu. Dann überprüfen wir, ob diese Elemente im zweiten Slice vorhanden sind. Und wir fügen sie dem Ergebnis hinzu. Das heißt, es unterscheidet sich fast nicht von dem vorherigen Beispiel. Lediglich haben wir hier den Zugriff auf die Map durch benutzerdefinierte Funktionen ersetzt. add und has.

Auf den ersten Blick scheint es, als ob das langsamer arbeiten sollte, wenn zuvor eine Standard-Map verwendet wurde und hier zusätzliche Funktionen aufgerufen werden. Aber das Profiling zeigt, dass dieses System zehnmal schneller ist als die Standard-Map für den Fall von VictoriaMetrics.
Darüber hinaus benötigt es viel weniger Speicher im Vergleich zur Implementierung mit Map. Denn hier speichern wir Bits anstelle von acht-Bit-Werten.
Ein Nachteil dieser Implementierung ist, dass sie nicht so offensichtlich und nicht trivial ist.
Ein weiterer Nachteil, der vielen möglicherweise nicht auffällt, ist, dass diese Implementierung in bestimmten Fällen schlecht funktionieren kann. Das bedeutet, dass sie für einen spezifischen Anwendungsfall optimiert ist, nämlich für den Fall des Überschneidens von IDs in den Zeitreihen von VictoriaMetrics. Das heißt nicht, dass sie für alle Fälle geeignet ist. Wenn sie falsch angewendet wird, kann dies nicht zu einer Leistungssteigerung, sondern zu einem Out-of-Memory-Fehler und einer Verlangsamung der Leistung führen.

Betrachten wir die Implementierung dieser Struktur. Wenn Sie sehen möchten, finden Sie sie im Quellcode von VictoriaMetrics, im Ordner . Sie ist speziell für den Fall von VictoriaMetrics optimiert, bei dem timeseries_id ein 64-Bit-Wert darstellt, bei dem die ersten 32 Bit konstant sind und nur die letzten 32 Bit variieren.
Diese Datenstruktur wird nicht auf der Festplatte gespeichert; sie arbeitet nur im Speicher.

Hier ist ihr API. Es ist nicht sehr kompliziert. Das API ist genau auf das spezifische Beispiel der Verwendung in VictoriaMetrics abgestimmt. Das heißt, hier gibt es keine unnötigen Funktionen. Es gibt hier nur die Funktionen, die VictoriaMetrics eindeutig verwendet.
Es gibt Funktionen add, die neue Werte hinzufügen. Es gibt eine Funktion has, die neue Werte überprüft. Es gibt auch eine Funktion del, die Werte entfernt. Es gibt eine Hilfsfunktion len, die die Größe der Menge zurückgibt. Funktion clone klont die Menge. Und Funktion appendto konvertiert diese Menge in einen Slice timeseries_ids.

So sieht die Implementierung dieser Datenstruktur aus. In der Menge gibt es zwei Elemente:
ItemsCount– ein Hilfsfeld, um schnell die Anzahl der Elemente in der Menge zurückzugeben. Man könnte auf dieses Hilfsfeld verzichten, aber es wurde hier hinzugefügt, weil VictoriaMetrics häufig die Länge des Bitsets in seinen Algorithmen abfragt.Das zweite Feld ist
buckets. Dies ist ein Slice der Strukturbucket32. In jeder Struktur wird gespeicherthiFeld. Dies sind die oberen 32 Bits. Und zwei Slices —b16hisundbucketsvonbucket16Strukturen.
Hier werden die oberen 16 Bits des zweiten Teils der 64-Bit-Struktur gespeichert. Und hier werden die Bitsets für die unteren 16 Bits jedes Bytes gespeichert.
Bucket64 besteht aus einem Array uint64. Die Länge wird mit diesen Konstanten berechnet. In einer bucket16 kann maximal 2^16=65536 Bits gespeichert werden. Teilt man das durch 8, erhält man 8 Kilobyte. Teilt man es nochmal durch 8, ergibt das 1000 uint64 Wert. Das heißt, Bucket16 – das ist eine 8-Kilobyte-Struktur.

Lassen Sie uns betrachten, wie eine der Methoden dieser Struktur zur Hinzufügung eines neuen Wertes implementiert ist.
Alles beginnt mit uint64 dem Wert. Wir berechnen die oberen 32 Bit, berechnen die unteren 32 Bit. Wir durchlaufen alle buckets. Wir vergleichen die oberen 32 Bit in jedem Bucket mit dem hinzuzufügenden Wert. Wenn sie übereinstimmen, rufen wir die Funktion add in der Struktur b32 buckets. Und fügen die unteren 32 Bit dort hinzu. Wenn dies truezurückgibt, bedeutet das, dass wir diesen Wert hinzugefügt haben und wir ihn nicht hatten. Wenn es falsezurückgibt, dann war dieser Wert bereits vorhanden. Dann erhöhen wir die Anzahl der Elemente in der Struktur.
Wenn wir den erforderlichen bucket mit dem passenden hi-Wert nicht gefunden haben, rufen wir die Funktion addAlloc, die einen neuen bucketzuteilt, indem sie ihn zur Bucket-Struktur hinzufügt.

Dies ist die Implementierung der Funktion b32.add. Sie ähnelt der vorherigen Implementierung. Wir berechnen die oberen 16 Bit, die unteren 16 Bit.
Dann durchlaufen wir alle oberen 16 Bit. Finden Übereinstimmungen. Und bei einer Übereinstimmung rufen wir die Methode add auf, die wir auf der nächsten Seite für bucket16.

beleuchten werden. Und dies ist die unterste Ebene, die maximal optimiert werden sollte. Wir berechnen für uint64 die id den Wert im Slice-Bit sowie den Bitmasken.. Diese Maske für den 64-Bit-Wert ermöglicht es, das Vorhandensein eines bestimmten Bits zu überprüfen oder es zu setzen. Wir prüfen, ob dieses Bit gesetzt ist, setzen es gegebenenfalls und geben zurück, ob es vorhanden ist. Diese Implementierung hat es uns ermöglicht, die Schnittstellenoperation von Zeitreihen-IDs im Vergleich zu herkömmlichen Maps um das Zehnfache zu beschleunigen.

Neben dieser Optimierung bietet VictoriaMetrics noch viele weitere Optimierungen. Die meisten dieser Optimierungen wurden nicht ohne Grund hinzugefügt, sondern nach der Profilerstellung des Codes in der Produktion.
Die wichtigste Regel bei der Optimierung lautet: keine Optimierungen hinzufügen, nur weil man annimmt, dass hier ein Engpass besteht, denn es könnte sich herausstellen, dass dort kein Engpass existiert. Optimierungen verschlechtern normalerweise die Codequalität. Daher sollte man nur nach der Profilerstellung optimieren und idealerweise in der Produktion, um reale Daten zu verwenden. Wer interessiert ist, kann den Quellcode von VictoriaMetrics einsehen und die anderen Optimierungen dort studieren.

Ich habe eine Frage zu bitset. Es ähnelt sehr der Implementierung von C++ vector, einem optimierten bitset. Haben Sie die Implementierung von dort übernommen?
Nein, nicht von dort. Bei der Implementierung dieses Bitsets habe ich mich an das Wissen über die Struktur dieser IDs in Time Series orientiert, die in VictoriaMetrics verwendet werden. Ihre Struktur sieht vor, dass die oberen 32 Bits im Wesentlichen konstant sind. Die unteren 32 Bits können sich ändern. Je geringer das Bit, desto häufiger kann es sich ändern. Daher ist diese Implementierung speziell auf diese Datenstruktur optimiert. Soweit ich weiß, ist die C++-Implementierung auf den allgemeinen Fall optimiert. Eine Optimierung für den allgemeinen Fall bedeutet, dass sie in einem spezifischen Fall nicht die optimale Lösung sein wird.
Ich empfehle Ihnen außerdem, den Vortrag von Alexey Milovid zu betrachten. Er hat vor etwa einem Monat über Optimierungen in ClickHouse für spezifische Spezialisierungen gesprochen. Er erläutert gerade, dass die C++-Implementierung oder eine andere Implementierung im Allgemeinen darauf ausgelegt sind, im Durchschnitt gut zu funktionieren. Sie kann schlechter abschneiden als eine spezialisierte Implementierung, die auf spezifisches Wissen zugeschnitten ist, wie bei uns, wenn wir wissen, dass die oberen 32 Bits im Wesentlichen konstant sind.
Ich habe eine zweite Frage. Was ist der grundlegende Unterschied zu InfluxDB?
Es gibt viele grundlegende Unterschiede. In Bezug auf Leistung und Speicherverbrauch zeigt InfluxDB in Tests einen zehnmal höheren Speicherverbrauch bei hochgradigen Zeitreihen, insbesondere wenn sie zahlreich sind, zum Beispiel Millionen. Zum Beispiel benötigt VictoriaMetrics 1 GB für eine Million aktiver Reihen, während InfluxDB dabei 10 GB benötigt. Das ist ein erheblicher Unterschied.
Ein weiterer wesentlicher Unterschied besteht darin, dass InfluxDB eigenartige Abfragesprachen – Flux und InfluxQL – verwendet. Diese sind im Vergleich zu PromQL, der in VictoriaMetrics unterstützt wird, nicht besonders benutzerfreundlich für die Arbeit mit Zeitreihen.
Ein weiteres Unterscheidungsmerkmal ist, dass InfluxDB ein etwas merkwürdiges Datenmodell hat, in dem jede Zeile mehrere Felder mit unterschiedlichen Tag-Sets speichern kann. Diese Zeilen werden zudem in verschiedene Tabellen unterteilt. Diese zusätzlichen Verkomplizierungen erschweren die spätere Arbeit mit dieser Datenbank. Sie ist schwer zu pflegen und zu verstehen.
In VictoriaMetrics ist alles viel einfacher. Dort stellt jede Zeitreihe ein Schlüssel-Wert-Paar dar. Der Wert ist eine Sammlung von Punkten – (Zeitstempel, Wert)und der Schlüssel ist ein Set. label=value. Es gibt keine Trennung zwischen Feldern und Messungen. Dies ermöglicht es Ihnen, beliebige Daten auszuwählen und diese zu kombinieren, zu addieren, zu subtrahieren, zu multiplizieren und zu dividieren, im Gegensatz zu InfluxDB, wo Berechnungen zwischen verschiedenen Zeilen meines Wissens nach immer noch nicht realisiert sind. Selbst wenn sie implementiert wurden, ist es schwierig und erfordert eine Menge Code.
Ich habe eine klärende Frage. Habe ich richtig verstanden, dass Sie von einem Problem gesprochen haben, bei dem dieser invertierte Index nicht in den Speicher passt, weshalb die Partitionierung notwendig ist?
Zunächst habe ich eine naive Implementierung des invertierten Index auf der Standard-Map von Go gezeigt. Eine solche Implementierung eignet sich nicht für Datenbanken, da dieser invertierte Index nicht auf der Festplatte gespeichert wird, und eine Datenbank muss auf der Festplatte speichern, damit diese Daten nach einem Neustart verfügbar bleiben. Bei dieser Implementierung würde der invertierte Index beim Neustart der Anwendung verschwinden. Und Sie verlieren den Zugang zu allen Daten, da Sie sie nicht finden können.
Hallo! Vielen Dank für den Vortrag! Ich bin Pavel von der Firma Wildberries. Ich habe ein paar Fragen an Sie. Die erste Frage: Glauben Sie, dass es möglich gewesen wäre, wenn Sie einen anderen Ansatz beim Aufbau der Architektur Ihrer Anwendung gewählt und die Daten nach Zeit partitioniert hätten? Wären Sie dann in der Lage gewesen, Datenintersektionen bei der Suche durchzuführen, nur basierend auf den Daten, die in einer Partition für einen bestimmten Zeitraum liegen, das heißt in einem Zeitintervall, sodass Sie sich keine Gedanken darüber machen müssten, dass Ihre Datenstücke unterschiedlich verteilt sind? Die zweite Frage — da Sie einen solchen Algorithmus mit Bitset und allem anderen implementieren, haben Sie vielleicht auch versucht, Prozessoranweisungen zu verwenden? Möglicherweise haben Sie solche Optimierungen ausprobiert?
Ich werde die zweite Frage direkt beantworten. Wir sind dort noch nicht angekommen. Aber wenn es notwendig ist, werden wir das tun. Und was war die erste Frage?
Sie haben zwei Szenarien besprochen. Und gesagt, dass Sie die zweite Implementierung mit mehr Komplexität gewählt haben und nicht die erste, bei der die Daten nach Zeit partitioniert sind.
Ja. Im ersten Fall wäre das gesamte Indexvolumen größer, da wir in jeder Partition doppelte Daten für die Zeitreihen speichern müssten, die sich über alle diese Partitionen erstrecken. Und wenn Ihre Churn-Rate bei den Zeitreihen niedrig ist, d. h. ständig die gleichen Reihen verwendet werden, hätten wir im ersten Fall im Vergleich zum zweiten Fall wesentlich mehr an Speicherplatz verloren.
So ist es – ja, die zeitliche Partitionierung ist eine gute Option. Das verwendet Prometheus. Aber Prometheus hat einen anderen Nachteil. Beim Zusammenführen dieser Datenstücke benötigt es, um die Metainformationen zu allen Labels und Zeitreihen im Speicher zu halten. Daher steigt der Speicherverbrauch beim Zusammenführen erheblich, wenn die Datenstücke groß sind, im Gegensatz zu VictoriaMetrics. Bei der Zusammenführung verbraucht VictoriaMetrics überhaupt keinen Speicher; es werden nur einige Kilobyte benötigt, unabhängig von der Größe der zusammengeführten Datenstücke.
Der verwendete Algorithmus benötigt Speicher. Darin werden Zeitstempel markiert, die Werte enthalten. So prüfen Sie das Vorhandensein von Paaren in einem Datensatz und einem anderen. Sie erkennen, ob ein Schnittpunkt stattgefunden hat oder nicht. Normalerweise implementieren Datenbanken Cursors und Iteratoren, die ihren aktuellen Status beibehalten und über sortierte Daten iterieren, was eine einfache Komplexität dieser Operationen ermöglicht.
Warum verwenden wir keine Cursors zum Überschneiden von Daten?
Ja.
In LevelDB oder im Mergeset werden genau die sortierten Zeilen gespeichert. Wir könnten einen Cursor verwenden, um das Überschneiden zu finden. Aber warum nutzen wir das nicht? Weil es langsam ist. Cursors setzen voraus, dass für jede Zeile eine Funktion aufgerufen werden muss. Der Funktionsaufruf dauert 5 Nanosekunden. Und wenn Sie 100.000.000 Zeilen haben, verbringen wir also eine halbe Sekunde nur mit dem Funktionsaufruf.
Ja, das gibt es. Und meine letzte Frage. Die Frage klingt vielleicht ein wenig seltsam. Warum können die notwendigen Aggregationen beim Eintreffen der Daten nicht sofort berechnet und in der benötigten Form gespeichert werden? Warum große Mengen in Systeme wie VictoriaMetrics, ClickHouse usw. speichern, um dann viel Zeit dafür aufzuwenden?
Ich gebe ein Beispiel, damit es verständlicher wird. Nehmen wir an, wie funktioniert ein kleiner Spielzeug-Tachometer? Er zeichnet die Strecke auf, die Sie zurückgelegt haben, indem er diese ständig zu einer Größe hinzufügt, zu einer anderen — die Zeit. Und teilt. Und erhält die Durchschnittsgeschwindigkeit. Man könnte ungefähr dasselbe tun. Alle benötigten Fakten im Flug aufsummieren.
Gut, ich habe die Frage verstanden. Ihr Beispiel hat seine Daseinsberechtigung. Wenn Sie wissen, welche Aggregationen Sie benötigen, ist das die beste Vorgehensweise. Das Problem ist jedoch, dass Menschen diese Metriken und Daten in ClickHouse speichern, ohne zu wissen, wie sie diese in Zukunft aggregieren oder filtern werden. Daher behalten sie alle Rohdaten. Wenn Sie jedoch wissen, dass Sie etwas Durchschnittliches berechnen müssen, warum sollten Sie dann nicht gleich das berechnen, anstatt eine Menge Rohwerte zu speichern? Aber das gilt nur, wenn Sie genau wissen, was Sie brauchen.
Übrigens unterstützen Datenbanken für zeitliche Reihen die Aggregationsberechnung. Zum Beispiel unterstützt Prometheus . Das heißt, das ist möglich, wenn Sie wissen, welche Aggregationen Sie benötigen. VictoriaMetrics hat das derzeit nicht, aber normalerweise wird Prometheus davor gesetzt, wo Sie dies in den Recoding Rules tun können.
Zum Beispiel musste ich in meinem vorherigen Job die Anzahl der Ereignisse in einem gleitenden Fenster der letzten Stunde zählen. Das Problem dabei war, dass ich eine benutzerdefinierte Implementierung in Go erstellen musste, also einen Dienst, um diese Zählung durchzuführen. Diese Implementierung war am Ende nicht trivial, da es schwierig ist, dies zu zählen. Die Umsetzung kann einfach sein, wenn Sie einige Aggregaten in festen Zeitintervallen zählen müssen. Wenn Sie jedoch Ereignisse in einem gleitenden Fenster zählen wollen, ist es nicht so einfach, wie es scheint. Ich glaube, dass dies bis heute nicht in ClickHouse oder in Zeitreihen-Datenbanken implementiert ist, da es schwierig umzusetzen ist.
Und eine weitere Frage. Wir haben jetzt über die Mittelung gesprochen und ich erinnere mich, dass es einmal so etwas wie Graphite mit einem Backend namens Carbon gab. Dieses konnte alte Daten reduzieren, d.h. eine Punkt pro Minute, einen Punkt pro Stunde usw. lassen. Grundsätzlich ist das ziemlich praktisch, wenn wir rohe Daten, sagen wir mal, für einen Monat benötigen, und alle anderen können reduziert werden. Aber Prometheus und VictoriaMetrics unterstützen diese Funktionalität nicht. Wird diese Unterstützung geplant? Wenn nicht, warum nicht?
Danke für die Frage. Unsere Nutzer stellen sie gelegentlich. Sie fragen, wann wir die Unterstützung für Downsampling hinzufügen. Hier gibt es mehrere Probleme. Erstens hat jeder Benutzer ein eigenes Verständnis davon, Downsampling was Downsampling bedeutet: Manche möchten einen beliebigen Punkt in einem bestimmten Intervall, andere wünschen sich die maximalen, minimalen oder durchschnittlichen Werte. Wenn viele Systeme Daten in Ihre Datenbank schreiben, kann man diese nicht über einen Kamm scheren. Es kann sich herausstellen, dass für jedes System unterschiedliche Downsampling-Methoden notwendig sind. Und das ist in der Umsetzung kompliziert.
Zweitens ist VictoriaMetrics, wie auch ClickHouse, darauf optimiert, mit großen Mengen Rohdaten zu arbeiten. Deshalb kann sie eine Milliarde Zeilen in weniger als einer Sekunde verarbeiten, wenn Sie über viele Kerne in Ihrem System verfügen. Das Scannen von Zeitreihendaten in VictoriaMetrics erreicht 50.000.000 Punkte pro Sekunde auf einen Kern. Und diese Leistung skaliert mit den vorhandenen Kernen. Das heißt, wenn Sie beispielsweise 20 Kerne haben, erreicht man eine Scanning-Geschwindigkeit von einer Milliarde Punkten pro Sekunde. Diese Eigenschaft von VictoriaMetrics und ClickHouse verringert den Bedarf an Downsampling.
Ein weiteres Merkmal ist, dass VictoriaMetrics diese Daten effizient komprimiert. Die Kompression liegt im Durchschnitt bei 0,4 bis 0,8 Byte pro Punkt in der Produktion. Jeder Punkt besteht aus einem Zeitstempel + einem Wert. Und er wird im Durchschnitt auf weniger als ein Byte komprimiert.
Sergej. Ich habe eine Frage. Was ist der minimale Zeitquantum für die Aufzeichnung?
Eine Millisekunde. Kürzlich hatten wir ein Gespräch mit anderen Entwicklern von Zeitreihendatenbanken. Ihr minimales Zeitquantum beträgt eine Sekunde. In Graphite beispielsweise auch eine Sekunde. In OpenTSDB ebenfalls eine Sekunde. In InfluxDB – Nanosekundenpräzision. In VictoriaMetrics – eine Millisekunde, weil in Prometheus eine Millisekunde verwendet wird. Und VictoriaMetrics wurde ursprünglich als Remote-Speicher für Prometheus entwickelt. Aber jetzt kann es auch Daten aus anderen Systemen speichern.
Der Mensch, mit dem ich gesprochen habe, sagt, dass sie eine Sekundengenauigkeit haben – das reicht ihnen, weil es von der Art der Daten abhängt, die in die Zeitreihendatenbank gespeichert werden. Wenn es sich um DevOps-Daten oder Infrastruktur-Daten handelt, die mit einem Intervall von 30 Sekunden oder einer Minute gesammelt werden, dann ist eine Sekundengenauigkeit ausreichend; weniger braucht man nicht. Wenn die Daten jedoch aus Hochfrequenzhandelssystemen gesammelt werden, ist Nanosekundengenauigkeit erforderlich.
Millisekundengenauigkeit in VictoriaMetrics eignet sich sowohl für DevOps-Anwendungen als auch für die meisten Anwendungsfälle, die ich zu Beginn der Präsentation erwähnt habe. Das einzige, wofür es möglicherweise nicht geeignet ist, sind Hochfrequenzhandelssysteme.
Danke! Und noch eine Frage. Wie sieht es mit der Kompatibilität in PromQL aus?
Vollständige Abwärtskompatibilität. VictoriaMetrics unterstützt vollständig PromQL. Darüber hinaus bietet es zusätzliche erweiterte Funktionalitäten für PromQL, die genannt werden . Zu dieser erweiterten Funktionalität gibt es einen Vortrag auf YouTube. Ich habe im Frühjahr auf dem Monitoring Meetup in St. Petersburg darüber gesprochen.
einen Telegram-Kanal .
Nur registrierte Benutzer können an der Umfrage teilnehmen. Sind Sie an Contour interessiert?
Was hindert Sie daran, zu VictoriaMetrics als langfristigem Speicher für Prometheus zu wechseln? (Bitte in die Kommentare schreiben, ich werde es zur Umfrage hinzufügen))
71,4%Ich benutze Prometheus5 nicht
28,6%Ich kannte VictoriaMetrics2 nicht
7 Benutzer haben abgestimmt. 12 Benutzer haben sich enthalten.
Quelle: habr.com
