
Parquet - Introduction
Parquet, un format de fichier binaire et colonnaire créé pour le stockage et la recherche efficaces de données.
Sur internet, il y a plein d'articles sur Parquet, alors pourquoi en ajouter un de plus ?
Voici ma vision de ce format fantastique, qui est essentiellement le résultat de mes expériences de travail sur
l'écriture d'une implémentation de Parquet en PHP pur.
Pour ceux qui sont arrivés ici par hasard, je mentionnerai juste que je suis l'auteur du premier framework de traitement
de données en PHP, appelé Flow PHP.
Comme il convient à un Data Frame, Flow doit pouvoir lire et écrire des données dans différents formats, y compris Parquet
Cependant, comme la seule implémentation que j'ai trouvée était essentiellement un port direct de C#, qui en plus ne gère pas complètement les structures profondément imbriquées et possède beaucoup de fonctions manquantes, j'ai décidé dans le cadre de l'apprentissage, d'écrire ma propre implémentation à partir de zéro, ce qui s'est avéré être une expérience extrêmement précieuse mais aussi très amusante.
Pourquoi Parquet
- Format Binaire - des fichiers jusqu'à 10x plus petits
- Métadonnées - accès plus facile aux données sélectionnées
- Schéma - garantie de structure correcte
- Compression - réduction supplémentaire de la taille
- Chiffrement - au niveau du fichier, des métadonnées, des colonnes ou des pages
Format Binaire
Grâce au fait que ce format est orienté colonnes, pas lignes, il permet une compression de données très efficace, ce qui se traduit par une taille de fichier significativement plus petite. Sans beaucoup d'effort, Parquet peut comprimer les données jusqu'à 10 fois, comparé aux formats traditionnels comme CSV ou XML.
Donc, si les mêmes données sauvegardées au format CSV occupent 1Go, au format Parquet elles peuvent n'occuper que 100Mo.
Pour les besoins de cet article, j'ai généré 2 fichiers, un au format CSV, l'autre au format Parquet.
La structure de ces fichiers est très simple, elle contient 10 colonnes et 10 millions de lignes, qui ressemblent à peu près à ceci :
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}]"
L'effet de compression est vraiment impressionnant :
4.1G Sep 20 18:32 orders.csv
437M Sep 20 18:47 orders.parquet
Cela se traduit non seulement par des coûts de stockage, mais aussi de traitement des données.
Surtout quand nos données vivent dans le cloud, que ce soit sur Azure Bucket ou AWS S3. L'un des plus grands facteurs affectant
la facture n'est pas tant la taille des données, mais combien de trafic nous utilisons pour lire/écrire ces données.
Donc en réduisant la taille du fichier, nous réduisons non seulement le coût de le stocker, mais aussi de le traiter. Il est important de comprendre que le traitement est en réalité toute forme d'accès, c'est-à-dire écriture/lecture.
Cela revient donc à dire qu'en choisissant le format de fichier approprié, les économies peuvent être vraiment importantes, surtout quand on parle de plus grandes quantités de données.
Que signifie exactement que Parquet est un format binaire ?
Cela signifie à peu près que les données sont stockées sous forme binaire, c'est-à-dire d'une manière qui ne peut pas être lue directement en utilisant des éditeurs de texte populaires.
Mais tout finalement est stocké sous forme binaire, non ?
Oui, généralement les fichiers texte sont aussi des fichiers binaires, la différence est que dans les fichiers texte la structure du fichier est toujours la même et chaque information est sauvegardée de la même manière.
Par exemple, si nous voulions sauvegarder "12345" dans un fichier texte, la version binaire ressemblerait à ceci :
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)
La même chaîne sauvegardée au format binaire comme int32 (entier sous forme 32 bits) ressemblerait à ceci :
INTEGER: 12345
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Integer: 0 0 48 57
Binary: 00000000 00000000 00110000 00111001
└─byte─┘ └─byte─┘ └─byte─┘ └─byte─┘
Total: 4 bytes for a 32-bit integer
Remarquons que dans le cas de sauvegarder un entier sous forme binaire, on ne peut pas simplement le lire de gauche à droite (ou vice-versa). Ici nous devons déjà savoir comment interpréter ces bits pour comprendre ce qu'ils signifient. Dans le cas des fichiers texte nous n'avons pas ce problème, puisque nous savons que chaque caractère est sauvegardé sous forme 8 bits.
À peu près pour cette raison n'importe quel éditeur de texte est capable d'ouvrir n'importe quel fichier texte et de nous afficher quelque chose qui aura plus ou moins de sens.
Cependant, si nous essayons d'ouvrir un fichier type Parquet dans un éditeur de texte, nous obtiendrons une chaîne de caractères semblant très aléatoire et n'ayant pas beaucoup de sens.
Colonnaire / Par Lignes
Il est préférable d'expliquer la différence entre ces formats à l'aide de visualisation.
Dans le modèle classique par lignes chaque ligne contient toutes les colonnes, comme par exemple au format CSV
+------+------+------+
| Col1 | Col2 | Col3 |
+------+------+------+
| A1 | B1 | C1 |
| A2 | B2 | C2 |
| A3 | B3 | C3 |
+------+------+------+
Le format colonnaire est intéressant car au lieu de stocker les données ligne par ligne, il les stocke colonne par colonne.
+------+------+------+------+
| Col1 | A1 | A2 | A3 |
+------+------+------+------+
| Col2 | B1 | B2 | B3 |
+------+------+------+------+
| Col3 | C1 | C2 | C3 |
+------+------+------+------+
Stocker les données au format colonnaire apporte de nombreux avantages, comme :
- Bien meilleure capacité de compression des données
- Possibilité de lire seulement les colonnes sélectionnées
- Possibilité de chiffrer les colonnes sélectionnées ou toutes
Dans le cas du format par lignes, pour lire seulement une colonne, nous devons quand même parcourir tout le fichier.
Dans le cas du format colonnaire nous pouvons lire seulement les colonnes qui nous intéressent.
C'est particulièrement utile dans le cas de très gros ensembles de données, où souvent nous n'avons besoin que d'une partie de l'information.
Immuable
En raison de la façon dont les données sont stockées au format colonnaire, les fichiers Parquet sont immuables.
Cela ne signifie pas cependant qu'on ne peut pas les modifier. On peut, mais la seule opération sensée est d'ajouter des données à la fin.
Pourquoi ? Parquet stocke les données au format colonnaire, cela signifie que si nous avons une colonne email
toutes les lignes (dans un groupe de lignes et une page donnés - de cela plus tard) seront écrites l'une après l'autre.
Essayer de modifier une ligne est donc impossible, parce que cela nécessiterait de déplacer pratiquement tout le fichier.
Il est cependant possible d'ajouter un nouveau groupe de lignes à la fin du fichier. Cela se fait en retirant les métadonnées de la fin du fichier, qui temporairement vont en mémoire. À leur place on écrit le nouveau groupe de lignes (qu'il faut aussi ajouter aux métadonnées), et ensuite à la fin on réécrit les métadonnées.
Pour cette raison, si nous voulons supprimer quelque chose d'un fichier Parquet, en pratique nous devons réécrire tout le fichier, en omettant les données indésirables.
Structure Forte
Parquet est un format basé sur le typage fort. Cela signifie que la structure de tout le fichier est définie et stockée dans le pied de page, grâce à quoi il suffit de lire seulement le segment approprié pour comprendre quelles données nous avons dans le fichier, et dans quelles régions du fichier ces données sont sauvegardées.
Nous pouvons penser à cela comme à une carte du fichier, une carte qui nous dira où exactement dans le fichier se trouvent les données qui nous intéressent.
Voici comment ressemble à peu près la structure simplifiée d'un fichier au format Parquet :
+-----------------+
| PAR1 |
+-----------------+
| Data |
| ............. |
| ............. |
+-----------------+
| File Metadata |
+-----------------+
| PAR1 |
+-----------------+
Dans l'exemple ci-dessus nous voyons 3 éléments :
PAR1
- c'est-à-dire "Parquet Magic Bytes" - 4 octets ouvrant et fermant les fichiers au format ParquetData
- ici sont sauvegardées toutes les colonnes (de cela plus tard)Metadata
- métadonnées, c'est-à-dire la carte du fichier
La première étape pour lire correctement un fichier Parquet est de vérifier si les 4 premiers octets sont PAR1
.
Si c'est le cas, nous devons sauter à la fin du fichier (seek) et lire les 4 derniers octets.
Si la fin et le début du fichier contiennent PAR1
nous pouvons procéder à la lecture des métadonnées.
Pour cela nous reculons de 8 octets depuis la fin du fichier et lisons 4 octets représentant la taille des métadonnées.
En d'autres termes, nous lisons les octets -8
à -4
Ces 4 octets sont un integer
nous disant sur combien d'octets sont écrites les métadonnées. Ayant
cette information nous pouvons lire les métadonnées, qui sont sérialisées de manière binaire à l'aide d'Apache Thrift
Apache Thrift
Apache Thrift est un outil très intelligent permettant la sérialisation binaire d'interfaces / types dans pratiquement chaque langage de programmation.
Ici nous pouvons voir comment ressemble la définition des métadonnées au format Parquet.
Ce format ressemble un peu à du pseudocode, qui ensuite à l'aide de l'application appropriée est utilisé pour générer du code dans un langage de programmation donné.
Ici nous pouvons voir comment ressemble le code généré en PHP.
Quand nous avons déjà les structures/interfaces/modèles générés nous pouvons procéder à la lecture.
<?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))
)
)
);
Pour cela nous aurons besoin de la bibliothèque Thrift pour le langage de programmation choisi. Toutes les implémentations sont disponibles dans le dépôt apache/thrift.
Ayant accès à $metadata
nous pouvons commencer à analyser notre fichier pour comprendre sa structure.
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
}
Les informations clés sur le fichier sont stockées dans la structure FileMetaData
.
Les plus importantes d'entre elles sont :
version
- version du format Parquetnum_rows
- nombre de lignes dans le fichierschema
- schéma des donnéesrow_groups
- ici sont stockées nos données
Versions du Format
Au moment d'écrire cet article le format Parquet était déjà disponible en version 2.12.0
.
Les changements les plus cruciaux entre les versions 1.0 et 2.0 sont :
- Nouveaux schémas d'encodage : DELTA_BINARY_PACKED pour les nombres, DELTA_BYTE_ARRAY pour les chaînes, RLE_DICTIONARY remplaçant PLAIN_DICTIONARY
- Structure Data Page V2 : Éliminé la surcharge de métadonnées, permis le filtrage au niveau des pages
Bien que la version 2.0 introduise de nombreuses améliorations, les plus grands acteurs utilisent encore la version 1 par défaut.
Nombre de Lignes
Cette information peut sembler peu intuitive au début dans le contexte du format colonnaire.
Nous devons cependant nous rappeler que le format colonnaire n'est qu'une façon de stocker les valeurs et non la structure des données.
Bien que les données soient groupées sur la base des colonnes et de leur type, la lecture/écriture se fait encore de manière classique, c'est-à-dire ligne par ligne.
La différence est qu'on ne lit pas une ligne à la fois, mais tout un groupe de lignes, chargeant en mémoire colonne par colonne, puis reconstruisant les lignes sur la base des index appropriés.
En gardant à l'esprit que pour écrire correctement les données au format colonnaire nous devons opérer sur des groupes logiques, et non sur des lignes individuelles. Nous pouvons de manière relativement facile gérer le rapport entre mémoire et quantité d'opérations IO.
L'écriture et la lecture depuis la mémoire est plus rapide que l'écriture et la lecture depuis le disque (bien que pas toujours).
En augmentant la quantité de lignes qui seront écrites dans un groupe, nous réduisons le nombre de groupes, c'est-à-dire le nombre d'opérations IO.
Ainsi nous augmentons la vitesse d'écriture/lecture, tout en augmentant l'utilisation de la mémoire.
Cela marche aussi dans l'autre sens, en réduisant la quantité de lignes dans un groupe, nous augmentons le nombre de groupes dans le fichier, ainsi augmentant le nombre d'opérations IO.
Taille du groupe, pas quantité de lignes - Parquet permet de définir non pas la quantité de lignes, mais la taille maximale
du groupe de lignes.
Il faut cependant se rappeler que ce ne sont pas des valeurs absolues (de cela un peu plus tard), donc
certains groupes peuvent être plus petits/plus gros que la taille permise et cela dépend principalement de l'implémentation de la bibliothèque
pour Parquet.
Dans la documentation du format Parquet nous trouverons l'information que la taille suggérée du groupe est 512Mo - 1Go
.
Il vaut cependant la peine d'aborder cela avec un peu de bon sens, surtout si pour la lecture/écriture nous ne dépendons pas de HDFS (Hadoop Distributed File System).
La valeur suggérée est établie de telle manière qu'un groupe de lignes tienne dans un bloc HDFS, garantissant que la lecture
se fasse depuis exactement un nœud.
Il vaut la peine de s'en souvenir, cependant si nous ne prévoyons pas d'utiliser Parquet avec un système de fichiers distribué, des groupes de lignes plus petits permettront d'économiser beaucoup de mémoire.
Un très bon exemple de cas où des groupes plus petits sont plus efficaces est le cas où nous voudrions lire seulement une petite section de lignes quelque part au milieu du fichier (pagination).
En supposant que nous devons lire seulement 100 lignes d'un fichier qui contient 10 millions de lignes, établir une taille de groupe plus petite permettra d'économiser beaucoup en mémoire. Pourquoi ?
Si nous divisons 10 millions en disons 10 groupes, chaque groupe contient 1 million de lignes. Cela signifie qu'en pratique nous devons lire tout le groupe, puis extraire seulement les 100 lignes qui nous intéressent.
Dans le cas d'établir une taille de groupe plus petite, qui permettrait de diviser 10 millions en 1000 groupes, en analysant les métadonnées du fichier, nous pourrons sauter une plus grande quantité de groupes et charger en mémoire une quantité beaucoup plus petite de lignes.
La décision sur la taille du groupe de lignes devrait être réfléchie tant pour la performance d'écriture que de lecture du fichier spécifique. La configuration appropriée se traduit directement par l'utilisation de ressources ce qui finalement se traduit par de l'argent.
Schéma
Lentement nous arrivons au cœur de Parquet, c'est-à-dire Row Groups
. Mais avant d'analyser leur structure, nous devons
revenir à un autre aspect très important de Parquet, le schéma des données.
Commençons par les types de données. Parquet consiste en types physiques et logiques.
Types Physiques
Les types physiques sont les types de données de base qui sont utilisés pour stocker les valeurs dans le fichier Parquet. Ce sont des types tels que :
- Boolean
- Byte Array
- Double
- Fixed Len Byte Array
- Float
- Int32
- Int64
- Int96 - (déprécié - utilisé seulement par les anciennes implémentations)
Les types logiques sont des types qui sont utilisés pour représenter des structures de données plus complexes. On peut penser à eux comme à une extension des types physiques.
Types Logiques
- Bson
- Date
- Decimal
- Enum
- Integer
- Json
- List
- Map
- String
- Time
- Timestamp
- Uuid
La structure actuelle peut toujours être vérifiée à la source, apache/parquet-format
Outre la division en types logiques et physiques, Parquet distingue aussi les colonnes plates et imbriquées.
Les colonnes plates sont celles qui stockent une seule valeur, par exemple Int32
, Boolean
, Float
, etc.
Les colonnes imbriquées sont celles qui stockent plus d'une valeur, par exemple List
, Map
, etc.
En principe il existe 3 types de colonnes imbriquées :
- List
- Map
- Struct
Struct, est un type spécial de colonne qui permet d'imbriquer n'importe quels autres types, permettant de créer pratiquement n'importe quelle structure de données.
En utilisant les types ci-dessus nous sommes capables de modéliser pratiquement n'importe quelle structure de données, puis de la stocker et de la rechercher efficacement.
Regardons donc les définitions Thrift SchemaElement
et quelques éléments liés.
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
}
La plupart des valeurs devraient être assez évidentes, regardons cependant FieldRepetitionType
.
Cette valeur nous dit si une colonne donnée est requise, optionnelle ou répétable.
Si une colonne est requise, cela signifie que la valeur ne peut pas être nulle.
Si une colonne est optionnelle la valeur peut être nulle, et si elle est répétable, cela signifie qu'elle peut contenir plusieurs valeurs (par exemple une liste).
Voici comment peut ressembler le schéma d'un fichier de commandes (sous forme DDL)
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;
}
}
}
}
Types Imbriqués
Pour comprendre pleinement la structure des groupes de lignes nous devons d'abord comprendre comment Parquet aplatit les types imbriqués.
Alors que des structures simples comme address
de l'exemple ci-dessus peuvent être réduites essentiellement à 4 colonnes simples :
address.street
- Stringaddress.city
- Stringaddress.zip
- Stringaddress.country
- String
Dans le cas de Map
ou List
la situation est un peu plus compliquée.
Par exemple, si nous voulions aplatir Map<string,int32>
nous obtiendrions quelque chose comme ceci :
map_column.key_value.key
- Stringmap_column.key_value.value
- Int32
Donc pour l'exemple ci-dessus le chemin plat vers sku
ressemblerait à ceci :
items.list.element.sku
, tandis que la structure plate complète ressemblerait à ceci :
┌─────────────────────────────┬──────────────────────── 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 │
└─────────────────────────────┴──────────────────────────┴──────────────┴────────────┴────────────────┴────────────────┘
Groupes de Lignes
+-----------------------------------+
| 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 |
+-----------------------------------+
Conformément à ce que nous savons déjà, un fichier Parquet est divisé en groupes de lignes, l'écriture au fichier fonctionne en résumé ainsi :
- 1) créer le fichier et ajouter 4 octets
PAR1
- 2) créer une structure de métadonnées basée sur le schéma et la garder en mémoire
- 3) aplatir la ligne passée (en vérifiant si elle correspond au schéma)
- 4) écrire la ligne aplatie en mémoire sous forme binaire
-
5) vérifier si la taille du groupe de lignes que nous avons actuellement en mémoire rentre dans la taille maximale permise
- a) écrire le groupe de lignes au fichier
- b) mettre à jour les métadonnées en mémoire en leur ajoutant les métadonnées du groupe que nous venons d'écrire
- 6) retourner à l'étape 2
- 7) Écrire les métadonnées à la fin du fichier après avoir écrit tous les groupes de lignes
-
8) Fermer le fichier avec 4 octets
PAR1
Bien sûr cette description est très simplifiée, en réalité c'est un peu plus complexe, de plus différentes implémentations peuvent différer dans les détails.
Concentrons-nous sur la structure du groupe de lignes, regardons d'abord les définitions Thrift RowGroup
.
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
}
Déjà à cette étape on voit combien d'informations sur un groupe spécifique de lignes sont stockées dans les métadonnées.
Pour l'instant concentrons-nous sur trois champs :
file_offset
- c'est-à-dire combien d'octets depuis le début du fichier il faut sauter pour lire le groupe donnétotal_byte_size
- sur combien d'octets est écrit le groupe de lignescolumns
- informations détaillées sur chaque colonne écrite dans le cadre du groupe donné
Important : chaque groupe de lignes contient toujours toutes les colonnes définies dans le schéma.
Même si sur l'étendue de tout le groupe une colonne ne contient que des valeurs nulles.
Chunks de Colonne
Allons plus profond et regardons la définition Thrift ColumnChunk
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;
}
Rappel : Tout ce sur quoi nous avons regardé jusqu'à maintenant fait encore partie des métadonnées.
Cela signifie que toute cette information sur les colonnes, groupes de lignes ou les données elles-mêmes nous l'obtenons en lisant
seulement la fin du fichier, indépendamment de si le fichier fait 1Mo ou 1To.
Ici nous arrivons essentiellement à l'endroit qui nous permet de lire les données du fichier.
Mais avant que cela n'arrive nous devons connaître la dernière structure de données nécessaire à la lecture.
Pages de Données
Pages
, c'est-à-dire une autre division logique dans la structure du fichier Parquet.
Row Group -> Column Chunk -> Data Pages
RowGroup
- groupe de lignes (partition)ColumnChunk
- chaque groupe de lignes contient exactement 1ColumnChunk
pour chaque colonne dans le groupeData Page
- page, la plus petite unité logique dans Parquet agrégeant les données
En fait la lecture de Parquet se résume à analyser la structure des métadonnées, localiser l'adresse du début d'un groupe spécifique de lignes, puis une colonne spécifique dans le groupe, puis itérer et lire les données de chaque page.
Mais avant de commencer à lire les pages, nous devons comprendre si nous avons affaire à DataPage
, IndexPage
ou DictionaryPage
.
Pour cela nous lisons d'abord PageHeader
c'est-à-dire l'en-tête de la page, dont la définition Thrift ressemble à ceci
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;
}
Pour lire l'en-tête nous devons connaître son adresse relative au début du fichier, voici comment nous pouvons la calculer pour un groupe de lignes et une colonne sélectionnés :
- Nous lisons
FileMetadata
- Nous trouvons le
RowGroup
approprié et recherchons leColumnChunk
pertinent pour nous - Ayant
ColumnChunk
nous obtiendrons l'adressefile_offset
du début deColumnChunk
relative au début du fichier.
Important : À cette étape nous n'avons pas encore besoin de charger physiquement les octets en mémoire.
Il suffit que nous créions un stream
permettant de lire les données directement depuis le fichier.
La première chose qu'il faut lire est l'en-tête, PageHeader
, en le faisant avec Thrift, en passant
le stream et en configurant appropriément l'adresse du début nous obtiendrons la structure de données PageHeader
, qui nous dira exactement comment lire
la page elle-même.
Il existe 3 types de pages :
DataPage
Page contenant la représentation binaire des données de la colonne sélectionnée des lignes qui sont allées dans le groupe de lignes sélectionné.
C'est le type de page le plus simple et le plus direct. Elle contient "seulement" des données.
En lisant une colonne de type entier, ce qui nous intéresse c'est vraiment le nombre de lignes dans le groupe spécifique (chaque ligne est une valeur dans DataPage
).
Donc en sachant que dans ce groupe nous avons disons 100 valeurs, nous savons que nous devons lire 400 octets (int32 est écrit sur 4 octets).
Bon, mais que se passe-t-il quand la colonne est optionnelle ? Cela signifie qu'elle peut contenir des valeurs nulles.
Ici la situation devient un peu plus compliquée parce que nous devons savoir quelles lignes contiennent une valeur nulle.
D'où vient cette connaissance ?
Definition Levels
La situation se complique un peu, au début j'ai écrit que DataPage
contient seulement des données, et maintenant j'ajoute des Definition Levels
.
En réalité la structure de data page ressemble à peu près à ceci :
Parquet Data Page: int32
=======================================
[ Repetition Levels ]: 0, 0, 0, 0, 0
---------------------------------------
[ Definition Levels ]: 1, 0, 1, 1, 0
---------------------------------------
[ Values ]: 42, 73, 19
=======================================
Pour l'instant, concentrons-nous seulement sur Definition Levels
et Values
. Il est très facile de remarquer la relation entre eux.
La quantité de Definition Level
et Repetition Levels
dans chaque page est toujours égale à la quantité de valeurs dans la colonne.
Peu importe s'il y a des nulls ou pas. Definition Levels
nous disent si une ligne donnée contient une valeur ou null.
Sur cette base, nous pouvons facilement déterminer la quantité totale de Values
non vides ce qui nous permettra de les lire.
Dans l'exemple ci-dessus nous avons 5 lignes, dont 3 constituent des valeurs, puisque int32
nous l'écrivons sur 4 octets,
nous savons déjà que nous devons lire en total 12 octets.
Nous savons aussi qu'en transformant la colonne en lignes, la première ligne contiendra la valeur 42
, la deuxième null
,
la troisième 73
, la quatrième 19
et la cinquième null
.
Important : Repetition Levels
et Definition Levels
sont cependant beaucoup plus compliqués, un peu plus plus tard.
Voici comment se présente à peu près la structure de DataPage
.
DictionaryPage
Si nous gardons les données dans DataPage
, quel but a DictionaryPage
?
Eh bien DictionaryPage
est une page qui contient un dictionnaire de valeurs.
Dictionnaire, utilisé pour lire les données, surtout dans le cas de colonnes contenant des valeurs répétables.
Cela marche à peu près ainsi qu'en lisant ColumChunk
, nous commençons par la première page, si cette page est DictionaryPage
,
nous savons que nous avons affaire à un dictionnaire (en fait nous le savons depuis le début, parce que c'est écrit dans les métadonnées de la colonne).
Si par exemple nous lisons une colonne avec haute répétabilité, ex. une colonne avec le nom du pays, au lieu d'écrire dans DataPage
le nom complet du pays pour chaque ligne,
nous écrivons seulement sa position dans le dictionnaire.
Dans le cas d'une telle colonne la première page dans la colonne sera DictionaryPage
, et les suivantes seront DataPage
.
La différence est que dans DataPage
au lieu de la valeur complète, il y aura des positions dans le dictionnaire, que nous garderons en mémoire pour reconstruire les lignes.
Important : Chaque ColumnChunk
peut contenir seulement une page DictionaryPage
.
Cela peut donner d'énormes économies, au lieu de disons écrire de manière binaire le mot Pologne
10 mille fois, c'est-à-dire 70k octets,
nous écrirons seulement la position dans l'index (c'est-à-dire 4 octets), qui en plus seront empaquetés en utilisant l'algorithme Run Length Encoding / Bit-Packing Hybrid.
Qui, se basant aussi sur la répétabilité des valeurs consécutives réduira la quantité totale d'octets nécessaires.
IndexPage
Le dernier type de page est IndexPage
.
Cette page ne contient pas de données, donc elle n'est pas nécessaire pour la lecture ni l'écriture.
Chaque ColumnChunk
peut contenir seulement une page de type IndexPage
et elle se trouve toujours à la fin, après DictionaryPage
et toutes les DataPage
.
Le but de cette page est de stocker des statistiques concernant ColumnChunk
, comme les valeurs Min/Max
, quantité de nulls
ou manière de tri pour chaque page dans un ColumnChunk
spécifique.
Cela permet un filtrage rapide et de trouver seulement des pages spécifiques dans le cadre d'un ColumnChunk
donné, ce qui accélère significativement la recherche dans le fichier, si des informations spécifiques nous intéressent.
Attention : Chaque ColumnChunk
dans ses métadonnées contient des statistiques similaires à IndexPage
, mais pas pour chaque page mais pour tout le ColumnChunk
.
Grâce à cela, en premier lieu nous pouvons sauter des colonnes complètes qui ne nous intéressent pas et ensuite même des pages spécifiques, réduisant au minimum absolu la quantité de données que nous devons lire.
Considérant que cette information se trouve dans les métadonnées du fichier, même les plus gros fichiers Parquet peuvent être lus et filtrés instantanément même s'ils ne sont disponibles qu'à travers le réseau.
Il suffit que nous réussissions à lire les métadonnées, sur leur base localiser un groupe spécifique de lignes, puis une colonne sélectionnée et à la fin des pages spécifiques.
Nous obtiendrons de cette manière une localisation très précise de nos données, que nous pourrons lire en utilisant l'en-tête Http Range Header
.
C'est précisément une des raisons pour lesquelles Parquet est si puissant, nous ne parlons plus de téléchargement brutal et d'itération sur un fichier de gigaoctets. Parquet permet avec la précision d'un chirurgien de télécharger et lire seulement les zones du fichier qui nous intéressent vraiment.
Dremel
En discutant de la structure de DataPage
j'ai mentionné Definition Levels
et Repetition Levels
.
L'exemple discuté était très simple, parce qu'il concernait une colonne simple (int32), donc Repetition Levels
n'ont pas d'application du tout.
La situation change diamétralement quand nous avons affaire à une colonne imbriquée, ex. structure, liste ou carte.
Regardons un exemple.
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
En revenant à la partie antérieure de cet article, spécifiquement aux types imbriqués.
Nous savons que nos données après aplatissement ressembleront à ceci :
items.list.element.sku
-"abc","def"
items.list.element.quantity
-1,2
items.list.element.price
-100,200
Nous avons ici 3 colonnes, chacune d'elles se trouvera dans un Column Chunk
séparé et chacune contiendra
une ou plusieurs pages.
Alors comment sur la base de ces deux valeurs (Repetition / Definition Levels)
les bibliothèques lisant les fichiers savent-elles à quelle profondeur dans la structure se trouvent les valeurs et à quel élément elles appartiennent ?
Que se passerait-il si notre structure ressemblait à ceci :
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":null, "quantity": 10, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
(dans le deuxième élément sku a la valeur null).
Que se passe-t-il quand la structure est beaucoup plus imbriquée, comment savons-nous quelle valeur va à quel niveau d'imbrication ?
La réponse à cette et beaucoup d'autres questions nous la trouverons dans le document publié par Google Dremel: Interactive Analysis of Web-Scale Datasets qui décrit comment Google stocke et recherche les structures de données imbriquées.
L'outil utilisé par Google s'appelle Dremel et c'est un système distribué de recherche de gros ensembles de données.
Il se base sur 2 algorithmes, Shredding
et Assembling
, qui sont décrits très brièvement dans le document ci-dessus.
Attention : Décrire le fonctionnement exact de ces algorithmes dépasse le cadre de cet article déjà long.
Si de l'intérêt apparaît pour le sujet, j'essaierai d'aborder aussi ce fil dans les prochains articles.
Ces algorithmes se basent sur ces 3 définitions :
- Repetition Levels
- Definition Levels
- Values
Comme nous l'avons déjà mentionné Definition Level
détermine si une ligne donnée contient une valeur, ou pas, Repetition Level
qui dans le cas de colonnes plates est toujours 0.
Pour les structures il déterminera si la valeur (ou null) doit être répétée, et à quel niveau de profondeur.
Attention : La connaissance de comment fonctionnent exactement les algorithmes de Dremel, n'est pas nécessaire pour l'utilisation optimale de Parquet.
Pour cette raison, je ne vais pas m'étendre sur ce sujet, cependant si de l'intérêt apparaît pour le sujet, j'essaierai d'aborder aussi ce fil dans les prochains articles.
Ci-dessous je présenterai seulement à peu près comment ressembleront les données aplaties.
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" }
}
}
C'est-à-dire qu'en réalité nous écrivons 0, 1, 0, 1, "abc", "def"
et non seulement "abc", "def"
.
Ce sont précisément ces nombres additionnels qui disent comment reconstruire n'importe quelle structure imbriquée.
C'est curieux que même les repetition levels et definition levels pour l'optimisation sont empaquetés appropriément en utilisant l'algorithme Run Length Encoding / Bit-Packing Hybrid.
Ça ne s'arrête pas là, parce que non seulement les niveaux sont empaquetés, mais aussi les valeurs elles-mêmes.
Selon le type de colonne, les valeurs peuvent être empaquetées de différentes manières, la liste de tous les algorithmes d'empaquetage supportés par Parquet (au moins en théorie) nous la trouverons
dans la documentation officielle.
Tandis que l'information sur quel algorithme a été utilisé pour empaqueter les données avant l'écriture nous la trouverons dans les métadonnées, sous ce chemin RowGroups[x].ColumnChunk[y].PageHeader[z].data_page_header.encoding
Mais ce n'est pas le dernier mot de Parquet dans le contexte d'optimisation !
Compression
Après empaquetage et écriture sous forme binaire de nos données pour une page spécifique, chaque page est additionnellement compressée.
Selon l'implémentation Parquet permet l'utilisation de différents algorithmes de compression :
- UNCOMPRESSED
- SNAPPY
- GZIP
- LZO
- BROTLI
- LZ4
- ZSTD
- LZ4_RAW
Une option très populaire est Snappy, qui offre un très bon compromis entre vitesse et degré de compression.
Des outils comme Apache Spark l'utilisent même par défaut.
Chiffrement
Une des dernières caractéristiques intéressantes que je veux discuter, c'est le chiffrement !
Oui, Parquet permet de chiffrer les données, chiffrer à plusieurs niveaux.
- Métadonnées - les métadonnées chiffrées rendent effectivement difficile la lecture du contenu du fichier, mais ce n'est pas impossible
- Données - les données chiffrées rendent pratiquement impossible la lecture
- Colonnes - particulièrement utile si seulement certaines colonnes contiennent des données sensibles.
- Pages
Attention : Le chiffrement est une de ces caractéristiques que je n'ai pas encore couvertes dans l'implémentation pour PHP
Pour cette raison je ne vais pas m'étendre sur ce sujet, dès que j'aurai l'occasion d'implémenter cette fonctionnalité, j'essaierai de compléter l'article.
Le chiffrement dans Parquet se base sur Parquet Modular Encryption et utilise AES pour chiffrer les données.
Le chiffrement, surtout de colonnes sélectionnées, élève Parquet à un niveau supérieur de stockage de données.
Grâce à cela de manière relativement facile, avec peu de surcharge,
nous pouvons protéger en plus les données que nous stockons dans les fichiers Parquet.
Imaginons que Parquet est utilisé pour stocker des données de clients, où la colonne email
et phone
contiennent des données sensibles.
Dans cette situation, il est demandé que ces deux colonnes soient protégées en plus. Même si quelqu'un réussit à obtenir un accès physique au fichier, sans la clé il ne
pourra toujours pas lire les données.
Résumé
C'est précisément le secret de Parquet et la façon d'atteindre l'efficacité. Au lieu de stocker des données arbitraires sous forme textuelle, Parquet va plusieurs étapes plus loin.
En premier lieu il force un schéma de données basé sur des types simples mais incroyablement flexibles, dont chacun peut être
représenté sous forme binaire.
Ensuite la forme binaire est appropriément empaquetée, pour éviter les répétitions inutiles d'octets, ce qui à la fin est
additionnellement compressé à l'aide d'algorithmes très efficaces.
La cerise sur le gâteau sont les métadonnées avancées et détaillées, disponibles à plusieurs niveaux, permettant de filtrer
les partitions inutiles, ou même des fichiers entiers sans lire leur contenu.
De plus grâce à la division logique appropriée, sur laquelle nous avons un contrôle complet (taille des groupes et pages) nous pouvons décider ce qui est plus important pour nous, vitesse ou économie de mémoire. Recherche ou lecture de données ou peut-être sécurité, pour laquelle nous utiliserons un chiffrement additionnel.
Parquet est vraiment un outil puissant qui dans les bonnes mains permet un stockage et une recherche efficaces
d'énormes quantités de données.
Si cet article t'a inspiré à expérimenter avec ce format de données révolutionnaire, fais-le savoir dans les commentaires !
Aide
Si tu as besoin d'aide dans la construction d'un entrepôt central de données, je serai ravi de t'aider.
Contacte-moi, et ensemble nous créerons une solution qui sera parfaitement adaptée à tes besoins.
Je t'encourage aussi à visiter le serveur Discord - Flow PHP, où nous pouvons parler directement.
