Translation Notice

This is an automatically translated version of that Article. Despite my best efforts, it might not be perfect.
Native speakers are welcome to open pull requests to correct anything that doesn't sound right.

Parquet - Einführung

Parquet - Einführung

Veröffentlichungsdatum September 20, 2025 00:00
Parquet binär spaltenorientiert Dateiformat Datenkompression Metadaten Verschlüsselung Apache Thrift Flow PHP

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

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:

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:

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:

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:

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:

Logische Typen sind Typen, die zur Darstellung komplexerer Datenstrukturen verwendet werden. Man kann sie als Erweiterung der physischen Typen betrachten.

Logical Types

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:

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:

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:

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:

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:

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

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:

  1. Wir lesen FileMetadata
  2. Wir finden die entsprechende RowGroup und suchen den für uns relevanten ColumnChunk
  3. Mit ColumnChunk erhalten wir die Adresse file_offset des Beginns von ColumnChunk 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:

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:

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:

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.

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.

Beratung