Die analytische Datenbank ClickHouse verarbeitet eine Vielzahl unterschiedlicher Zeichenfolgen und verbraucht dabei Ressourcen. Um die Systemleistung zu beschleunigen, werden ständig neue Optimierungen hinzugefügt. Der Entwickler von ClickHouse, Nikolai Kochetov, spricht über den Datentyp String, einschließlich des neuen Typs LowCardinality, und erklärt, wie man die Arbeit mit Zeichenfolgen beschleunigen kann.

— Lassen Sie uns zunächst klären, wie Zeichenfolgen gespeichert werden können.

Wir verfügen über Datentypen für Zeichenfolgen. String eignet sich standardmäßig gut und sollte fast immer verwendet werden. Er hat einen geringen Overhead von 9 Byte pro Zeichenfolge. Wenn wir möchten, dass die Größe der Zeichenfolgen festgelegt und im Voraus bekannt ist, sollten wir besser FixedString verwenden. Hier können wir die gewünschte Anzahl an Bytes festlegen, was für Daten wie IP-Adressen oder Hash-Funktionen praktisch ist.

Natürlich wird manchmal etwas langsamer. Angenommen, Sie stellen eine Anfrage an eine Tabelle. ClickHouse liest eine erhebliche Menge an Daten, sagen wir mit einer Geschwindigkeit von 100 GB/s, während nur wenige Zeichenfolgen verarbeitet werden. Wir haben zwei Tabellen, die fast identische Daten speichern. Aus der zweiten Tabelle liest ClickHouse die Daten schneller, aber die Anzahl der pro Sekunde gelesenen Zeichenfolgen ist dreimal geringer.

Wenn wir die Größe der komprimierten Daten betrachten, wird sie fast gleich sein. Tatsächlich sind in den Tabellen die gleichen Daten gespeichert — die erste Milliarde Zahlen — nur im ersten Datenfeld sind sie als UInt64 gespeichert, im zweiten als String. Aus diesem Grund benötigt die zweite Anfrage länger, um die Daten von der Festplatte zu lesen und sie zu dekomprimieren.

Hier ein weiteres Beispiel. Angenommen, es gibt eine vorher bekannte Menge von Zeichenfolgen, die durch eine Konstante von 1000 oder 10.000 beschränkt und so gut wie nie verändert wird. Für diesen Fall eignet sich der Datentyp Enum, von dem es in ClickHouse zwei gibt — Enum8 und Enum16. Durch die Speicherung in Enum können wir Anfragen schnell verarbeiten.
In ClickHouse gibt es Beschleunigungen für GROUP BY, IN, DISTINCT und Optimierungen für einige Funktionen, zum Beispiel für den Vergleich mit einer konstanten Zeichenfolge. Natürlich werden Zahlen in Zeichenfolgen nicht umgewandelt, sondern im Gegenteil, die konstante Zeichenfolge wird in den Wert Enum umgewandelt. Danach erfolgt der Vergleich schnell.
Es gibt jedoch auch Nachteile. Selbst wenn wir die genaue Menge an Zeichenfolgen kennen, muss sie manchmal erweitert werden. Eine neue Zeichenfolge ist eingetroffen — wir müssen ein ALTER durchführen.

ALTER für Enum in ClickHouse ist optimal umgesetzt. Wir schreiben die Daten auf der Festplatte nicht neu, aber ALTER kann wegen der Tatsache, dass die Enum-Strukturen im Schema der Tabelle gespeichert sind, etwas langsam sein. Daher müssen wir auf Leseanforderungen aus der Tabelle warten, zum Beispiel.
Die Frage stellt sich, ob man es besser machen kann? Wahrscheinlich ja. Man könnte die Enum-Struktur nicht im Tabellenschema, sondern in ZooKeeper speichern. Allerdings könnten Synchronisationsprobleme auftreten. Zum Beispiel, wenn eine Replik Daten erhält, die andere nicht, und wenn sie dann ein altes Enum hat, könnte etwas kaputtgehen. (In ClickHouse haben wir fast die nicht-blockierenden ALTER-Abfragen fertiggestellt. Wenn wir sie vollständig abschließen, müssen wir nicht mehr auf Leseanforderungen warten.)

Um sich mit ALTER Enum nicht herumzuschlagen, kann man externe Dictionaries von ClickHouse verwenden. Ich erinnere daran, dass dies eine Key-Value-Datenstruktur innerhalb von ClickHouse ist, mit der man Daten aus externen Quellen abrufen kann, beispielsweise von MySQL-Tabellen.
Im Dictionary von ClickHouse speichern wir viele verschiedene Strings, und in der Tabelle sind ihre Identifikatoren in Form von Zahlen. Wenn wir einen String benötigen, rufen wir die Funktion dictGet auf und arbeiten mit ihr. Danach müssen wir kein ALTER durchführen. Um etwas dem Enum hinzuzufügen, fügen wir es in die gleiche MySQL-Tabelle ein.
Aber hier entstehen andere Probleme. Erstens die umständliche Syntax. Wenn wir einen String erhalten möchten, müssen wir dictGet aufrufen. Zweitens das Fehlen bestimmter Optimierungen. Der Vergleich mit einem konstanten String ist für Dictionaries nicht so schnell möglich.
Es können auch Probleme mit Updates auftreten. Angenommen, wir haben einen String im Cache-Dictionary angefordert, aber er ist nicht im Cache gelandet. Dann müssen wir warten, bis die Daten aus der externen Quelle geladen werden.

Ein allgemeines Manko beider Methoden ist, dass wir alle Schlüssel an einem Ort speichern und sie synchronisieren. Warum nicht die Dictionaries lokal speichern? Keine Synchronisation – keine Probleme. Man kann das Dictionary lokal auf einem Stück der Festplatte speichern. Das heißt, wir haben ein Insert durchgeführt, das Dictionary aufgezeichnet. Wenn wir mit Daten im Arbeitsspeicher arbeiten, können wir das Dictionary entweder in einen Datenblock, in ein Stück der Spalte oder in einen Cache speichern, um die Berechnungen zu beschleunigen.
Dictionary-Codierung von Strings
So sind wir zur Schaffung eines neuen Datentyps in ClickHouse – LowCardinality – gekommen. Dies ist ein Datenformat: wie sie auf der Festplatte geschrieben und gelesen werden, wie sie im Speicher dargestellt werden und wie ihr Verarbeitungschema aussieht.

Auf der Folie gibt es zwei Spalten. Rechts werden die Zeilen standardmäßig im Typ String gespeichert. Man sieht, dass dies einige Modelle von Mobiltelefonen sind. Links gibt es genau die gleiche Spalte, nur im Typ LowCardinality. Sie besteht aus einem Wörterbuch mit vielen verschiedenen Zeilen (Zeilen aus der rechten Spalte) und einer Liste von Positionen (Zeilennummern).
Mit diesen beiden Strukturen kann man die ursprüngliche Spalte wiederherstellen. Es gibt auch einen umgekehrten Index – eine Hash-Tabelle, die hilft, die Position im Wörterbuch anhand der Zeile zu finden. Diese ist notwendig, um bestimmte Abfragen zu beschleunigen. Zum Beispiel, wenn wir eine Zeile in unserer Spalte vergleichen oder suchen möchten oder sie zusammenführen wollen.
LowCardinality ist ein parametrischer Datentyp. Er kann entweder eine Zahl sein, oder etwas, das als Zahl gespeichert wird, oder ein String, oder Nullable davon.

Das Besondere an LowCardinality ist, dass es für einige Funktionen gespeichert werden kann. Auf der Folie sieht man ein Beispiel für eine Abfrage. In der ersten Zeile habe ich eine Spalte vom Typ LowCardinality von String erstellt und sie S genannt. Dann fragte ich nach ihrem Namen – ClickHouse sagte, dass es LowCardinality von String ist. Alles richtig.
Die dritte Zeile ist fast identisch, nur dass wir die Funktion length aufgerufen haben. In ClickHouse gibt die Funktion length den Datentyp UInt64 zurück. Aber wir haben jetzt LowCardinality von UInt64. Was ist der Sinn?

Im Wörterbuch wurden die Namen von Mobiltelefonen gespeichert, wir haben die Funktion length angewendet. Jetzt haben wir ein ähnliches Wörterbuch, das nur aus Zahlen besteht – das sind die Längen der Strings. Die Spalte mit den Positionen hat sich nicht geändert. Insgesamt haben wir weniger Daten verarbeitet und Zeit bei der Abfrage gespart.
Es kann auch andere Optimierungen geben, wie zum Beispiel die Hinzufügung eines einfachen Caches. Bei der Berechnung des Wertes einer Funktion kann man diesen speichern und denselben Wert erneut erstellen, ohne neu zu berechnen.
Es kann auch eine Optimierung von GROUP BY vorgenommen werden, da unsere Spalte mit dem Wörterbuch bereits teilweise aggregiert ist – dadurch können die Werte der Hash-Funktionen schneller berechnet und die ungefähr passende Bucket gefunden werden, in die die nächste Zeile eingefügt werden kann. Außerdem kann man bestimmte Aggregatfunktionen spezialisieren, zum Beispiel uniq, da man nur das Wörterbuch senden kann und die Positionen unberührt lässt – so wird alles schneller funktionieren. Die ersten beiden Optimierungen haben wir bereits in ClickHouse hinzugefügt.

Was aber, wenn wir eine Spalte mit unserem Datentyp erstellen und darin viele verschiedene, schlechte Zeilen einfügen? Wird unser Speicher überlaufen? Nein, dafür gibt es in ClickHouse zwei spezielle Einstellungen. Die erste ist low_cardinality_max_dictionary_size. Das ist die maximale Größe des Wörterbuchs, das auf die Festplatte geschrieben werden kann. Das Einfügen erfolgt folgendermaßen: Wenn wir Daten einfügen, erhalten wir einen Strom von Zeilen, aus denen wir ein großes gemeinsames Wörterbuch erstellen. Wenn das Wörterbuch größer wird als der Wert der Einstellung, schreiben wir das aktuelle Wörterbuch auf die Festplatte, und die restlichen Zeilen werden irgendwo „nebenbei“, neben den Indizes, gespeichert. Am Ende werden wir also niemals das große Wörterbuch neu zählen und haben keine Probleme mit dem Speicher.
Die zweite Einstellung wird low_cardinality_use_single_dictionary_for_part genannt. Stellen Sie sich vor, dass in dem vorherigen Schema, als wir Daten eingefügt haben, unser Wörterbuch übergelaufen ist und wir es auf die Festplatte geschrieben haben. Die Frage ist nun, warum wir nicht ein weiteres genau gleiches Wörterbuch erstellen sollten?
Wenn es überläuft, schreiben wir es wieder auf die Festplatte und beginnen, das dritte zu erstellen. Diese Einstellung deaktiviert genau diese Möglichkeit standardmäßig.
In der Tat können viele Wörterbücher nützlich sein, wenn wir eine bestimmte Menge an Zeilen einfügen wollen, aber versehentlich „Müll“ eingefügt haben. Sagen wir, zuerst haben wir schlechte Zeilen eingefügt, und dann gute. Dann wird das Wörterbuch in viele kleine Wörterbücher aufgeteilt. Einige von ihnen enthalten „Müll“, aber die letzten werden mit guten Zeilen gefüllt sein. Und wenn wir nur das letzte Granulat lesen, wird auch alles schnell funktionieren.

Bevor ich über die Vorteile von LowCardinality spreche, sage ich gleich, dass wir wahrscheinlich keine Reduzierung der Daten auf der Festplatte erreichen werden (obwohl das geschehen kann), da ClickHouse die Daten komprimiert. Es gibt eine Standardoption - LZ4. Man kann auch die Komprimierung mit ZSTD vornehmen. Aber beide Algorithmen implementieren bereits die Wörterbuchkompression, weshalb unser externes Wörterbuch in ClickHouse nicht besonders hilfreich ist.
Um nicht unbegründet zu sein, habe ich einige Daten aus der Metrik entnommen — String, LowCardinality(String) und Enum — und sie in verschiedene Datentypen gespeichert. Es entstanden drei Spalten, in denen eine Milliarde Zeilen verzeichnet sind. In der ersten Spalte, CodePage, gibt es insgesamt 62 Werte. Man sieht, dass wir sie in LowCardinality(String) besser komprimiert haben. String ist ein wenig schlechter, aber das liegt wahrscheinlich daran, dass die Strings kurz sind, wir ihre Längen speichern und sie viel Platz einnehmen, schlecht komprimiert werden.
Wenn man PhoneModel betrachtet, gibt es 48.000 — schon mehr, und die Unterschiede zwischen String und LowCardinality(String) sind fast nicht vorhanden. Bei URL haben wir ebenfalls nur 2 GB gespart — ich denke, darauf sollte man sich nicht verlassen.
Bewertung der Arbeitsgeschwindigkeit

Jetzt bewerten wir die Arbeitsgeschwindigkeit. Um dies zu bewerten, habe ich ein Dataset mit Beschreibungen von Taxifahrten in New York verwendet. Es enthält auf GitHub. Darin sind etwas mehr als eine Milliarde Fahrten enthalten. Es sind der Standort, die Start- und Endzeit der Fahrt, die Zahlungsart, die Anzahl der Passagiere und sogar die Art des Taxis — grün, gelb und Uber — aufgeführt.

Die erste Abfrage habe ich recht einfach gehalten — ich habe gefragt, wo am häufigsten Taxis bestellt werden. Dazu muss man den Standort nehmen, von dem aus bestellt wurde, eine GROUP BY-Abfrage erstellen und die Funktion count zählen. So liefert ClickHouse etwas.

Um die Geschwindigkeit der Abfrage zu messen, habe ich drei Tabellen mit denselben Daten erstellt, aber für unseren Startstandort drei verschiedene Datentypen verwendet — String, LowCardinality und Enum. LowCardinality und Enum waren fünfmal schneller als String. Enum ist schneller, weil es mit Zahlen arbeitet. LowCardinality ist schneller, weil es eine GROUP BY-Optimierung implementiert.

Lassen Sie uns die Abfrage noch komplizierter gestalten — fragen wir, wo sich der beliebteste Park in New York befindet. Auch hier werden wir messen, wo am häufigsten Taxis bestellt werden, aber wir filtern nur die Standorte, die das Wort „Park“ enthalten. Zusätzlich fügen wir die Funktion like hinzu.

Wir schauen auf die Zeit — sehen, dass Enum plötzlich anfängt langsam zu werden. Tatsächlich arbeitet er sogar langsamer als der Standarddatentyp String. Das geschieht, weil die Funktion like für Enum überhaupt nicht optimiert ist. Wir müssen unsere Strings von Enum in normale Strings umwandeln — wir machen mehr Arbeit. LowCardinality(String) ist ebenfalls nicht standardmäßig optimiert, aber dort funktioniert like über das Wörterbuch, weshalb die Abfrage im Vergleich zu String beschleunigt wird.
Bei der Arbeit mit Enum gibt es ein größeres Problem. Wenn wir es optimieren möchten, müssen wir dies an jedem Ort im Code tun. Angenommen, wir haben eine neue Funktion geschrieben – dann müssen wir unbedingt eine Optimierung für Enum erfinden. Und bei LowCardinality ist alles standardmäßig optimiert.

Schauen wir uns die letzte Abfrage an, die künstlicher ist. Wir berechnen einfach die Hash-Funktion unseres Standorts. Die Hash-Funktion ist eine ziemlich langsame Abfrage, sie dauert lange, daher wird alles um den Faktor drei langsamer.

LowCardinality arbeitet immer noch schneller, obwohl es hier keine Filterung gibt. Das liegt daran, dass unsere Funktionen nur über das Wörterbuch arbeiten. Die Hash-Berechnungsfunktion hat ein Argument – sie kann weniger Daten verarbeiten und kann auch LowCardinality zurückgeben.

Unser globaler Plan ist es, eine Arbeitsgeschwindigkeit zu erreichen, die in allen Fällen nicht unter der von String liegt, und die Beschleunigung beizubehalten. Und vielleicht ersetzen wir eines Tages String durch LowCardinality, Sie aktualisieren ClickHouse, und alles funktioniert ein wenig schneller.
Quelle: habr.com
