
Parquet - Introduzione
Parquet è un formato di file binario e colonnare progettato per l'archiviazione e l'interrogazione efficiente dei dati.
Ci sono tonnellate di articoli su Parquet là fuori, quindi perché scriverne un altro?
Questa è la mia visione di questo formato fantastico, essenzialmente il risultato della mia esperienza nel lavorare su
un'implementazione Parquet pura in PHP.
Per quelli che sono capitati qui per caso, dovrei menzionare che sono l'autore del primo framework di elaborazione dati
per PHP chiamato Flow PHP.
Come ogni framework DataFrame che si rispetti, Flow deve leggere e scrivere dati in vari formati, incluso Parquet
Tuttavia, dato che l'unica implementazione che ho trovato era praticamente un port diretto da C#, che non gestisce particolarmente bene le strutture profondamente nidificate e ha parecchie funzionalità mancanti, ho deciso di scrivere la mia implementazione da zero come esercizio di apprendimento. Questo si è rivelato un'esperienza incredibilmente preziosa e molto divertente.
Perché Parquet
- Formato Binario - fino a file 10 volte più piccoli
- Metadati - accesso più facile ai dati selezionati
- Schema - integrità garantita della struttura dati
- Compressione - ulteriore riduzione delle dimensioni
- Crittografia - a livello di file, metadati, colonne o pagine
Formato Binario
Grazie al fatto di essere orientato alle colonne anziché alle righe, questo formato consente una compressione dei dati altamente efficiente, che si traduce in dimensioni di file significativamente più piccole. Senza troppo sforzo, Parquet può comprimere i dati fino a 10 volte rispetto ai formati tradizionali come CSV o XML.
Quindi se gli stessi dati memorizzati in formato CSV occupano 1GB, in formato Parquet potrebbero occupare solo 100MB.
Per questo post, ho generato 2 file - uno in formato CSV, l'altro in Parquet.
La struttura di questi file è molto semplice, contiene 10 colonne e 10 milioni di righe che assomigliano a qualcosa del genere:
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'effetto di compressione è davvero impressionante:
4.1G Sep 20 18:32 orders.csv
437M Sep 20 18:47 orders.parquet
Questo si traduce non solo in costi di archiviazione ma anche in costi di elaborazione dei dati.
Specialmente quando i tuoi dati vivono nel cloud, che sia su Azure Bucket o AWS S3. Uno dei fattori più grandi che influenzano
la tua bolletta non è in realtà la dimensione dei dati, ma quanto trasferimento usi per leggere/scrivere quei dati.
Quindi riducendo la dimensione del file, riduciamo non solo il costo di archiviarlo ma anche di elaborarlo. È importante capire che l'elaborazione significa davvero qualsiasi forma di accesso - operazioni di lettura/scrittura.
Questo si riduce al fatto che scegliendo il formato di file giusto, i risparmi possono essere davvero sostanziali, specialmente quando parliamo di quantità maggiori di dati.
Cosa significa effettivamente per Parquet essere un formato binario?
Significa grosso modo che i dati sono memorizzati in forma binaria - cioè, in una forma che non può essere letta direttamente usando editor di testo popolari.
Ma alla fine tutto è memorizzato in forma binaria, no?
Sì, generalmente anche i file di testo sono file binari. La differenza è che nei file di testo la struttura è sempre la stessa e ogni pezzo di informazione è scritto allo stesso modo.
Per esempio, se volessimo salvare "12345" in un file di testo, la versione binaria sarebbe così:
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 stessa stringa salvata in formato binario come int32 (intero in forma a 32 bit) sarebbe così:
INTEGER: 12345
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Integer: 0 0 48 57
Binary: 00000000 00000000 00110000 00111001
└─byte─┘ └─byte─┘ └─byte─┘ └─byte─┘
Total: 4 bytes for a 32-bit integer
Nota che nel caso del salvataggio di un intero in forma binaria, non puoi semplicemente leggerlo da sinistra a destra (o viceversa). Qui dobbiamo sapere come interpretare questi bit per capire cosa significano. Con i file di testo non abbiamo questo problema, dato che sappiamo che ogni carattere è salvato in forma a 8 bit.
Questo è più o meno il motivo per cui qualsiasi editor di testo può aprire qualsiasi file di testo e mostrarci qualcosa che ha più o meno senso.
Tuttavia, se proviamo ad aprire un file Parquet in un editor di testo, otterremo una stringa di caratteri che sembra molto casuale e non ha molto senso.
Colonnare vs Basato su Righe
Il modo migliore per spiegare la differenza tra questi formati è attraverso la visualizzazione.
Nel modello classico basato su righe, ogni riga contiene tutte le colonne, come nel formato CSV
+------+------+------+
| Col1 | Col2 | Col3 |
+------+------+------+
| A1 | B1 | C1 |
| A2 | B2 | C2 |
| A3 | B3 | C3 |
+------+------+------+
Il formato colonnare è interessante in quanto invece di memorizzare i dati riga per riga, li memorizza colonna per colonna.
+------+------+------+------+
| Col1 | A1 | A2 | A3 |
+------+------+------+------+
| Col2 | B1 | B2 | B3 |
+------+------+------+------+
| Col3 | C1 | C2 | C3 |
+------+------+------+------+
Memorizzare i dati in formato colonnare porta molti benefici, come:
- Capacità di compressione dei dati molto migliori
- Capacità di leggere solo colonne selezionate
- Capacità di crittografare colonne selezionate o tutte
Con un formato basato su righe, per leggere solo una colonna, dobbiamo comunque attraversare l'intero file.
Con un formato colonnare, possiamo leggere solo le colonne che ci interessano.
Questo è particolarmente utile con dataset molto grandi dove spesso abbiamo bisogno solo di parte delle informazioni.
Immutabile
A causa di come i dati sono memorizzati in formato colonnare, i file Parquet sono immutabili.
Questo non significa che non possono essere modificati. Possono esserlo, ma l'unica operazione sensata è aggiungere dati alla fine.
Perché? Parquet memorizza i dati in formato colonnare, il che significa che se abbiamo una colonna `email`, tutte le righe (in un dato gruppo di righe e pagina - di più su questo dopo) sono scritte una dopo l'altra. Tentare di modificare una riga è quindi impossibile, dato che richiederebbe di spostare praticamente l'intero file.
Tuttavia, è possibile aggiungere un nuovo gruppo di righe alla fine del file. Questo viene fatto rimuovendo i metadati dalla fine del file, che temporaneamente vanno in memoria. Al loro posto, viene scritto un nuovo gruppo di righe (che deve anche essere aggiunto ai metadati), e poi i metadati vengono scritti di nuovo alla fine.
Per questo motivo, se vogliamo rimuovere qualcosa da un file Parquet, in pratica dobbiamo riscrivere l'intero file, escludendo i dati indesiderati.
Struttura Forte
Parquet è un formato basato su tipizzazione forte. Questo significa che la struttura dell'intero file è definita e memorizzata nel footer, quindi è necessario solo leggere il segmento appropriato per capire che dati abbiamo nel file e in quali regioni del file quei dati sono scritti.
Possiamo pensare a questo come a una mappa del file - una mappa che ci dirà esattamente dove nel file si trovano i dati che ci interessano.
Ecco più o meno come appare una struttura di file Parquet semplificata:
+-----------------+
| PAR1 |
+-----------------+
| Data |
| ............. |
| ............. |
+-----------------+
| File Metadata |
+-----------------+
| PAR1 |
+-----------------+
Nell'esempio sopra vediamo 3 elementi:
PAR1
- i "Parquet Magic Bytes" - 4 byte che aprono e chiudono i file ParquetData
- qui è dove sono scritte tutte le colonne (di più su questo dopo)Metadata
- metadati, cioè la mappa del file
Il primo passo per leggere correttamente un file Parquet è controllare se i primi 4 byte sono PAR1
.
Se così, dobbiamo saltare alla fine del file (seek) e leggere gli ultimi 4 byte.
Se la fine e l'inizio del file contengono PAR1
, possiamo procedere a leggere i metadati.
Per farlo, torniamo indietro di 8 byte dalla fine del file e leggiamo 4 byte che rappresentano la dimensione dei metadati.
In altre parole, leggiamo i byte da -8
a -4
Questi 4 byte sono un intero
che ci dice su quanti byte sono scritti i metadati. Avendo
questa informazione, possiamo leggere i metadati, che sono serializzati in binario usando Apache Thrift
Apache Thrift
Apache Thrift è uno strumento molto intelligente che permette la serializzazione binaria di interfacce/tipi in praticamente qualsiasi linguaggio di programmazione.
Qui possiamo vedere come appare la definizione dei metadati Parquet.
Questo formato assomiglia un po' a pseudocodice, che viene poi usato dall'applicazione appropriata per generare codice in un dato linguaggio di programmazione.
Qui possiamo vedere come appare il codice generato in PHP.
Una volta che abbiamo le strutture/interfacce/modelli generati, possiamo procedere alla lettura.
<?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))
)
)
);
Per questo avremo bisogno della libreria Thrift per il nostro linguaggio di programmazione scelto. Tutte le implementazioni sono disponibili nel repository apache/thrift.
Avendo accesso a $metadata
, possiamo iniziare ad analizzare il nostro file per capire la sua struttura.
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
}
Le informazioni chiave sul file sono memorizzate nella struttura FileMetaData
.
Le più importanti sono:
version
- versione del formato Parquetnum_rows
- numero di righe nel fileschema
- schema dei datirow_groups
- qui è dove sono memorizzati i nostri dati
Versioni del Formato
Al momento di scrivere questo articolo, il formato Parquet era già disponibile nella versione 2.12.0
.
I cambiamenti più cruciali tra le versioni 1.0 e 2.0 sono:
- Nuovi schemi di codifica: DELTA_BINARY_PACKED per i numeri, DELTA_BYTE_ARRAY per le stringhe, RLE_DICTIONARY che sostituisce PLAIN_DICTIONARY
- Struttura Data Page V2: Eliminato l'overhead dei metadati, abilitato il filtraggio a livello di pagina
Anche se la versione 2.0 introduce molti miglioramenti, i più grandi player usano ancora la versione 1 di default.
Conteggio Righe
Questa informazione potrebbe sembrare non intuitiva all'inizio nel contesto del formato colonnare.
Tuttavia, dobbiamo ricordare che il formato colonnare è solo un modo di memorizzare valori, non la struttura dei dati.
Nonostante i dati siano raggruppati per colonne e loro tipo, la lettura/scrittura avviene ancora nel modo classico - riga per riga.
La differenza è che non leggiamo una riga alla volta, ma un intero gruppo di righe, caricando colonna per colonna in memoria, poi ricostruendo le righe basandoci sugli indici appropriati.
Ricordando che per scrivere correttamente i dati in formato colonnare dobbiamo operare su gruppi logici, non su righe individuali. Possiamo relativamente facilmente gestire il bilanciamento tra uso della memoria e il numero di operazioni IO.
Leggere e scrivere dalla memoria è più veloce che leggere e scrivere dal disco (anche se non sempre).
Aumentando il numero di righe che saranno scritte in un gruppo, riduciamo il numero di gruppi, quindi il numero di operazioni IO.
Questo aumenta la velocità di scrittura/lettura mentre aumenta l'uso della memoria.
Questo funziona anche al contrario - riducendo il numero di righe in un gruppo, aumentiamo il numero di gruppi nel file, quindi aumentando il numero di operazioni IO.
Dimensione del gruppo, non conteggio righe - Parquet permette di definire non il numero di righe, ma la dimensione massima
di un gruppo di righe.
Tuttavia, ricorda che questi non sono valori assoluti (di più su questo dopo), quindi
alcuni gruppi possono essere più piccoli/grandi della dimensione consentita, dipendendo principalmente dall'implementazione della libreria Parquet.
Nella documentazione del formato Parquet, troveremo informazioni che la dimensione suggerita del gruppo è 512MB - 1GB
.
Tuttavia, vale la pena affrontare questo con un po' di buon senso, specialmente se non ci stiamo affidando a HDFS (Hadoop Distributed File System) per lettura/scrittura.
Il valore suggerito è impostato in modo che un gruppo di righe si adatti in un blocco HDFS, garantendo che la lettura
avvenga da esattamente un nodo.
Vale la pena ricordarlo, ma se non pianifichiamo di usare Parquet con un file system distribuito, gruppi di righe più piccoli ci permetteranno di risparmiare molta memoria.
Un esempio molto buono di quando i gruppi più piccoli sono più efficienti è quando vogliamo leggere solo una piccola fetta di righe da qualche parte nel mezzo del file (paginazione).
Assumendo che dobbiamo leggere solo 100 righe da un file che contiene 10 milioni di righe, impostare una dimensione di gruppo più piccola ci permetterà di risparmiare molto sulla memoria. Perché?
Se dividiamo 10 milioni in, diciamo, 10 gruppi, ogni gruppo contiene 1 milione di righe. Questo significa in pratica che dobbiamo leggere l'intero gruppo, poi estrarre solo le 100 righe che ci interessano.
Con una dimensione di gruppo più piccola che permette di dividere 10 milioni in 1000 gruppi, analizzando i metadati del file, saremo in grado di saltare più gruppi e caricare molte meno righe in memoria.
La decisione sulla dimensione del gruppo di righe dovrebbe essere ponderata, considerando sia le prestazioni di scrittura che di lettura per il file specifico. La configurazione appropriata si traduce direttamente nell'uso delle risorse, che alla fine si traduce in denaro.
Schema
Stiamo lentamente arrivando al cuore di Parquet - Row Groups
. Ma prima di analizzare la loro struttura, dobbiamo
tornare a un altro aspetto molto importante di Parquet: lo schema dei dati.
Iniziamo con i tipi di dati. Parquet consiste di tipi fisici e logici.
Tipi Fisici
I tipi fisici sono i tipi di dati di base utilizzati per memorizzare valori in un file Parquet. Sono tipi come:
- Boolean
- Byte Array
- Double
- Fixed Len Byte Array
- Float
- Int32
- Int64
- Int96 - (deprecato - usato solo da implementazioni più vecchie)
I tipi logici sono tipi utilizzati per rappresentare strutture dati più complesse. Puoi pensare a loro come estensioni dei tipi fisici.
Tipi Logici
- Bson
- Date
- Decimal
- Enum
- Integer
- Json
- List
- Map
- String
- Time
- Timestamp
- Uuid
La struttura attuale può sempre essere verificata alla fonte: apache/parquet-format
Oltre alla divisione in tipi logici e fisici, Parquet distingue anche colonne piatte e nidificate.
Le colonne piatte sono quelle che memorizzano un singolo valore, es. Int32
, Boolean
, Float
, ecc.
Le colonne nidificate sono quelle che memorizzano più di un valore, es. List
, Map
, ecc.
Ci sono in realtà 3 tipi di colonne nidificate:
- List
- Map
- Struct
Struct è un tipo speciale di colonna che permette di nidificare qualsiasi altro tipo, rendendo possibile creare praticamente qualsiasi struttura dati.
Usando i tipi sopra, possiamo modellare praticamente qualsiasi struttura dati e poi efficientemente memorizzarla e interrogarla.
Diamo un'occhiata alle definizioni Thrift di SchemaElement
e alcuni elementi correlati.
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 maggior parte dei valori dovrebbe essere abbastanza ovvia, ma diamo un'occhiata a FieldRepetitionType
.
Questo valore ci dice se una data colonna è richiesta, opzionale o ripetibile.
Se una colonna è richiesta, significa che il valore non può essere null.
Se una colonna è opzionale, il valore può essere null, e se è ripetibile, significa che può contenere valori multipli (es. una lista).
Ecco come potrebbe apparire uno schema di file ordini (in forma 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;
}
}
}
}
Tipi Nidificati
Per capire completamente la struttura dei gruppi di righe, dobbiamo prima capire come Parquet appiattisce i tipi nidificati.
Mentre strutture semplici come address
dall'esempio sopra possono essere praticamente ridotte a 4 colonne semplici:
address.street
- Stringaddress.city
- Stringaddress.zip
- Stringaddress.country
- String
Con Map
o List
, la situazione è un po' più complicata.
Per esempio, se volessimo appiattire Map<string,int32>
otterremmo qualcosa del genere:
map_column.key_value.key
- Stringmap_column.key_value.value
- Int32
Quindi per l'esempio sopra, il percorso piatto a sku
sarebbe così:
items.list.element.sku
, mentre la struttura completa appiattita sarebbe così:
┌─────────────────────────────┬──────────────────────── 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 │
└─────────────────────────────┴──────────────────────────┴──────────────┴────────────┴────────────────┴────────────────┘
Gruppi di Righe
+-----------------------------------+
| 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 |
+-----------------------------------+
Come già sappiamo, un file Parquet è diviso in gruppi di righe. Scrivere su un file funziona più o meno così:
- 1) crea un file e aggiungi 4 byte
PAR1
ad esso - 2) crea una struttura di metadati basata sullo schema e mantienila in memoria
- 3) appiattisci la riga passata (controllando se si adatta allo schema)
- 4) salva la riga appiattita in memoria in forma binaria
-
5) controlla se la dimensione del gruppo di righe che stiamo attualmente mantenendo in memoria si adatta alla dimensione massima consentita
- a) scrivi il gruppo di righe nel file
- b) aggiorna i metadati in memoria aggiungendo ad essi i metadati del gruppo che abbiamo appena scritto
- 6) torna al passo 2
- 7) Scrivi i metadati alla fine del file dopo aver scritto tutti i gruppi di righe
-
8) Chiudi il file con 4 byte
PAR1
Naturalmente, questa descrizione è molto semplificata. In realtà, è un po' più complessa, e implementazioni diverse possono differire nei dettagli.
Concentriamoci sulla struttura del gruppo di righe. Prima, diamo un'occhiata alle definizioni 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
}
Già a questo stadio, possiamo vedere quante informazioni su un gruppo di righe specifico sono memorizzate nei metadati.
Per ora, però, concentriamoci su tre campi:
file_offset
- quanti byte dall'inizio del file saltare per leggere il gruppo datototal_byte_size
- su quanti byte è scritto il gruppo di righecolumns
- informazioni dettagliate su ogni colonna scritta all'interno del gruppo dato
Importante: ogni gruppo di righe contiene sempre tutte le colonne definite nello schema.
Anche se in tutto il gruppo una colonna contiene solo valori null.
Chunk di Colonne
Andiamo più a fondo e diamo un'occhiata alla definizione 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;
}
Ricorda: Tutto quello che abbiamo guardato finora è ancora parte dei metadati.
Questo significa che otteniamo tutte queste informazioni su colonne, gruppi di righe e i dati stessi leggendo
solo la fine del file, indipendentemente dal fatto che il file sia 1MB o 1TB.
Qui arriviamo praticamente al posto che ci permette di leggere i dati dal file.
Ma prima che questo accada, dobbiamo imparare l'ultima struttura dati necessaria per la lettura.
Pagine di Dati
Pages
- un'altra divisione logica nella struttura del file Parquet.
Row Group -> Column Chunk -> Data Pages
RowGroup
- gruppo di righe (partizione)ColumnChunk
- ogni gruppo di righe contiene esattamente 1ColumnChunk
per ogni colonna nel gruppoData Page
- pagina, l'unità logica più piccola in Parquet che aggrega i dati
Leggere Parquet si riduce davvero ad analizzare la struttura dei metadati, localizzare l'indirizzo dell'inizio di un gruppo di righe specifico, poi una colonna specifica nel gruppo, e poi iterare attraverso e leggere i dati da ogni pagina.
Ma prima di iniziare a leggere le pagine, dobbiamo capire se abbiamo a che fare con una DataPage
, IndexPage
, o DictionaryPage
.
Per farlo, prima leggiamo il PageHeader
- l'intestazione della pagina, la cui definizione Thrift è così
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;
}
Per leggere l'intestazione, dobbiamo conoscere il suo indirizzo relativo all'inizio del file. Ecco come possiamo calcolarlo per un gruppo di righe selezionato e una colonna selezionata:
- Leggi
FileMetadata
- Trova il
RowGroup
appropriato e localizza ilColumnChunk
rilevante - Avendo
ColumnChunk
, otteniamo l'indirizzofile_offset
dell'inizio diColumnChunk
relativo all'inizio del file.
Importante: A questo stadio non dobbiamo ancora fisicamente caricare i byte in memoria.
È sufficiente creare uno stream
che ci permetta di leggere i dati direttamente dal file.
La prima cosa da leggere è l'intestazione, PageHeader
. Facendolo tramite Thrift, passando lo
stream e impostando l'indirizzo di inizio appropriato, otteniamo una struttura dati PageHeader
che ci dirà esattamente come leggere
la pagina stessa.
Ci sono 3 tipi di pagine:
DataPage
Una pagina che contiene la rappresentazione binaria dei dati da una colonna selezionata dalle righe che sono finite nel gruppo di righe selezionato.
Questo è il tipo di pagina più semplice e diretto. Contiene "solo" dati.
Quando leggiamo una colonna integer, quello che ci interessa è il numero di righe in un gruppo specifico (ogni riga è un valore in una DataPage
).
Quindi sapendo che in questo gruppo abbiamo, diciamo, 100 valori, sappiamo che dobbiamo leggere 400 byte (int32 è scritto su 4 byte).
Va bene, ma cosa succede se la colonna è opzionale? Significa che può contenere valori null.
Qui la situazione diventa un po' più complicata perché dobbiamo sapere quali righe contengono valori null.
Da dove viene questa conoscenza, chiederai?
Definition Levels
La situazione si complica un po'. All'inizio ho scritto che DataPage
contiene solo dati, e ora sto aggiungendo alcuni Definition Levels
.
In realtà, la struttura della pagina dati è più o meno così:
Parquet Data Page: int32
=======================================
[ Repetition Levels ]: 0, 0, 0, 0, 0
---------------------------------------
[ Definition Levels ]: 1, 0, 1, 1, 0
---------------------------------------
[ Values ]: 42, 73, 19
=======================================
Per ora, concentriamoci solo su Definition Levels
e Values
. È facile vedere la relazione tra loro.
Il numero di Definition Levels
e Repetition Levels
in ogni pagina è sempre uguale al numero di valori nella colonna.
Indipendentemente dal fatto che ci siano null o no. Definition Levels
ci dicono se una data riga contiene un valore o null.
Basandoci su questo, possiamo facilmente determinare il numero totale di Values
non vuoti, il che ci permetterà di leggerli.
Nell'esempio sopra abbiamo 5 righe, di cui 3 sono valori. Dato che int32
è scritto su 4 byte,
sappiamo che dobbiamo leggere un totale di 12 byte.
Sappiamo anche che quando convertiamo la colonna in righe, la prima riga conterrà il valore 42
, la seconda riga null
,
la terza riga 73
, la quarta riga 19
, e la quinta riga null
.
Importante: Repetition Levels
e Definition Levels
sono molto più complicati, però. Di più su questo dopo.
Questa è più o meno la struttura di DataPage
.
DictionaryPage
Dato che manteniamo i dati in DataPage
, qual è lo scopo di DictionaryPage
?
Beh, DictionaryPage
è una pagina che contiene un dizionario di valori.
Un dizionario usato per leggere i dati, specialmente per colonne contenenti valori ripetibili.
Funziona più o meno così: quando leggiamo un ColumnChunk
, iniziamo con la prima pagina. Se questa pagina è una DictionaryPage
,
sappiamo che abbiamo a che fare con un dizionario (lo sappiamo dall'inizio, dato che è registrato nei metadati della colonna).
Se, per esempio, stiamo leggendo una colonna con alta ripetibilità, come una colonna con nomi di paesi, invece di scrivere il nome completo del paese per ogni riga in DataPage
,
scriviamo solo la sua posizione nel dizionario.
Per una tale colonna, la prima pagina nella colonna sarebbe DictionaryPage
, e quelle successive sarebbero DataPage
.
La differenza è che in DataPage
, invece di valori completi, ci sarebbero posizioni nel dizionario, che manterremmo in memoria per ricostruire le righe.
Importante: Ogni ColumnChunk
può contenere solo una DictionaryPage
.
Questo può fornire enormi risparmi. Invece di, diciamo, scrivere la parola Polonia
in binario 10.000 volte, che sono 60k byte,
scriviamo solo la posizione nell'indice (4 byte), che inoltre viene impacchettata usando l'algoritmo Run Length Encoding / Bit-Packing Hybrid.
Questo algoritmo, anche basato sulla ripetibilità di valori consecutivi, ridurrà il numero totale di byte necessari.
IndexPage
L'ultimo tipo di pagina è IndexPage
.
Questa pagina non contiene dati, quindi non è necessaria per la lettura o la scrittura.
Ogni ColumnChunk
può contenere solo una IndexPage
ed è sempre situata alla fine, dopo DictionaryPage
e tutte le DataPage
.
Lo scopo di questa pagina è memorizzare statistiche riguardo a ColumnChunk
, come valori Min/Max
, numero di null
, o metodo di ordinamento per ogni pagina in un ColumnChunk
specifico.
Questo permette rapido filtraggio e trovare solo pagine specifiche all'interno di un dato ColumnChunk
, accelerando significativamente la ricerca nel file quando siamo interessati a informazioni specifiche.
Nota: Ogni ColumnChunk
nei suoi metadati contiene statistiche simili a IndexPage
, ma non per ogni pagina ma per l'intero ColumnChunk
.
Grazie a questo, possiamo prima saltare intere colonne che non ci interessano e poi anche pagine specifiche, riducendo al minimo assoluto la quantità di dati che dobbiamo leggere.
Considerando che queste informazioni sono nei metadati del file, anche i file Parquet più grandi possono essere letti e filtrati in modo ultra-veloce anche se sono disponibili solo via rete.
È sufficiente che possiamo leggere i metadati, poi basandoci su di essi localizzare un gruppo di righe specifico, poi la colonna selezionata, e infine pagine specifiche.
Questo ci dà una localizzazione molto precisa dei nostri dati, che possiamo leggere usando l'Http Range Header
.
Questa è esattamente una delle ragioni per cui Parquet è così potente. Non stiamo più parlando di scaricare brutalmente e iterare attraverso un file di gigabyte. Parquet ci permette con precisione chirurgica di scaricare e leggere solo quelle aree del file che ci interessano davvero.
Dremel
Quando ho discusso la struttura di DataPage
, ho menzionato Definition Levels
e Repetition Levels
.
L'esempio che ho coperto era molto semplice perché riguardava una colonna semplice (int32), quindi Repetition Levels
non si applicano affatto.
La situazione cambia drasticamente quando abbiamo a che fare con una colonna nidificata, come una struttura, lista, o mappa.
Diamo un'occhiata a un esempio.
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
Tornando alla parte precedente di questo articolo, specificamente ai tipi nidificati.
Sappiamo che i nostri dati dopo l'appiattimento saranno così:
items.list.element.sku
-"abc","def"
items.list.element.quantity
-1,2
items.list.element.price
-100,200
Abbiamo 3 colonne qui, ognuna sarà in un Column Chunk
separato e ognuna conterrà
una o più pagine.
Quindi come, basandosi su questi due valori (Repetition / Definition Levels
), le librerie che leggono i file sanno quanto profondamente nella struttura si trovano i valori e a quale elemento appartengono?
Cosa succede se la nostra struttura fosse così:
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":null, "quantity": 10, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
(nel secondo elemento, sku ha un valore null).
Cosa succede se la struttura è molto più nidificata - come sappiamo quale valore va a quale livello di nidificazione?
La risposta a questa e molte altre domande può essere trovata in un documento pubblicato da Google: Dremel: Interactive Analysis of Web-Scale Datasets che descrive come Google memorizza e cerca le strutture dati nidificate.
Lo strumento utilizzato da Google si chiama Dremel ed è un sistema distribuito per la ricerca di grandi dataset.
È basato su 2 algoritmi, Shredding
e Assembling
, che sono descritti molto brevemente nel documento sopra.
Nota: Descrivere il funzionamento esatto di questi algoritmi va oltre lo scopo di questo post già lungo.
Se c'è interesse nell'argomento, però, cercherò di coprire questo filone nei prossimi post.
Questi algoritmi sono basati su queste 3 definizioni:
- Repetition Levels
- Definition Levels
- Values
Come abbiamo già menzionato, Definition Level
determina se una data riga contiene un valore o no. Repetition Level
, che per le colonne piatte è sempre 0.
Per le strutture, determinerà se un valore (o null) dovrebbe essere ripetuto, e a quale livello di nidificazione.
Nota: La conoscenza di come funzionano esattamente gli algoritmi di Dremel non è necessaria per l'uso ottimale di Parquet.
Per questo motivo, non elaborerò su questo argomento, ma se c'è interesse, cercherò di coprire questo filone nei prossimi post.
Sotto mostrerò solo più o meno come apparirebbero i dati appiattiti.
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" }
}
}
Quindi in realtà salviamo 0, 1, 0, 1, "abc", "def"
e non solo "abc", "def"
.
Sono esattamente questi numeri aggiuntivi che ci dicono come ricostruire qualsiasi struttura nidificata.
Curiosamente, anche i repetition levels e i definition levels sono appropriatamente impacchettati per l'ottimizzazione usando l'algoritmo Run Length Encoding / Bit-Packing Hybrid.
Non è tutto, perché non sono solo i livelli ad essere impacchettati, ma anche i valori stessi.
A seconda del tipo di colonna, i valori possono essere impacchettati in modi diversi. Una lista di tutti gli algoritmi di impacchettamento supportati da Parquet (almeno in teoria) può essere trovata
nella documentazione ufficiale.
E le informazioni su quale algoritmo è stato usato per impacchettare i dati prima della scrittura possono essere trovate nei metadati, sotto questo percorso: RowGroups[x].ColumnChunk[y].PageHeader[z].data_page_header.encoding
Ma questa non è l'ultima parola di Parquet quando si tratta di ottimizzazione!
Compressione
Dopo aver impacchettato e scritto i nostri dati in forma binaria per una pagina specifica, ogni pagina è additionally compressa.
A seconda dell'implementazione, Parquet permette l'uso di diversi algoritmi di compressione:
- UNCOMPRESSED
- SNAPPY
- GZIP
- LZO
- BROTLI
- LZ4
- ZSTD
- LZ4_RAW
Un'opzione molto popolare è Snappy, che offre un compromesso molto buono tra velocità e livello di compressione.
Strumenti come Apache Spark lo usano di default.
Crittografia
Una delle ultime caratteristiche interessanti di cui vorrei parlare è la crittografia!
Sì, Parquet permette di crittografare i dati - crittografia a più livelli.
- Metadati - i metadati crittografati rendono effettivamente difficile leggere i contenuti del file, ma non impossibile
- Dati - i dati crittografati rendono la lettura praticamente impossibile
- Colonne - particolarmente utile se solo alcune colonne contengono dati sensibili.
- Pagine
Nota: La crittografia è una di quelle funzionalità che non ho ancora coperto nell'implementazione PHP
Per questo motivo non elaborerò su questo argomento. Una volta che avrò la possibilità di implementare questa funzionalità, cercherò di integrare questo articolo.
La crittografia in Parquet è basata su Parquet Modular Encryption e usa AES per la crittografia dei dati.
La crittografia, specialmente di colonne selezionate, porta Parquet a un livello superiore di archiviazione dati.
Questo ci permette relativamente facilmente, con overhead minimo,
di proteggere ulteriormente i dati che memorizziamo nei file Parquet.
Immaginiamo che Parquet sia usato per memorizzare dati dei clienti, dove le colonne email
e phone
contengono dati sensibili.
In questa situazione, chiede proprio che quelle due colonne siano protette ulteriormente. Anche se qualcuno riesce ad ottenere accesso fisico al file, senza la chiave non
saranno in grado di leggere i dati.
Riassunto
Questo è esattamente il segreto di Parquet e il modo all'efficienza. Invece di memorizzare dati arbitrari in forma testuale, Parquet fa diversi passi in più.
Prima, impone uno schema dei dati basato su tipi semplici ma incredibilmente flessibili, ognuno dei quali può essere
rappresentato in forma binaria.
Poi la forma binaria è appropriatamente impacchettata per evitare ripetizioni di byte non necessarie, che viene infine
additionally compressa usando algoritmi molto efficienti.
La ciliegina sulla torta sono i metadati avanzati e dettagliati, disponibili a più livelli, permettendo di filtrare
partizioni non necessarie o persino interi file senza leggere i loro contenuti.
Inoltre, grazie alla divisione logica appropriata, su cui abbiamo pieno controllo (dimensione di gruppi e pagine), possiamo decidere cosa è più importante per noi - velocità o risparmio di memoria. Ricerca o lettura di dati, o magari sicurezza, per cui useremo crittografia aggiuntiva?
Parquet è davvero uno strumento potente che, nelle mani giuste, permette l'archiviazione e la ricerca efficiente di
enormi quantità di dati.
Se questo post ti ha ispirato a sperimentare con questo formato di dati fantastico, fammi sapere nei commenti!
Aiuto
Se hai bisogno di aiuto nella costruzione di un data warehouse centrale, sarei felice di aiutarti.
Contattami, e insieme creeremo una soluzione perfettamente su misura per le tue esigenze.
Ti incoraggio anche a visitare il server Discord - Flow PHP, dove possiamo parlare direttamente.
