
Parquet - Einführung
Parquet, ein binäres, spaltenorientiertes Dateiformat, das für die effiziente Speicherung und Abfrage von Daten entwickelt wurde.
Es gibt unzählige Artikel über Parquet im Netz, warum also noch einer?
Das ist meine Sicht auf dieses fantastische Format, die im Wesentlichen aus meinen Erfahrungen bei der Arbeit an
einer Parquet-Implementierung in reinem PHP resultiert.
Für diejenigen, die hier zufällig gelandet sind, möchte ich erwähnen, dass ich der Autor des ersten Frameworks für Datenverarbeitung
in PHP namens Flow PHP bin.
Wie es sich für ein Data Frame gehört, muss Flow Daten in verschiedenen Formaten lesen und schreiben können, einschließlich Parquet
Da die einzige Implementierung, die ich fand, im Grunde ein direkter Port aus C# war, der außerdem nicht so gut mit tief verschachtelten Strukturen umgehen konnte und viele fehlende Funktionen hatte, entschied ich mich, zu Lernzwecken eine eigene Implementierung von Grund auf zu schreiben, was sich als äußerst wertvolle Erfahrung herausstellte, aber auch großen Spaß machte.
Warum Parquet
- Binäres Format - bis zu 10x kleinere Dateien
- Metadaten - einfacherer Zugriff auf ausgewählte Daten
- Schema - Garantie für korrekte Struktur
- Kompression - zusätzliche Größenreduktion
- Verschlüsselung - auf Datei-, Metadaten-, Spalten- oder Seitenebene
Binäres Format
Da dieses Format spalten- und nicht zeilenorientiert ist, ermöglicht es eine sehr effiziente Datenkompression, was sich in einer deutlich kleineren Dateigröße niederschlägt. Ohne großen Aufwand kann Parquet Daten sogar um das 10-fache komprimieren, verglichen mit traditionellen Formaten wie CSV oder XML.
Wenn also dieselben Daten im CSV-Format 1GB belegen, können sie im Parquet-Format nur 100MB benötigen.
Für diesen Beitrag habe ich 2 Dateien generiert, eine im CSV-Format, die andere im Parquet-Format.
Die Struktur dieser Dateien ist sehr einfach, sie enthält 10 Spalten und 10 Millionen Zeilen, die etwa so aussehen:
index,order_id,created_at,updated_at,discount,email,customer,address,notes,items
0,254d61c5-22c8-4407-83a2-76f1cab53af2,2025-01-01T12:00:00+00:00,2025-01-01T12:10:00+00:00,24.4,[email protected],"John Doe 0","{""street"":""123 Main St, Apt 0"",""city"":""City "",""zip"":""12345-0"",""country"":""PL""}","[""Note 1 for order 0"",""Note 2 for order 0"",""Note 3 for order 0""]","[{""sku"":""SKU_0001"",""quantity"":1,""price"":0.14},{""sku"":""SKU_0002"",""quantity"":2,""price"":25.13}]"
1,254d61c5-22c8-4407-83a2-76f1cab53af2,2025-01-01T12:00:00+00:00,2025-01-01T12:10:00+00:00,24.4,[email protected],"John Doe 1","{""street"":""123 Main St, Apt 1"",""city"":""City "",""zip"":""12345-1"",""country"":""PL""}","[""Note 1 for order 1"",""Note 2 for order 1"",""Note 3 for order 1""]","[{""sku"":""SKU_0001"",""quantity"":1,""price"":0.14},{""sku"":""SKU_0002"",""quantity"":2,""price"":25.13}]"
2,254d61c5-22c8-4407-83a2-76f1cab53af2,2025-01-01T12:00:00+00:00,,,[email protected],"John Doe 2","{""street"":""123 Main St, Apt 2"",""city"":""City "",""zip"":""12345-2"",""country"":""PL""}","[""Note 1 for order 2"",""Note 2 for order 2"",""Note 3 for order 2""]","[{""sku"":""SKU_0001"",""quantity"":1,""price"":0.14},{""sku"":""SKU_0002"",""quantity"":2,""price"":25.13}]"
3,254d61c5-22c8-4407-83a2-76f1cab53af2,2025-01-01T12:00:00+00:00,,24.4,[email protected],"John Doe 3","{""street"":""123 Main St, Apt 3"",""city"":""City "",""zip"":""12345-3"",""country"":""PL""}","[""Note 1 for order 3"",""Note 2 for order 3"",""Note 3 for order 3""]","[{""sku"":""SKU_0001"",""quantity"":1,""price"":0.14},{""sku"":""SKU_0002"",""quantity"":2,""price"":25.13}]"
Der Kompressionseffekt ist wirklich beeindruckend:
4.1G Sep 20 18:32 orders.csv
437M Sep 20 18:47 orders.parquet
Das wirkt sich nicht nur auf die Speicherkosten aus, sondern auch auf die Datenverarbeitung.
Besonders wenn unsere Daten in der Cloud leben, sei es auf Azure Bucket oder AWS S3. Einer der größeren Kostenfaktoren
ist nicht die Größe der Daten, sondern der Transfer, den wir verbrauchen, um diese Daten zu lesen/schreiben.
Indem wir die Dateigröße reduzieren, reduzieren wir nicht nur die Speicherkosten, sondern auch die Verarbeitungskosten. Wichtig ist jedoch zu verstehen, dass Verarbeitung eigentlich jede Form des Zugriffs bedeutet, also Schreiben/Lesen.
Das läuft darauf hinaus, dass durch die Wahl des richtigen Dateiformats die Einsparungen wirklich erheblich sein können, besonders wenn wir über größere Datenmengen sprechen.
Was bedeutet es überhaupt, dass Parquet ein binäres Format ist?
Es bedeutet etwa so viel, dass Daten in binärer Form gespeichert werden, also in einer Form, die nicht direkt mit gängigen Texteditoren gelesen werden kann.
Aber wird nicht letztendlich alles in binärer Form gespeichert?
Ja, im Allgemeinen sind auch Textdateien Binärdateien, der Unterschied liegt darin, dass in Textdateien die Struktur der Datei immer gleich ist und jede Information auf die gleiche Weise gespeichert wird.
Wenn wir zum Beispiel "12345" in einer Textdatei speichern wollten, würde die binäre Version so aussehen:
STRING: "12345"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Character: '1' '2' '3' '4' '5' '\0'
ASCII: 49 50 51 52 53 0
Binary: 00110001 00110010 00110011 00110100 00110101 00000000
└─byte─┘ └─byte─┘ └─byte─┘ └─byte─┘ └─byte─┘ └─byte─┘
Total: 6 bytes (including null terminator)
Derselbe String, gespeichert im binären Format als int32 (Integer in 32-Bit-Form), würde so aussehen:
INTEGER: 12345
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Integer: 0 0 48 57
Binary: 00000000 00000000 00110000 00111001
└─byte─┘ └─byte─┘ └─byte─┘ └─byte─┘
Total: 4 bytes for a 32-bit integer
Beachten Sie, dass bei der binären Speicherung von Integern diese nicht einfach von links nach rechts (oder umgekehrt) gelesen werden können. Hier müssen wir bereits wissen, wie diese Bits zu interpretieren sind, um zu verstehen, was sie bedeuten. Bei Textdateien haben wir dieses Problem nicht, da wir wissen, dass jeder Buchstabe in 8-Bit-Form gespeichert wird.
Deshalb ist praktisch jeder Texteditor in der Lage, jede Textdatei zu öffnen und uns etwas anzuzeigen, was mehr oder weniger Sinn ergibt.
Wenn wir jedoch versuchen, eine Parquet-Datei in einem Texteditor zu öffnen, erhalten wir eine Zeichenkette, die sehr zufällig aussieht und nicht viel Sinn ergibt.
Spaltenorientiert / Zeilenorientiert
Den Unterschied zwischen diesen Formaten erklärt man am besten mit einer Visualisierung.
Im klassischen zeilenorientierten Modell enthält jede Zeile alle Spalten, wie z.B. im CSV-Format
+------+------+------+
| Col1 | Col2 | Col3 |
+------+------+------+
| A1 | B1 | C1 |
| A2 | B2 | C2 |
| A3 | B3 | C3 |
+------+------+------+
Das spaltenorientierte Format ist insofern interessant, als es, anstatt Daten Zeile für Zeile zu speichern, sie Spalte für Spalte speichert.
+------+------+------+------+
| Col1 | A1 | A2 | A3 |
+------+------+------+------+
| Col2 | B1 | B2 | B3 |
+------+------+------+------+
| Col3 | C1 | C2 | C3 |
+------+------+------+------+
Die Speicherung von Daten im spaltenorientierten Format bringt viele Vorteile mit sich, wie:
- Viel bessere Datenkompression
- Möglichkeit, nur ausgewählte Spalten zu lesen
- Möglichkeit, ausgewählte oder alle Spalten zu verschlüsseln
Im Fall des zeilenorientierten Formats müssen wir, um nur eine Spalte zu lesen, trotzdem die gesamte Datei durchgehen.
Im Fall des spaltenorientierten Formats können wir nur die Spalten lesen, die uns interessieren.
Das ist besonders nützlich bei sehr großen Datensätzen, wo wir oft nur einen Teil der Informationen benötigen.
Unveränderlich
Aufgrund der Art, wie Daten im spaltenorientierten Format gespeichert werden, sind Parquet-Dateien unveränderlich.
Das bedeutet jedoch nicht, dass sie nicht modifiziert werden können. Das können sie, aber die einzige sinnvolle Operation ist das Anhängen von Daten am Ende.
Warum? Parquet speichert Daten im spaltenorientierten Format, das bedeutet, wenn wir eine Spalte email
haben,
werden alle Zeilen (in einer gegebenen Zeilengruppe und Seite - dazu später mehr) hintereinander als Kette gespeichert.
Die Modifikation einer Zeile ist daher unmöglich, da sie praktisch die Verschiebung der gesamten Datei erfordern würde.
Es ist jedoch möglich, eine neue Zeilengruppe am Ende der Datei hinzuzufügen. Dies geschieht so, dass die Metadaten vom Ende der Datei entfernt werden, die vorübergehend in den Speicher wandern. An ihrer Stelle wird die neue Zeilengruppe geschrieben (die auch zu den Metadaten hinzugefügt werden muss), und dann werden die Metadaten am Ende wieder geschrieben.
Aus diesem Grund müssen wir, wenn wir etwas aus einer Parquet-Datei löschen wollen, praktisch die gesamte Datei neu schreiben, wobei wir ungewünschte Daten weglassen.
Starke Struktur
Parquet ist ein Format, das auf starker Typisierung basiert. Das bedeutet, dass die Struktur der gesamten Datei definiert und in der Fußzeile gespeichert ist, so dass es ausreicht, nur das entsprechende Segment zu lesen, um zu verstehen, welche Daten wir in der Datei haben und in welchen Bereichen der Datei diese Daten gespeichert sind.
Wir können uns das wie eine Karte der Datei vorstellen, eine Karte, die uns sagt, wo in der Datei genau die Daten sind, die uns interessieren.
So sieht etwa die vereinfachte Struktur einer Datei im Parquet-Format aus:
+-----------------+
| PAR1 |
+-----------------+
| Data |
| ............. |
| ............. |
+-----------------+
| File Metadata |
+-----------------+
| PAR1 |
+-----------------+
Im obigen Beispiel sehen wir 3 Elemente:
PAR1
- also "Parquet Magic Bytes" - 4 Bytes, die Dateien im Parquet-Format öffnen und schließenData
- hier sind alle Spalten gespeichert (dazu später mehr)Metadata
- Metadaten, also die Karte der Datei
Der erste Schritt zum korrekten Lesen einer Parquet-Datei ist die Überprüfung, ob die ersten 4 Bytes PAR1
sind.
Wenn ja, müssen wir zum Ende der Datei springen (seek) und die letzten 4 Bytes lesen.
Wenn Anfang und Ende der Datei PAR1
enthalten, können wir mit dem Lesen der Metadaten beginnen.
Dazu gehen wir 8 Bytes vom Ende der Datei zurück und lesen 4 Bytes, die die Größe der Metadaten repräsentieren.
Mit anderen Worten, wir lesen die Bytes -8
bis -4
Diese 4 Bytes sind ein integer
, der angibt, auf wie vielen Bytes die Metadaten gespeichert sind. Mit
dieser Information können wir die Metadaten lesen, die binär mit Apache Thrift serialisiert sind
Apache Thrift
Apache Thrift ist ein sehr cleveres Tool, das die binäre Serialisierung von Interfaces/Typen in praktisch jeder Programmiersprache ermöglicht.
Hier können wir sehen, wie die Definition der Metadaten im Parquet-Format aussieht.
Dieses Format erinnert etwas an Pseudocode, der dann mit der entsprechenden Anwendung verwendet wird, um Code in der jeweiligen Programmiersprache zu generieren.
Hier können wir sehen, wie der generierte Code in PHP aussieht.
Wenn wir die generierten Strukturen/Interfaces/Modelle haben, können wir mit dem Lesen beginnen.
<?php
use Flow\Parquet\Thrift\FileMetaData;
use Thrift\Protocol\TCompactProtocol;
use Thrift\Transport\TMemoryBuffer;
$metadataLength = \unpack($this->byteOrder->value, $this->stream->read(4, $fileTotalSize - 8))[1];
$fileMetadata = new FileMetaData();
$fileMetadata->read(
new TCompactProtocol(
new TMemoryBuffer(
$this->stream->read($metadataLength, $fileTotalSize - ($metadataLength + 8))
)
)
);
Dafür brauchen wir die Thrift-Bibliothek für die gewählte Programmiersprache. Alle Implementierungen sind im Repository apache/thrift verfügbar.
Mit Zugriff auf $metadata
können wir anfangen, unsere Datei zu analysieren, um ihre Struktur zu verstehen.
Parquet - FileMetaData
struct FileMetaData {
1: required i32 version
2: required list<SchemaElement> schema;
3: required i64 num_rows
4: required list<RowGroup> row_groups
5: optional list<KeyValue> key_value_metadata
6: optional string created_by
7: optional list<ColumnOrder> column_orders;
8: optional EncryptionAlgorithm encryption_algorithm
9: optional binary footer_signing_key_metadata
}
Schlüsselinformationen über die Datei werden in der FileMetaData
-Struktur gespeichert.
Die wichtigsten davon sind:
version
- Parquet-Format-Versionnum_rows
- Anzahl der Zeilen in der Dateischema
- Datenschemarow_groups
- hier sind unsere Daten gespeichert
Format-Versionen
Zum Zeitpunkt des Schreibens dieses Artikels war das Parquet-Format bereits in Version 2.12.0
verfügbar.
Die wichtigsten Änderungen zwischen Version 1.0 und 2.0 sind:
- Neue Kodierungsschemas: DELTA_BINARY_PACKED für Zahlen, DELTA_BYTE_ARRAY für Strings, RLE_DICTIONARY ersetzt PLAIN_DICTIONARY
- Data Page V2 Struktur: Metadaten-Overhead eliminiert, Filterung auf Seitenebene ermöglicht
Obwohl Version 2.0 viele Verbesserungen einführt, verwenden die größten Akteure standardmäßig noch Version 1.
Anzahl der Zeilen
Diese Information mag anfangs im Kontext des spaltenorientierten Formats wenig intuitiv erscheinen.
Wir müssen jedoch daran denken, dass das spaltenorientierte Format nur eine Art der Wertspeicherung und nicht der Datenstruktur ist.
Obwohl Daten auf Basis von Spalten und ihrem Typ gruppiert sind, erfolgt das Lesen/Schreiben immer noch auf klassische Weise, also Zeile für Zeile.
Der Unterschied besteht darin, dass wir nicht eine Zeile auf einmal lesen, sondern eine ganze Zeilengruppe, wobei wir Spalte für Spalte in den Speicher laden und dann die Zeilen basierend auf den entsprechenden Indizes rekonstruieren.
Wenn wir daran denken, dass wir, um Daten ordnungsgemäß im spaltenorientierten Format zu speichern, mit logischen Gruppen arbeiten müssen, nicht mit einzelnen Zeilen. Können wir relativ einfach das Verhältnis von Speicher zu Anzahl der IO-Operationen verwalten.
Schreiben und Lesen aus dem Speicher ist schneller als Schreiben und Lesen von der Festplatte (obwohl nicht immer).
Indem wir die Anzahl der Zeilen erhöhen, die in einer Gruppe gespeichert werden, reduzieren wir die Anzahl der Gruppen, also die Anzahl der IO-Operationen.
Damit erhöhen wir die Geschwindigkeit des Schreibens/Lesens und gleichzeitig den Speicherverbrauch.
Das funktioniert auch in die andere Richtung: Indem wir die Anzahl der Zeilen in einer Gruppe reduzieren, erhöhen wir die Anzahl der Gruppen in der Datei, damit erhöhen wir die Anzahl der IO-Operationen.
Gruppengröße, nicht Zeilenanzahl - Parquet erlaubt es, nicht die Anzahl der Zeilen zu definieren, sondern die maximale
Größe der Zeilengruppe.
Man muss jedoch bedenken, dass dies keine absoluten Werte sind (dazu etwas später), so dass
einige Gruppen kleiner/größer als die zulässige Größe sein können, und das hängt hauptsächlich von der Implementierung der Bibliothek
für Parquet ab.
In der Dokumentation des Parquet-Formats finden wir die Information, dass die empfohlene Gruppengröße 512MB - 1GB
beträgt.
Es lohnt sich jedoch, dies mit etwas Vernunft anzugehen, besonders wenn wir uns beim Lesen/Schreiben nicht auf HDFS (Hadoop Distributed File System) verlassen.
Der empfohlene Wert ist so festgelegt, dass eine Zeilengruppe in einen HDFS-Block passt, wodurch garantiert wird, dass das Lesen
von genau einem Knoten aus erfolgt.
Das sollte man im Hinterkopf behalten. Wenn wir jedoch nicht planen, Parquet mit einem verteilten Dateisystem zu verwenden, erlauben kleinere Zeilen- gruppen, viel Speicher zu sparen.
Ein sehr gutes Beispiel, wann kleinere Gruppen effizienter sind, ist der Fall, in dem wir nur einen kleinen Abschnitt von Zeilen irgendwo aus der Mitte der Datei lesen möchten (Paginierung).
Angenommen, wir müssen nur 100 Zeilen aus einer Datei lesen, die 10 Millionen Zeilen enthält. Die Einstellung einer kleineren Gruppengröße ermöglicht es, viel Speicher zu sparen. Warum?
Wenn wir 10 Millionen in, sagen wir, 10 Gruppen aufteilen, enthält jede Gruppe 1 Million Zeilen. Das bedeutet, dass wir praktisch die gesamte Gruppe lesen müssen und dann nur 100 Zeilen extrahieren, die uns interessieren.
Im Fall der Festlegung einer kleineren Gruppengröße, die es ermöglicht, 10 Millionen in 1000 Gruppen zu unterteilen, können wir durch die Analyse der Datei-Metadaten eine größere Anzahl von Gruppen überspringen und eine viel kleinere Anzahl von Zeilen in den Speicher laden.
Die Entscheidung über die Größe der Zeilengruppe sollte sowohl hinsichtlich der Schreibgeschwindigkeit als auch der Lesegeschwindigkeit einer bestimmten Datei durchdacht sein. Die richtige Konfiguration wirkt sich direkt auf den Ressourcenverbrauch aus, was sich letztendlich in Geld niederschlägt.
Schema
Langsam kommen wir zum Kern von Parquet, den Row Groups
. Bevor wir jedoch ihre Struktur analysieren, müssen wir
zu einem weiteren sehr wichtigen Aspekt von Parquet zurückkehren, dem Datenschema.
Beginnen wir mit den Datentypen. Parquet besteht aus physischen und logischen Typen.
Physical Types
Physische Typen sind grundlegende Datentypen, die zur Speicherung von Werten in einer Parquet-Datei verwendet werden. Das sind Typen wie:
- Boolean
- Byte Array
- Double
- Fixed Len Byte Array
- Float
- Int32
- Int64
- Int96 - (veraltet - nur von älteren Implementierungen verwendet)
Logische Typen sind Typen, die zur Darstellung komplexerer Datenstrukturen verwendet werden. Man kann sie als Erweiterung der physischen Typen betrachten.
Logical Types
- Bson
- Date
- Decimal
- Enum
- Integer
- Json
- List
- Map
- String
- Time
- Timestamp
- Uuid
Die aktuelle Struktur kann man immer bei der Quelle überprüfen, apache/parquet-format
Neben der Unterteilung in logische und physische Typen unterscheidet Parquet auch zwischen flachen und verschachtelten Spalten.
Flache Spalten sind solche, die einen einzelnen Wert speichern, z.B. Int32
, Boolean
, Float
usw.
Verschachtelte Spalten sind solche, die mehr als einen Wert speichern, z.B. List
, Map
usw.
Es gibt grundsätzlich 3 Arten von verschachtelten Spalten:
- List
- Map
- Struct
Struct ist ein spezieller Spaltentyp, der die Verschachtelung beliebiger anderer Typen ermöglicht und die Erstellung praktisch jeder Datenstruktur erlaubt.
Mit den obigen Typen sind wir in der Lage, praktisch jede Datenstruktur zu modellieren und sie dann effizient zu speichern und zu durchsuchen.
Schauen wir uns also die Thrift-Definitionen von SchemaElement
und einigen verwandten Elementen an.
struct SchemaElement {
1: optional Type type;
2: optional i32 type_length;
3: optional FieldRepetitionType repetition_type;
4: required string name;
5: optional i32 num_children;
6: optional ConvertedType converted_type;
7: optional i32 scale
8: optional i32 precision
9: optional i32 field_id;
10: optional LogicalType logicalType
}
enum FieldRepetitionType {
REQUIRED = 0;
OPTIONAL = 1;
REPEATED = 2;
}
enum Type {
BOOLEAN = 0;
INT32 = 1;
INT64 = 2;
INT96 = 3;
FLOAT = 4;
DOUBLE = 5;
BYTE_ARRAY = 6;
FIXED_LEN_BYTE_ARRAY = 7;
}
union LogicalType {
1: StringType STRING
2: MapType MAP
3: ListType LIST
4: EnumType ENUM
5: DecimalType DECIMAL
6: DateType DATE
7: TimeType TIME
8: TimestampType TIMESTAMP
10: IntType INTEGER
11: NullType UNKNOWN
12: JsonType JSON
13: BsonType BSON
14: UUIDType UUID
}
Die meisten Werte sollten ziemlich offensichtlich sein, schauen wir uns jedoch FieldRepetitionType
an.
Dieser Wert sagt uns, ob eine gegebene Spalte erforderlich, optional oder wiederholbar ist.
Wenn eine Spalte erforderlich ist, bedeutet das, dass der Wert nicht null sein kann.
Wenn eine Spalte optional ist, kann der Wert null sein, und wenn sie wiederholbar ist, bedeutet das, dass sie mehrere Werte enthalten kann (z.B. eine Liste).
So könnte das Schema einer Bestelldatei aussehen (in DDL-Form)
message orders_scheme {
required fixed_len_byte_array(16) order_id (UUID)
required int64 created_at (TIMESTAMP(MICROS,false))
optional int64 updated_at (TIMESTAMP(MICROS,false))
optional float discount
required binary email (STRING)
required binary customer (STRING)
required group address {
required binary street (STRING);
required binary city (STRING);
required binary zip (STRING);
required binary country (STRING);
}
required group notes (LIST) {
repeated group list {
required binary element (STRING);
}
}
required group items (LIST) {
repeated group list {
required group element {
required binary sku (STRING);
required int64 quantity (INTEGER(64,true));
required float price;
}
}
}
}
Verschachtelte Typen
Um die Struktur von Zeilengruppen vollständig zu verstehen, müssen wir zuerst verstehen, wie Parquet verschachtelte Typen abflacht.
Während einfache Strukturen wie address
aus dem obigen Beispiel auf 4 einfache Spalten reduziert werden können:
address.street
- Stringaddress.city
- Stringaddress.zip
- Stringaddress.country
- String
Im Fall von Map
oder List
ist die Situation etwas komplizierter.
Wenn wir zum Beispiel Map<string,int32>
abflachen wollten, würden wir so etwas erhalten:
map_column.key_value.key
- Stringmap_column.key_value.value
- Int32
Für das obige Beispiel wäre der flache Pfad zu sku
:
items.list.element.sku
, während die abgeflachte vollständige Struktur so aussehen würde:
┌─────────────────────────────┬──────────────────────── Columns ────────┬────────────┬────────────────┬────────────────┐
│ path │ type │ logical type │ repetition │ max repetition │ max definition │
├─────────────────────────────┼──────────────────────────┼──────────────┼────────────┼────────────────┼────────────────┤
│ order_id │ FIXED_LEN_BYTE_ARRAY(16) │ UUID │ REQUIRED │ 0 │ 0 │
│ created_at │ INT64 │ TIMESTAMP │ REQUIRED │ 0 │ 0 │
│ updated_at │ INT64 │ TIMESTAMP │ OPTIONAL │ 0 │ 1 │
│ discount │ FLOAT │ - │ OPTIONAL │ 0 │ 1 │
│ email │ BYTE_ARRAY │ STRING │ REQUIRED │ 0 │ 0 │
│ customer │ BYTE_ARRAY │ STRING │ REQUIRED │ 0 │ 0 │
│ address.street │ BYTE_ARRAY │ STRING │ REQUIRED │ 0 │ 0 │
│ address.city │ BYTE_ARRAY │ STRING │ REQUIRED │ 0 │ 0 │
│ address.zip │ BYTE_ARRAY │ STRING │ REQUIRED │ 0 │ 0 │
│ address.country │ BYTE_ARRAY │ STRING │ REQUIRED │ 0 │ 0 │
│ notes.list.element │ BYTE_ARRAY │ STRING │ REQUIRED │ 1 │ 1 │
│ items.list.element.sku │ BYTE_ARRAY │ STRING │ REQUIRED │ 1 │ 1 │
│ items.list.element.quantity │ INT64 │ - │ REQUIRED │ 1 │ 1 │
│ items.list.element.price │ FLOAT │ - │ REQUIRED │ 1 │ 1 │
└─────────────────────────────┴──────────────────────────┴──────────────┴────────────┴────────────────┴────────────────┘
Row Groups
+-----------------------------------+
| PAR1 |
+-----------------------------------+
| Row Group 1 |
| +-----------------------------+ |
| | Column Chunk 1 | |
| | (Metadata + Data Pages) | |
| +-----------------------------+ |
| | Column Chunk 2 | |
| | (Metadata + Data Pages) | |
| +-----------------------------+ |
| ... |
+-----------------------------------+
| Row Group 2 |
| +-----------------------------+ |
| | Column Chunk 1 | |
| | (Metadata + Data Pages) | |
| +-----------------------------+ |
| | Column Chunk 2 | |
| | (Metadata + Data Pages) | |
| +-----------------------------+ |
| ... |
+-----------------------------------+
| ... |
+-----------------------------------+
| Metadata |
+-----------------------------------+
| PAR1 |
+-----------------------------------+
Wie wir bereits wissen, ist eine Parquet-Datei in Zeilengruppen unterteilt. Das Schreiben in eine Datei sieht vereinfacht so aus:
- 1) Erstelle eine Datei und füge 4 Bytes
PAR1
hinzu - 2) Erstelle eine Metadatenstruktur basierend auf dem Schema und halte sie im Speicher
- 3) Flache die übergebene Zeile ab (überprüfe, ob sie zum Schema passt)
- 4) Speichere die abgeflachte Zeile im Speicher in binärer Form
-
5) Überprüfe, ob die Größe der Zeilengruppe, die wir derzeit im Speicher halten, in die maximal zulässige Größe passt
- a) Schreibe die Zeilengruppe in die Datei
- b) Aktualisiere die Metadaten im Speicher, indem du die Metadaten der Gruppe hinzufügst, die wir gerade geschrieben haben
- 6) Kehre zu Schritt 2 zurück
- 7) Schreibe die Metadaten an das Ende der Datei, nachdem alle Zeilengruppen geschrieben wurden
-
8) Schließe die Datei mit 4 Bytes
PAR1
Natürlich ist diese Beschreibung sehr vereinfacht, in Wirklichkeit ist sie etwas komplexer, außerdem können sich verschiedene Implementierungen in den Details unterscheiden.
Konzentrieren wir uns auf die Struktur der Zeilengruppe, schauen wir uns zuerst die Thrift-Definitionen von RowGroup
an.
struct RowGroup {
1: required list<ColumnChunk> columns
2: required i64 total_byte_size
3: required i64 num_rows
4: optional list<SortingColumn> sorting_columns
5: optional i64 file_offset
6: optional i64 total_compressed_size
7: optional i16 ordinal
}
Bereits auf dieser Ebene sieht man, wie viele Informationen über eine bestimmte Zeilengruppe in den Metadaten gespeichert sind.
Für jetzt konzentrieren wir uns jedoch auf drei Felder:
file_offset
- wie viele Bytes vom Dateianfang übersprungen werden müssen, um die gegebene Gruppe zu lesentotal_byte_size
- auf wie vielen Bytes die Zeilengruppe gespeichert istcolumns
- detaillierte Informationen über jede Spalte, die im Rahmen der gegebenen Gruppe gespeichert ist
Wichtig: Jede Zeilengruppe enthält immer alle im Schema definierten Spalten.
Auch wenn über den gesamten Bereich der Gruppe eine Spalte nur Null-Werte enthält.
Column Chunks
Gehen wir tiefer und schauen uns die Thrift-Definition von ColumnChunk
an
struct ColumnChunk {
1: optional string file_path
2: required i64 file_offset
3: optional ColumnMetaData meta_data
4: optional i64 offset_index_offset
5: optional i32 offset_index_length
6: optional i64 column_index_offset
7: optional i32 column_index_length
8: optional ColumnCryptoMetaData crypto_metadata
9: optional binary encrypted_column_metadata
}
struct ColumnMetaData {
1: required Type type
2: required list<Encoding> encodings
3: required list<string> path_in_schema
4: required CompressionCodec codec
5: required i64 num_values
6: required i64 total_uncompressed_size
7: required i64 total_compressed_size
8: optional list<KeyValue> key_value_metadata
9: required i64 data_page_offset
10: optional i64 index_page_offset
11: optional i64 dictionary_page_offset
12: optional Statistics statistics;
13: optional list<PageEncodingStats> encoding_stats;
14: optional i64 bloom_filter_offset;
15: optional i32 bloom_filter_length;
}
Denke daran: Alles, was wir bisher betrachtet haben, ist immer noch Teil der Metadaten.
Das bedeutet, dass wir all diese Informationen über Spalten, Zeilengruppen oder die Daten selbst erhalten, indem wir
nur das Ende der Datei lesen, unabhängig davon, ob die Datei 1MB oder 1TB hat.
Hier kommen wir grundsätzlich zu dem Punkt, der es uns ermöglicht, Daten aus der Datei zu lesen.
Bevor das jedoch geschieht, müssen wir die letzte Datenstruktur kennenlernen, die zum Lesen notwendig ist.
Data Pages
Pages
, also eine weitere logische Unterteilung in der Struktur einer Parquet-Datei.
Row Group -> Column Chunk -> Data Pages
RowGroup
- Zeilengruppe (Partition)ColumnChunk
- jede Zeilengruppe enthält genau 1ColumnChunk
für jede Spalte in der GruppeData Page
- Seite, die kleinste logische Einheit in Parquet, die Daten aggregiert
Das Lesen von Parquet läuft eigentlich darauf hinaus, die Metadatenstruktur zu analysieren, die Adresse des Beginns einer bestimmten Zeilengruppe zu lokalisieren, dann eine bestimmte Spalte in der Gruppe und dann zu iterieren und Daten von jeder Seite zu lesen.
Bevor wir jedoch mit dem Lesen von Seiten beginnen, müssen wir verstehen, ob wir es mit einer DataPage
, IndexPage
oder DictionaryPage
zu tun haben.
Dazu lesen wir zuerst den PageHeader
, also den Seitenkopf, dessen Thrift-Definition so aussieht
struct PageHeader {
1: required PageType type
2: required i32 uncompressed_page_size
3: required i32 compressed_page_size
4: optional i32 crc
5: optional DataPageHeader data_page_header;
6: optional IndexPageHeader index_page_header;
7: optional DictionaryPageHeader dictionary_page_header;
8: optional DataPageHeaderV2 data_page_header_v2;
}
enum PageType {
DATA_PAGE = 0;
INDEX_PAGE = 1;
DICTIONARY_PAGE = 2;
DATA_PAGE_V2 = 3;
}
Um den Header zu lesen, müssen wir seine Adresse relativ zum Dateianfang kennen. So können wir ihn für eine ausgewählte Zeilengruppe und ausgewählte Spalte berechnen:
- Wir lesen
FileMetadata
- Wir finden die entsprechende
RowGroup
und suchen den für uns relevantenColumnChunk
- Mit
ColumnChunk
erhalten wir die Adressefile_offset
des Beginns vonColumnChunk
relativ zum Dateianfang.
Wichtig: Auf dieser Ebene müssen wir noch nicht physisch Bytes in den Speicher laden.
Es reicht, wenn wir einen stream
erstellen, der das Lesen von Daten direkt aus der Datei ermöglicht.
Das erste, was gelesen werden sollte, ist der Header, PageHeader
. Indem wir das mit Thrift machen und den
Stream übergeben und die Adresse des Beginns entsprechend setzen, erhalten wir die Datenstruktur PageHeader
, die uns genau sagt, wie die
Seite selbst zu lesen ist.
Es gibt 3 Arten von Seiten:
DataPage
Seite, die die binäre Darstellung von Daten einer ausgewählten Spalte aus Zeilen enthält, die in die ausgewählte Zeilengruppe gelangt sind.
Das ist der einfachste und direkteste Seitentyp. Enthält "nur" Daten.
Beim Lesen einer Integer-Spalte interessiert uns eigentlich die Anzahl der Zeilen in einer bestimmten Gruppe (jede Zeile ist ein Wert in DataPage
).
Wenn wir also wissen, dass wir in dieser Gruppe, sagen wir, 100 Werte haben, wissen wir, dass wir 400 Bytes lesen müssen (int32 ist auf 4 Bytes gespeichert).
Okay, aber was ist, wenn die Spalte optional ist? Das bedeutet, dass sie Null-Werte enthalten kann.
Hier wird die Situation etwas komplizierter, weil wir wissen müssen, welche Zeilen Null-Werte enthalten.
Woher kommt dieses Wissen, fragt ihr euch?
Definition Levels
Die Situation wird etwas kompliziert, am Anfang schrieb ich, dass DataPage
nur Daten enthält, und jetzt füge ich irgendwelche Definition Levels
hinzu.
In Wirklichkeit sieht die Struktur der Datenseite etwa so aus:
Parquet Data Page: int32
=======================================
[ Repetition Levels ]: 0, 0, 0, 0, 0
---------------------------------------
[ Definition Levels ]: 1, 0, 1, 1, 0
---------------------------------------
[ Values ]: 42, 73, 19
=======================================
Im Moment konzentrieren wir uns nur auf Definition Levels
und Values
. Es ist sehr leicht, die Beziehung zwischen ihnen zu sehen.
Die Anzahl der Definition Level
und Repetition Levels
in jeder Seite ist immer gleich der Anzahl der Werte in der Spalte.
Unabhängig davon, ob dort Nullen sind oder nicht. Definition Levels
sagen uns, ob eine gegebene Zeile einen Wert oder null enthält.
Auf dieser Basis können wir leicht die Gesamtzahl der nicht leeren Values
bestimmen, was uns das Lesen ermöglicht.
Im obigen Beispiel haben wir 5 Zeilen, von denen 3 Werte darstellen. Da wir int32
auf 4 Bytes speichern,
wissen wir bereits, dass wir insgesamt 12 Bytes lesen müssen.
Wir wissen auch, dass bei der Umwandlung der Spalte in Zeilen die erste Zeile den Wert 42
enthalten wird, die zweite Zeile null
,
die dritte Zeile 73
, die vierte Zeile 19
und die fünfte Zeile null
.
Wichtig: Repetition Levels
und Definition Levels
sind jedoch viel komplizierter, dazu etwas mehr später.
So etwa sieht die Struktur von DataPage
aus.
DictionaryPage
Da wir Daten in DataPage
speichern, welchen Zweck hat DictionaryPage
?
Nun, DictionaryPage
ist eine Seite, die ein Wörterbuch von Werten enthält.
Wörterbuch, verwendet zum Lesen von Daten, besonders bei Spalten mit sich wiederholenden Werten.
Es funktioniert etwa so, dass beim Lesen von ColumChunk
wir mit der ersten Seite beginnen. Wenn diese Seite eine DictionaryPage
ist,
wissen wir, dass wir es mit einem Wörterbuch zu tun haben (eigentlich wissen wir das von Anfang an, weil es in den Metadaten der Spalte gespeichert ist).
Wenn wir zum Beispiel eine Spalte mit hoher Wiederholung lesen, z.B. eine Spalte mit einem Ländernamen, anstatt in DataPage
den vollständigen Ländernamen für jede Zeile zu speichern,
speichern wir nur ihre Position im Wörterbuch.
Bei einer solchen Spalte wird die erste Seite in der Spalte DictionaryPage
sein, und die folgenden werden DataPage
sein.
Der Unterschied besteht darin, dass in DataPage
anstelle des vollen Wertes Positionen im Wörterbuch stehen, das wir im Speicher behalten, um Zeilen zu rekonstruieren.
Wichtig: Jeder ColumnChunk
kann nur eine DictionaryPage
enthalten.
Das kann zu enormen Einsparungen führen. Anstatt, sagen wir, das Wort Polen
10.000 Mal binär zu speichern, also 60k Bytes,
speichern wir nur die Position im Index (also 4 Bytes), die zusätzlich mit dem Algorithmus Run Length Encoding / Bit-Packing Hybrid gepackt werden.
Der sich ebenfalls auf die Wiederholung aufeinanderfolgender Werte stützt, um die Gesamtzahl der benötigten Bytes zu reduzieren.
IndexPage
Der letzte Seitentyp ist IndexPage
.
Diese Seite enthält keine Daten, ist also weder zum Lesen noch zum Schreiben notwendig.
Jeder ColumnChunk
kann nur eine IndexPage
enthalten, und sie befindet sich immer am Ende, nach DictionaryPage
und allen DataPage
.
Das Ziel dieser Seite ist die Speicherung von Statistiken über ColumnChunk
, wie z.B. Min/Max
-Werte, Anzahl der Nulls
oder Sortierreihenfolge für jede Seite in einem bestimmten ColumnChunk
.
Das ermöglicht schnelles Filtern und Finden bestimmter Seiten innerhalb eines gegebenen ColumnChunks
, was die Durchsuchung der Datei erheblich beschleunigt, wenn wir an bestimmten Informationen interessiert sind.
Hinweis: Jeder ColumnChunk
enthält in seinen Metadaten ähnliche Statistiken wie IndexPage
, jedoch nicht für jede Seite, sondern für den gesamten ColumnChunk
.
Dadurch können wir zunächst ganze Spalten überspringen, die uns nicht interessieren, und dann sogar bestimmte Seiten, wodurch die Menge der Daten, die wir lesen müssen, auf ein absolutes Minimum reduziert wird.
Wenn man bedenkt, dass diese Informationen in den Metadaten der Datei zu finden sind, können sogar die größten Parquet-Dateien blitzschnell gelesen und gefiltert werden, auch wenn sie nur über das Netzwerk verfügbar sind.
Es reicht, wenn wir die Metadaten lesen können. Auf deren Basis lokalisieren wir eine bestimmte Zeilengruppe, dann eine ausgewählte Spalte und am Ende bestimmte Seiten.
Wir erhalten so eine sehr präzise Lokalisierung unserer Daten, die wir mit dem Http Range Header
lesen können.
Das ist genau einer der Gründe, warum Parquet so mächtig ist. Wir sprechen hier nicht mehr über das brutale Herunterladen und Iterieren über eine Gigabyte-Datei. Parquet ermöglicht es, mit chirurgischer Präzision nur die Bereiche der Datei herunterzuladen und zu lesen, die uns wirklich interessieren.
Dremel
Bei der Diskussion der DataPage
-Struktur erwähnte ich Definition Levels
und Repetition Levels
.
Das diskutierte Beispiel war sehr einfach, weil es sich um eine einfache Spalte (int32) handelte, wodurch Repetition Levels
überhaupt nicht anwendbar sind.
Die Situation ändert sich dramatisch, wenn wir es mit einer verschachtelten Spalte zu tun haben, z.B. einer Struktur, Liste oder Map.
Schauen wir uns ein Beispiel an.
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
Zurück zum früheren Teil dieses Artikels, genauer zu verschachtelten Typen.
Wir wissen, dass unsere Daten nach dem Abflachen so aussehen werden:
items.list.element.sku
-"abc","def"
items.list.element.quantity
-1,2
items.list.element.price
-100,200
Wir haben hier 3 Spalten, jede von ihnen wird sich in einem separaten Column Chunk
befinden, und jede wird
eine oder mehrere Seiten in sich enthalten.
Woher wissen also Bibliotheken, die Dateien lesen, basierend auf diesen beiden Werten (Repetition / Definition Levels
), wie tief in der Struktur sich Werte befinden und zu welchem Element sie gehören?
Was wäre, wenn unsere Struktur so aussehen würde:
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":null, "quantity": 10, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
(im zweiten Element hat sku den Wert null).
Was ist, wenn die Struktur viel verschachtelter ist? Woher sollen wir wissen, welcher Wert auf welche Verschachtelungsebene gehört?
Die Antwort auf diese und viele andere Fragen finden wir in dem von Google veröffentlichten Dokument Dremel: Interactive Analysis of Web-Scale Datasets das beschreibt, wie Google verschachtelte Datenstrukturen speichert und durchsucht.
Das von Google verwendete Tool heißt Dremel und ist ein verteiltes System zur Durchsuchung großer Datensätze.
Es basiert auf 2 Algorithmen, Shredding
und Assembling
, die sehr oberflächlich im obigen Dokument beschrieben sind.
Hinweis: Die Beschreibung der genauen Funktionsweise dieser Algorithmen geht über den Rahmen dieses bereits langen Beitrags hinaus.
Wenn jedoch Interesse an dem Thema aufkommt, werde ich versuchen, auch diesen Aspekt in kommenden Beiträgen zu behandeln.
Diese Algorithmen basieren auf diesen 3 Definitionen:
- Repetition Levels
- Definition Levels
- Values
Wie bereits erwähnt, bestimmt Definition Level
, ob eine gegebene Zeile einen Wert enthält oder nicht, Repetition Level
, das bei flachen Spalten immer 0 ist.
Für Strukturen wird es bestimmen, ob ein Wert (oder null) wiederholt werden soll und auf welcher Verschachtelungsebene.
Hinweis: Das Wissen darüber, wie genau die Algorithmen aus Dremel funktionieren, ist nicht notwendig für die optimale Nutzung von Parquet.
Aus diesem Grund werde ich nicht ausführlich darüber schreiben. Wenn jedoch Interesse an dem Thema aufkommt, werde ich versuchen, auch diesen Aspekt in kommenden Beiträgen zu behandeln.
Unten werde ich nur ungefähr zeigen, wie die abgeflachten Daten aussehen werden.
Input:
[
'items' => [
['sku' => 'abc', ...],
['sku' => 'def', ...],
]
]
Output:
{
["sku"] =>
{
["repetition_levels"] => { [0] => int(0) [1] => int(1) }
["definition_levels"] => { [0] => int(1) [1] => int(1) }
["values"] => { [0] => string(3) "abc" [1] => string(3) "def" }
}
}
Also speichern wir in Wirklichkeit 0, 1, 0, 1, "abc", "def"
und nicht nur "abc", "def"
.
Genau diese zusätzlichen Zahlen sagen uns, wie beliebige verschachtelte Strukturen rekonstruiert werden können.
Interessant ist, dass sogar Repetition Levels und Definition Levels zur Optimierung früh entsprechend mit dem Algorithmus Run Length Encoding / Bit-Packing Hybrid gepackt werden.
Das ist noch nicht alles, denn nicht nur die Ebenen werden gepackt, sondern auch die Werte selbst.
Je nach Spaltentyp können Werte auf verschiedene Weise gepackt werden. Eine Liste aller von Parquet unterstützten Packungsalgorithmen (zumindest theoretisch) finden wir
in der offiziellen Dokumentation.
Informationen darüber, welcher Algorithmus zum Packen der Daten vor dem Schreiben verwendet wurde, finden wir in den Metadaten unter dem Pfad RowGroups[x].ColumnChunk[y].PageHeader[z].data_page_header.encoding
Das ist jedoch nicht Parquets letztes Wort im Kontext der Optimierung!
Kompression
Nach dem Packen und Speichern unserer Daten für eine bestimmte Seite in binärer Form wird jede Seite zusätzlich komprimiert.
Je nach Implementierung erlaubt Parquet die Verwendung verschiedener Kompressionsalgorithmen:
- UNCOMPRESSED
- SNAPPY
- GZIP
- LZO
- BROTLI
- LZ4
- ZSTD
- LZ4_RAW
Eine sehr beliebte Option ist Snappy, die einen sehr guten Kompromiss zwischen Geschwindigkeit und Kompressionsgrad bietet.
Tools wie Apache Spark verwenden es sogar standardmäßig.
Verschlüsselung
Eine der letzten interessanten Funktionen, die ich besprechen möchte, ist die Verschlüsselung!
Ja, Parquet ermöglicht die Verschlüsselung von Daten, Verschlüsselung auf mehreren Ebenen.
- Metadaten - verschlüsselte Metadaten erschweren das Lesen des Dateiinhalts erheblich, machen es jedoch nicht unmöglich
- Daten - verschlüsselte Daten machen das Lesen praktisch unmöglich
- Spalten - besonders nützlich, wenn nur einige Spalten sensible Daten enthalten.
- Seiten
Hinweis: Verschlüsselung ist eine jener Funktionen, die ich in der Implementierung für PHP noch nicht abgedeckt habe
Aus diesem Grund werde ich nicht ausführlich darüber schreiben. Sobald sich die Gelegenheit ergibt, diese Funktionalität zu implementieren, werde ich versuchen, den Artikel zu ergänzen.
Die Verschlüsselung in Parquet basiert auf Parquet Modular Encryption und nutzt AES zur Datenverschlüsselung.
Verschlüsselung, besonders von ausgewählten Spalten, hebt Parquet auf ein höheres Niveau der Datenspeicherung.
Dadurch können wir relativ einfach, mit geringem Overhead,
zusätzlich Daten sichern, die wir in Parquet-Dateien speichern.
Stellen wir uns vor, dass Parquet zur Speicherung von Kundendaten verwendet wird, wo die Spalten email
und phone
sensible Daten enthalten.
In dieser Situation bietet es sich geradezu an, diese beiden Spalten zusätzlich zu sichern. Selbst wenn es jemandem gelingt, physischen Zugang zur Datei zu erlangen, kann er ohne Schlüssel die
Daten trotzdem nicht lesen.
Zusammenfassung
Das ist genau das Geheimnis von Parquet und der Weg zur Effizienz. Anstatt beliebige Daten in Textform zu speichern, geht Parquet mehrere Schritte weiter.
Zunächst wird ein Datenschema basierend auf einfachen, aber unglaublich flexiblen Typen erzwungen, die alle in binärer Form dargestellt werden können.
Dann wird die binäre Form entsprechend gepackt, um unnötige Byte-Wiederholungen zu vermeiden, was am Ende noch zusätzlich mit sehr effizienten Algorithmen komprimiert wird.
Die Kirsche auf der Torte sind erweiterte und detaillierte Metadaten, die auf mehreren Ebenen verfügbar sind und es ermöglichen, unnötige Partitionen oder sogar ganze Dateien zu filtern, ohne deren Inhalt zu lesen.
Außerdem können wir dank der entsprechenden logischen Unterteilung, über die wir volle Kontrolle haben (Größe von Gruppen und Seiten), entscheiden, was für uns wichtiger ist: Geschwindigkeit oder Speichereinsparung. Durchsuchen oder Lesen von Daten oder vielleicht Sicherheit, für die wir zusätzliche Verschlüsselung verwenden werden?
Parquet ist wirklich ein mächtiges Tool, das in den richtigen Händen die effiziente Speicherung und Durchsuchung
riesiger Datenmengen ermöglicht.
Wenn dieser Beitrag Sie dazu inspiriert hat, mit diesem fantastischen Datenformat zu experimentieren, lassen Sie es mich in den Kommentaren wissen!
Hilfe
Wenn Sie Hilfe beim Aufbau eines zentralen Data Warehouse benötigen, helfe ich Ihnen gerne.
Kontaktieren Sie mich, und wir werden gemeinsam eine Lösung schaffen, die perfekt auf Ihre Bedürfnisse zugeschnitten ist.
Ich lade Sie auch ein, den Server Discord - Flow PHP zu besuchen, wo wir direkt sprechen können.
