
Parquet - Wprowadzenie
Parquet, binarny, kolumnowy format plików stworzony w celu wydajnego przechowywania oraz przeszukiwania danych.
Artykułów na temat parquet jest w sieci całe mnóstwo, więc dlaczego kolejny?
Jest to moje spojrzenie na ten fantastyczny format, będący w zasadzie wynikiem moich doświadczeń z pracy nad
napisaniem implementacji parqueta w czystym PHP.
Dla tych, którzy trafili tu przypadkiem, wspomnę tylko, że jestem autorem pierwszego frameworka do przetwarzania
danych w PHP, o nazwie Flow PHP.
Jak przystało na Data Frame, Flow musi umieć czytać i zapisywać dane w różnych formatach, w tym Parquet
Ponieważ jednak jedyna implementacja, którą znalazłem, była w zasadzie bezpośrednim portem z C#, który w dodatku nie do końca radzi sobie z głęboko zagnieżdżonymi strukturami, oraz posiada sporo brakujących funkcji, postanowiłem w ramach nauki, napisać własną implementację od zera, co okazało się niezwykle cennym doświadczeniem ale i świetną zabawą.
Dlaczego Parquet
- Format Binarny - nawet o 10x mniejszy rozmiar plików
- Metadata - łatwiejszy dostęp do wybranych danych
- Schema - gwarancja poprawnej struktury
- Kompresja - dodatkowa redukcja rozmiaru
- Szyfrowanie - na poziomie, pliku, metadanych, kolumn czy stron
Format Binarny
Dzięki temu, że format ten jest zorientowany na kolumny, nie wiersze, pozwala na bardzo wydajną kompresję danych, co przekłada się na znacznie mniejszy rozmiar pliku. Bez większego wysiłku parquet potrafi skompresować dane nawet o 10 razy, w porównaniu z tradycyjnymi formatami jak np. CSV czy XML.
Jeżeli więc te same dane, zapisane w formacie CSV zajmują 1Gb, to w formacie parquet mogą zająć zaledwie 100Mb.
Na potrzeby tego wpisu wygenerowałem 2 pliki, jeden w formacie CSV, drugi w formacie Parquet.
Struktura tych plików jest bardzo prosta, zawiera 10 kolumn i 10mln wierszy, które wyglądają mniej więcej tak:
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}]"
Efekt kompresji jest naprawdę imponujący:
4.1G Sep 20 18:32 orders.csv
437M Sep 20 18:47 orders.parquet
Przekłada się to nie tylko na koszty przechowywania, ale i przetwarzania danych.
Szczególnie kiedy nasze dane żyją w chmurze, czy to na Azure Bucket, czy AWS S3. Jednym z większych czynników wpływających
na rachunek nie jest wcale rozmiar danych, a to ile transferu zużywamy, aby te dane odczytać / zapisać.
Redukując rozmiar pliku, redukujemy nie tylko koszt jego przechowywania, ale również przetwarzania. Istotne jest jednak żeby zrozumieć, że przetwarzanie to tak naprawdę jakakolwiek forma dostępu, czyli zapis/odczyt.
Poprzez wybór odpowiedniego formatu pliku, oszczędności mogą być naprawdę spore, szczególnie kiedy mówimy o większej ilości danych.
Co to w ogóle znaczy, że Parquet jest formatem binarnym?
Oznacza to mniej więcej tyle, że dane przechowywane są w postaci binarnej, czyli takiej, której nie da się bezpośrednio odczytać za pomocą popularnych edytorów tekstowych.
No ale przecież wszystko finalnie przechowywane jest w postaci binarnej, nie?
Tak, generalnie pliki tekstowe to też pliki binarne, różnica polega na tym, że w plikach tekstowych struktura pliku jest zawsze taka sama i każda informacja zapisywana jest w ten sam sposób.
Przykładowo jeżeli chcielibyśmy zapisać "12345" w pliku tekstowym, wersja binarna będzie wyglądać następująco:
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)
Ten sam ciąg znaków zapisany w formacie binarnym jako int32 (integer w postaci 32 bitowej) będzie wyglądać tak:
INTEGER: 12345
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Integer: 0 0 48 57
Binary: 00000000 00000000 00110000 00111001
└─byte─┘ └─byte─┘ └─byte─┘ └─byte─┘
Total: 4 bytes for a 32-bit integer
Zauważmy, że w przypadku zapisu integera w formie binarnej, nie da się go po prostu odczytać od lewej do prawej (lub odwrotnie). Tutaj musimy już wiedzieć, jak interpretować te bity, aby zrozumieć co one oznaczają. W przypadku plików tekstowych nie mamy tego problemu, gdyż wiemy, że każdy znak jest zapisywany w postaci 8-bitowej.
Mniej więcej dlatego dowolny edytor tekstu jest w stanie otworzyć każdy plik tekstowy i wyświetlić nam coś, co będzie miało mniej lub więcej sensu.
Jeżeli jednak spróbujemy otworzyć plik typu parquet w edytorze tekstowym, dostaniemy ciąg znaków wyglądający bardzo losowo i nie mający większego sensu.
Kolumnowy / Wierszowy
Najlepiej różnicę między tymi formatami wytłumaczyć za pomocą wizualizacji.
W klasycznym modelu wierszowym każdy wiersz zawiera wszystkie kolumny, tak jak np. w formacie CSV
+------+------+------+
| Col1 | Col2 | Col3 |
+------+------+------+
| A1 | B1 | C1 |
| A2 | B2 | C2 |
| A3 | B3 | C3 |
+------+------+------+
Format kolumnowy jest o tyle ciekawy, że zamiast przechowywać dane wiersz po wierszu, przechowuje je kolumna po kolumnie.
+------+------+------+------+
| Col1 | A1 | A2 | A3 |
+------+------+------+------+
| Col2 | B1 | B2 | B3 |
+------+------+------+------+
| Col3 | C1 | C2 | C3 |
+------+------+------+------+
Przechowywanie danych w formacie kolumnowym przynosi bardzo wiele korzyści, takich jak:
- Dużo lepsza możliwość kompresji danych
- Możliwość odczytu tylko wybranych kolumn
- Możliwość szyfrowania wybranych lub wszystkich kolumn
W przypadku formatu wierszowego, aby odczytać tylko jedną kolumnę, musimy i tak przejrzeć cały plik.
W przypadku formatu kolumnowego możemy odczytać tylko te kolumny, które nas interesują.
Jest to szczególnie przydatne w przypadku bardzo dużych zbiorów danych, gdzie często potrzebujemy tylko części informacji.
Niezmienny
Ze względu na sposób w jaki dane są przechowywane w formacie kolumnowym, pliki parquet są niezmienne.
Nie oznacza to jednak, że nie można ich modyfikować. Można, ale jedyna sensowna operacja to dopisywanie danych na końcu.
Dlaczego? Parquet przechowuje dane w formacie kolumnowym, oznacza to że jeżeli mamy kolumnę email
to wszystkie wiersze (w danej grupie wierszy i stronie - o tym dalej) będą zapisane ciągniem jeden za drugim.
Próba modyfikacji jednego wiersza jest więc niemożliwa, ponieważ wymagałaby przesunięcia praktycznie całego pliku.
Możliwe jest natomiast dodanie nowej grupy wierszy na końcu pliku. Robi się to tak, że z końcówki pliku usuwa się metadane, które tymczasowo trafiają do pamięci. W ich miejsce zapisuje się nową grupę wierszy (którą też należy dodać do metadanych), a następnie na samym końcu ponownie zapisuje się metadane.
Z tego względu, jeżeli chcemy usunąc coś z pliku parqueta, w praktyce musimy przepisać cały plik, z pominięciem niechcianych danych.
Silna Struktura
Parquet jest formatem opartym o silne typowanie. Oznacza to, że struktura całego pliku jest zdefiniowana i przechowywana w stopce, dzięki czemu wystarczy odczytać tylko odpowiedni segment aby zrozumieć jakie dane mamy w pliku, oraz w których regionach pliku te dane są zapisane.
Możemy o tym myśleć jak o mapie pliku, mapie, która powie nam gdzie w pliku dokładnie znajdują się dane, które nas interesują.
Oto jak mniej więcej wygląda uproszczona struktura pliku w formacie parquet:
+-----------------+
| PAR1 |
+-----------------+
| Data |
| ............. |
| ............. |
+-----------------+
| File Metadata |
+-----------------+
| PAR1 |
+-----------------+
Na powyższym przykładzie widzimy 3 elementy:
PAR1
- czyli "Parquet Magic Bytes" - 4 bajty otwierające i zamykające pliki w formacie parquetData
- tutaj zapisane są wszystkie kolumny (o tym dalej)Metadata
- metadane, czyli mapa pliku
Pierwszym krokiem do poprawnego odczytania pliku parqueta jest sprawdzenie czy pierwsze 4 bajty to PAR1
.
Jeżeli tak, musimy przeskoczyć do końca pliku (seek) i odczytać ostatnie 4 bajty.
Jeżeli koniec i początek pliku zawierają PAR1
możemy przystąpić do odczytu metadanych.
W tym celu cofamy się o 8 bajtów od końca pliku i odczytujemy 4 bajty reprezentujące rozmiar metadanych.
Innymi słowy, czytamy bajty -8
do -4
Te 4 bajty to integer
mówiący o tym, na ilu bajtach zapisane są metadane. Mając
tę informację możemy odczytać metadane, które są zserializowane binarnie za pomocą Apache Thrift
Apache Thrift
Apache Thrift to bardzo sprytne narzędzie pozwalające na binarną serializację interfejsów / typów w praktycznie każdym języku programowania.
Tutaj możemy zobaczyć jak wygląda definicja metadanych w formacie parquet.
Format ten przypomina trochę pseudokod, który następnie za pomocą odpowiedniej aplikacji jest użyty do wygenerowania kodu w danym języku programowania.
Tutaj możemy zobaczyć jak wygląda wygenerowany kod w PHP.
Kiedy mamy już wygenerowane struktury / interfesy / modele możemy przystąpić do odczytu.
<?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))
)
)
);
W tym celu będzie nam potrzebna biblioteka Thrift dla wybranego języka programowania. Wszystkie implementacje dostępne są w repozytorium apache/thrift.
Mając dostęp do $metadata
możemy zacząć analizować nasz plik, aby zrozumieć jego strukturę.
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
}
Kluczowe informacje na temat pliku przechowywane są w strukturze FileMetaData
.
Najważniejsze z nich to:
version
- wersja formatu Parquetnum_rows
- liczba wierszy w plikuschema
- schemat danychrow_groups
- tutaj przechowywane są nasze dane
Wersje Formatu
W czasie pisania tego artykułu format parquet był już dostępny w wersji 2.12.0
.
Najbardziej kluczowe zmiany pomiędzy wersjami 1.0 a 2.0 to:
- Nowe schematy kodowania: DELTA_BINARY_PACKED dla liczb, DELTA_BYTE_ARRAY dla stringów, RLE_DICTIONARY zastępujący PLAIN_DICTIONARY
- Struktura Data Page V2: Wyeliminowano narzut metadanych, umożliwiono filtrowanie na poziomie stron
Pomimo że wersja 2.0 wprowadza wiele ulepszeń, to jednak najwięksi gracze nadal domyślnie używają wersji 1.
Liczba Wierszy
Ta informacja może na początku wydawać się mało intuicyjna w kontekście formatu kolumnowego.
Musimy jednak pamiętać, że format kolumnowy to tylko sposób przechowywania wartości a nie struktury danych.
Pomimo tego, że dane pogrupowane są na podstawie kolumn i swojego typu, odczyt/zapis nadal odbywa się w klasyczny sposób, czyli wiersz po wierszu.
Różnica polega na tym, że nie odczytujemy jednego wiersza na raz, a całą grupę wierszy, wczytując do pamięci kolumnę po kolumnie, a następnie odbudowując wiersze na podstawie odpowiednich indeksów.
Pamiętając o tym, że aby odpowiednio zapisywać dane w formacie kolumnowym musimy operować na logicznych grupach, a nie na pojedynczych wierszach. Możemy w stosunkowo łatwy sposób zarządzać stosunkiem pamięci do ilości operacji IO.
Zapis i odczyt z pamięci jest szybszy niż zapis i odczyt z dysku (chociaż nie zawsze).
Zwiększając ilość wierszy, które będą zapisane w jednej grupie, redukujemy ilość grup, czyli ilość operacji IO.
Tym samym zwiększamy prędkość zapisu/odczytu, jednoczeście zwiększając zużycie pamięci.
Działa to również w drugą stronę, redukując ilość wierszy w grupie, zwiększamy ilość grup w pliku, tym samym zwiększając ilość operacji IO.
Rozmiar grupy, nie ilość wierszy - parquet pozwala definiować nie ilość wierszy, a maksymalny
rozmiar grupy wierszy.
Jednak należy pamiętać, że nie są to wartości bezwzględne (o tym nieco później), więc
niektóre grupy mogą być mniejsze/większe niż dopuszczalny rozmiar i zależy to głównie od implementacji biblioteki
do parqueta.
W dokumentacji formatu parquet znajdziemy informację, że sugerowany rozmiar grupy to 512Mb - 1Gb
.
Warto jednak podejść do tego z odrobiną rozsądku, szczególnie jeżeli do odczytu/zapisu nie polegamy na HDFS (Hadoop Distributed File System).
Sugerowana wartość jest ustalona w taki sposób, aby jedna grupa wierszy mieściła się w jednym bloku HDFS, gwarantując, że odczyt
odbędzie się z dokładnie jednego węzła.
Warto o tym pamiętać, jeżeli jednak nie planujemy używać parqueta z rozproszonym systemem plików, mniejsze grupy wierszy pozwolą zaoszczędzić sporo pamięci.
Bardzo dobrym przykładem, kiedy mniejsze grupy są wydajniejsze, jest przypadek, w którym chcielibyśmy odczytać tylko niewielki wycinek wierszy gdzieś z środka pliku (stronicowanie).
Zakładając, że musimy odczytać tylko 100 wierszy z pliku, który zawiera 10 milionów wierszy, ustawienie mniejszego rozmiaru grupy pozwoli zaoszczędzić sporo na pamięci. Dlaczego?
Jeżeli podzielimy 10 mln na powiedzmy 10 grup, każda grupa zawiera 1 mln wierszy. Oznacza to, że w praktyce musimy odczytać całą grupę, a następnie wyciągnąć tylko 100 wierszy, które nas interesują.
W przypadku ustalenia mniejszego rozmiaru grupy, który pozwoli podzielić 10 mln na 1000 grup, analizując metadane pliku, będziemy mogli przeskoczyć większą ilość grup i wczytać do pamięci dużo mniejszą ilość wierszy.
Decyzja o rozmiarze grupy wierszy powinna być przemyślana zarówno pod kątem wydajności zapisu jak i odczytu konkretnego pliku. Odpowiednia konfiguracja przekłada się bezpośrednio na zużycie zasobów co finalnie przekłada się na pieniądze.
Schema
Powoli dochodzimy do sedna parqueta, czyli Row Groups
. Zanim jednak przeanalizujemy ich strukturę, musimy
cofnąć się do kolejnego bardzo ważnego aspektu parquet'a, schematu danych.
Zacznijmy od typów danych. Parquet składa się z typów fizycznych i logicznych.
Physical Types
Typy fizyczne to podstawowe typy danych, które są używane do przechowywania wartości w pliku parquet. Są to typy takie jak:
- Boolean
- Byte Array
- Double
- Fixed Len Byte Array
- Float
- Int32
- Int64
- Int96 - (deprecated - używany jedynie przez starsze implementacje)
Typy logiczne to typy, które są używane do reprezentowania bardziej złożonych struktur danych. Można o nich myśleć jak o rozszerzeniu typów fizycznych.
Logical Types
- Bson
- Date
- Decimal
- Enum
- Integer
- Json
- List
- Map
- String
- Time
- Timestamp
- Uuid
Aktualną strukturę zawsze można sprawdzić u źródła, apache/parquet-format
Poza podziałem na typy logiczne oraz fizyczne parquet rozróżnia też kolumny płaskie, oraz zagnieżdżone.
Kolumny płaskie to takie, które przechowują pojedynczą wartość, np. Int32
, Boolean
, Float
itp.
Kolumny zagnieżdżone to takie, które przechowują więcej niż jedną wartość, np. List
, Map
itp.
W zasadzie istnieją 3 rodzaje kolumn zagnieżdżonych:
- List
- Map
- Struct
Struct, to specjalny typ kolumny, który pozwala na zagnieżdżenie dowolnych innych typów, pozwalając stworzyć praktycznie dowolną strukturę danych.
Za pomocą powyższych typów jesteśmy w stanie zamodelować praktycznie dowolną strukturę danych, a następnie efektywnie ją przechowywać i przeszukiwać.
Popatrzmy więc na definicje Thrift SchemaElement
oraz kilku powiązanych elementów.
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
}
Większość wartości powinna być w miarę oczywista, popatrzmy jednak na FieldRepetitionType
.
Ta wartość mówi nam o tym, czy dana kolumna jest wymagana, opcjonalna czy powtarzalna.
Jeżeli kolumna jest wymagana, oznacza to, że wartość nie może być nullem.
Jeżeli kolumna jest opcjonalna wartość może być nullem, a jeżeli jest powtarzalna, oznacza to, że może zawierać wiele wartości (np. listę).
Oto jak może wyglądać schemat pliku z zamówieniami (w postaci 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;
}
}
}
}
Typy Zagnieżdżone
Aby w pełni zrozumieć strukturę grup wierszy musimy najpierw zrozumieć w jaki sposób parquet spłaszcza typy zagnieżdżone.
O ile proste struktury jak address
z powyższego przykładu można sprowadzić w zasadzie do 4 kolumn prostych:
address.street
- Stringaddress.city
- Stringaddress.zip
- Stringaddress.country
- String
Tak w przypadku Map
czy List
sytuacja jest trochę bardziej skomplikowana.
Przykładowo, jeżeli chcielibyśmy spłaszczyć Map<string,int32>
otrzymamy coś takiego:
map_column.key_value.key
- Stringmap_column.key_value.value
- Int32
Dla powyższego przykładu płaska ścieżka do sku
będzie wyglądać następująco:
items.list.element.sku
, natomiast spłaszczona kompletna struktura będzie wyglądać tak:
┌─────────────────────────────┬──────────────────────── 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 |
+-----------------------------------+
Zgodnie z tym co już wiemy, plik parquet podzielony jest na grupy wierszy, zapis do pliku wygląda w uproszczeniu w taki sposób:
- 1) utwórz plik i dodaj do niego 4 bajty
PAR1
- 2) stwórz strukturę metadanych na podstawie schematu i trzymaj ją w pamięci
- 3) spłaszcz przekazany wiersz (sprawdzając czy pasuje do schematu)
- 4) zapisz spłąszczony wiersz w pamięci w postaci binarnej
-
5) sprawdź czy rozmiar grupy wierszy którą aktualnie trzymamy w pamięci mieści sie w maksymalnym dopuszczalnym rozmiarze
- a) zapisz grupę wierszy do pliku
- b) zaktualizuj metadane w pamięci dodając do nich metadane grupy, którą właśnie zapisaliśmy
- 6) wróć do kroku 2
- 7) Zapisz metadane na końcu pliku po zapisaniu wszystkich grup wierszy
-
8) Zamknij plik 4 bajtami
PAR1
Oczywiście ten opis jest bardzo uproszczony, w rzeczywistości jest on trochę bardziej złożony, ponadto różne implementacje mogą się różnić w szczegółach.
Skupmy się na strukturze grupy wierszy, popatrzmy najpierw na definicje 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
}
Już na tym etapie widać, jak wiele informacji na temat konkretnej grupy wierszy jest przechowywanych w metadanych.
Na razie jednak skupmy się na trzech polach:
file_offset
- czyli ile bajtów od początku pliku należy przeskoczyć aby odczytać daną grupętotal_byte_size
- na ilu bajtach zapisana jest grupa wierszycolumns
- szczegółowe informacje o każdej kolumnie zapisanej w ramach danej grupy
Ważne: każda grupa wierszy zawiera zawsze wszystkie kolumny zdefiniowane w schemacie.
Nawet jeżeli na przestrzeni całej grupy kolumna zawiera tylko wartości null.
Column Chunks
Wejdźmy głębiej i przyglądnijmy się definicji 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;
}
Pamiętaj: Wszystko na co do tej pory patrzyliśmy to dalej część metadanych.
Oznacza to, że te wszystkie informacje na temat kolumn, grup wierszy czy samych danych otrzymamy odczytując
tylko i wyłącznie końcówkę pliku, niezależnie czy plik ma 1Mb czy 1Tb.
Tutaj dochodzimy w zasadzie do miejsca pozwalającego nam na odczytanie danych z pliku.
Zanim jednak to nastąpi musimy poznać ostatnią strukturę danych niezbędną do odczytu.
Data Pages
Pages
, czyli kolejny logiczny podział w strukturz pliku parqueta.
Row Group -> Column Chunk -> Data Pages
RowGroup
- grupa wierszy (partycja)ColumnChunk
- każda grupa wierszy zawiera dokładnie 1ColumnChunk
dla każdej kolumny w grupieData Page
- strona, najmniejsza jednostka logiczna w parquecie agregująca dane
Tak naprawdę odczyt parqueta sprowadza się do przeanalizowania struktury metadanych, zlokalizowania adresu początku konkretnej grupy wierszy, następnie konkretnej kolumny w grupie, a następnie przeiterowania i odczytania danych z każdej strony.
Zanim jednak zaczniemy czytać strony, musimy zrozumieć czy mamy do czynienia z DataPage
, IndexPage
lub DictionaryPage
.
W tym celu najpierw odczytujemy PageHeader
czyli nagłówek strony, którego definicja Thrift wygląda następująco
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;
}
Aby odczytać nagłówek musimy znać jego adres względem początku pliku, oto jak możemy go obliczyć dla wybranej grupy wierszy i wybranej kolumny:
- Odczytujemy
FileMetadata
- Znajdujemy odpowiednia
RowGroup
i odszukujemy istotny dla nasColumnChunk
- Mając
ColumnChunk
otrzymamy adresfile_offset
początkuColumnChunk
względem początku pliku.
Ważne: Na tym etapie nie musimy jeszcze fizycznie wczytywać do pamięci bajtów.
Wystarczy, że utworzymy stream
pozwalający na odczytanie danych bezpośrednio z pliku.
Pierwszą rzeczą jaką należy odczytać jest nagłówek, PageHeader
, robiąc to za pomocą Thrifta, przekazując
stream oraz ustawiając odpowiednio adres początku otrzymamy strukturę danych PageHeader
, która powie nam dokładnie jak należy odczytać
samą stronę.
Istnieją 3 typy stron:
DataPage
Strona zawierająca binarną reprezentację danych wybranej kolumny z wierszy, które trafiły do wybranej grupy wierszy.
Jest to najprostszy i najbardziej bezpośredni typ strony. Zawiera "tylko" dane.
Czytając kolumnę typu integer, to co nas interesuje to tak naprawdę liczba wierszy w konkretnej grupie (każdy wiersz to jedna wartość w DataPage
).
Dlatego wiedząc, że w tej grupie mamy powiedzmy 100 wartości, wiemy że musimy odczytać 400 bajtów (int32 zapisany jest na 4 bajtach).
No dobra, ale co w przypadku kiedy kolumna jest opcjonalna? To znaczy, że może zawierać wartości null.
Tutaj sytuacja robi się trochę bardziej skomplikowana ponieważ musimy wiedzieć, które wiersze zawierają wartość null.
Skąd ta wiedza zapytacie?
Definition Levels
Sytuacja się trochę komplikuje, na początku pisałem, że DataPage
zawiera tylko dane, a teraz dokładam jakieś Definition Levels
.
W rzeczywistości struktura data page wygląda mniej więcej tak:
Parquet Data Page: int32
=======================================
[ Repetition Levels ]: 0, 0, 0, 0, 0
---------------------------------------
[ Definition Levels ]: 1, 0, 1, 1, 0
---------------------------------------
[ Values ]: 42, 73, 19
=======================================
Na ten moment, skupmy się tylko na Definition Levels
i Values
. Bardzo łatwo zauważyć związek między nimi.
Ilość Definition Level
i Repetition Levels
w każdej stronie jest zawsze równa ilości wartości w kolumnie.
Bez względu na to czy są tam nulle czy nie. Definition Levels
mówią nam czy dany wiersz zawiera wartość czy null.
Na tej podstawie, możemy łatwo określić całkowitą ilość nie pustych Values
co pozwoli nam na ich odczyt.
W powyższym przykładzie mamy 5 wierszy, z czego 3 stanowią wartości, ponieważ int32
zapisujemy na 4 bajtach,
wiemy już że musimy odczytać w sumie 12 bajatów.
Wiemy też, że przekształcając kolumnę na wiersze, wiersz pierwszy będzie zawierał wartość 42
, wiersz drugi null
,
wiersz trzeci 73
, wiersz czwarty 19
i wiersz piąty null
.
Ważne: Repetition Levels
i Definition Levels
są jednak o wiele bardziej skomplikowane, nieco więcej później.
Tak mniej więcej prezentuje się struktura DataPage
.
DictionaryPage
Skoro dane trzymamy w DataPage
, jaki cel ma DictionaryPage
?
Otóż DictionaryPage
to strona zawierająca słownik wartości.
Słownik, używany do odczytywania danych, szczególnie w przypadku kolumn zawierających powtarzalne wartości.
Działa to mniej więcej tak, że odczytując ColumChunk
, zaczynamy od pierwszej strony, jeżeli ta strona to DictionaryPage
,
wiemy że mamy do czynienia z słownikiem (w zasadzie wiemy to od początku, ponieważ jest to zapisane w metadanych kolumny).
Jeżeli przykładowo odczytujemy kolumnę o wysokiej powtarzalności, np. kolumnę z nazwą kraju, zamiast zapisywać w DataPage
pełną nazwę kraju dla każdego wiersza,
zapisujemy jedynie jej pozycję w słowniku.
W przypadku takiej kolumny pierwsza strona w kolumnie będzie DictionaryPage
, a kolejne będą DataPage
.
Różnica polega na tym, że w DataPage
zamiast pełnej wartości, będą znajdować się pozycje w słowniku, który będziemy trzymać w pamięci w celu odbudowania wierszy.
Ważne: Każdy ColumnChunk
może zawierać tylko jedną stronę DictionaryPage
.
Potrafi to dać ogromne oszczędności, zamiast powiedzmy zapisywać binarnie słowo Polska
10 tys. razy, czyli 60k bajtów,
zapiszemy tylko pozycję w indeksie (czyli 4 bajty), które dodatkowo zostaną spakowane za pomocą algorytmu Run Length Encoding / Bit-Packing Hybrid.
Który, również opierając się na powtarzalności kolejnych wartości zredukuje całkowitą ilość potrzebnych bajtów.
IndexPage
Ostatnim już typem strony, jest IndexPage
.
Strona ta nie zawiera danych, nie jest więc niezbędna do odczytu ani zapisu.
Każdy ColumnChunk
może zawierać tylko jedną stronę typu IndexPage
i znajduje się ona zawsze na końcu, po DictionaryPage
oraz wszystkich DataPage
.
Celem tej strony jest przechowywanie statystyk odnośnie ColumnChunk
, jak np. wartości Min/Max
, ilość nulli
czy sposób sortowania dla każdej strony w konkretnym ColumnChunk
.
Pozwala to na szybkie filtrowanie oraz odnajdywanie konkretnych tylko konkretnych stron w ramach danego ColumnChunka
, co znacząco przyśpiesza przeszukiwanie pliku, jeżeli interesują nas konkretne informacje.
Uwaga: Każdy ColumnChunk
w swoich metadanych zawiera podobne statystyki co IndexPage
, jednak nie dla każdej strony a dla całego ColumnChunk
.
Dzięki temu, w pierwszej kolejności możemy przeskoczyć całe kolumny, które nas nie interesują a następnie nawet konkretne strony, redukując do absolutnego minimum ilość danych, które musimy odczytać.
Biorąc pod uwagę, że te informacje znajdują się w metadanych pliku, nawet największe pliki parqueta, mogą być błyskawicznie odczytywane i filtrowane nawet jeżeli są one tylko dostępne przez sieć.
Wystarczy, że uda się nam odczytać metadane, na ich podstawie zlokalizować konkretną grupę wierszy, następnie wybraną kolumnę a na końcu konkretne strony.
Dostaniemy w ten sposób bardzo precyzyjną lokalizację naszych danych, które będziemy mogli odczytać za pomocą nagłówka Http Range Header
.
To właśnie jeden z powodów, dla których parquet jest tak potężny, nie mówimy już tutaj o brutalnym pobieraniu i iterowaniu po gigabajtowym pliku. Parquet pozwala z precyzją chirurga pobrać i odczytać tylko te obszary pliku, które naprawdę nas interesują.
Dremel
Omawiając strukturę DataPage
wspomniałem o Definition Levels
oraz Repetition Levels
.
Omówiony przykład był bardzo prosty, ponieważ dotyczył kolumny prostej (int32), przez co Repetition Levels
w ogóle nie mają zastosowania.
Sytuacja zmienia się diametralnie, kiedy mamy do czynienia z kolumną zagnieżdżoną, np. strukturą, listą czy mapą.
Spójrzmy na przykład.
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
Wracając do wcześniejszej części tego artykułu, a dokładnie do typów zagnieżdżonych.
Wiemy, że nasze dane po spłaszczeniu będą wyglądać następująco:
items.list.element.sku
-"abc","def"
items.list.element.quantity
-1,2
items.list.element.price
-100,200
Mamy tutaj do czynienia z 3 kolumnami, każda z nich będzie znajdować się w osobnym Column Chunk
i każda będzie zawierać
w sobie jedną lub więcej stron.
Skąd więc na podstawie tych dwóch wartości (Repetition / Definition Levels)
biblioteki czytające pliki wiedzą, jak głęboko w strukturze znajdują się wartości oraz do którego elementu one należą?
Co w przypadku, gdyby nasza struktura wyglądała tak:
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":null, "quantity": 10, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
(w drugim elemencie sku ma wartość null).
Co w przypadku, kiedy struktura jest o wiele bardziej zagnieżdżona, skąd mamy wiedzieć, która wartość wpada na który poziom zagnieżdżenia?
Na to oraz wiele innych pytań odpowiedź znajdziemy w dokumencie opublikowanym przez Google Dremel: Interactive Analysis of Web-Scale Datasets który opisuje, w jaki sposób Google przechowuje oraz przeszukuje zagnieżdżone struktury danych.
Narzędzie wykorzystywane przez Googla nazywa się Dremel i jest rozproszonym systemem przeszukiwania dużych zbiorów danych.
Opiera się ono na 2 algorytmach, Shredding
oraz Assembling
, które zostały opisane bardzo pobieżnie w powyższym dokumencie.
Uwaga: Opisanie dokładnego działania tych algorytmów wykracza poza ramy tego i tak długiego wpisu.
Jeżeli jednak pojawi się zainteresowanie tematem, postaram się poruszyć również i ten wątek w nadchodzących wpisach.
Algorytmy te bazują na tych 3 definicjach:
- Repetition Levels
- Definition Levels
- Values
Tak jak już wspominaliśmy Definition Level
określa czy dany wiersz zawiera wartość, czy też nie, Repetition Level
który w przypadku kolumn płaskich jest zawsze 0.
Dla struktur określać będzie czy wartość (lub null) ma być powtórzona, oraz na którym poziomie zagłębienia.
Uwaga: Wiedza o tym, jak dokładnie działają algorytmy z Dremela, nie jest niezbędna do optymalnego wykorzystywania parqueta. Z tego powodu, nie będę się na ten temat rozpisywał, jeżeli jednak pojawi się zainteresowanie tematem, postaram się poruszyć również i ten wątek w nadchodzących wpisach.
Poniżej przedstawię tylko mniej więcej, jak wyglądać będą spłaszczone dane.
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" }
}
}
Czyli w rzeczywistości zapisujemy 0, 1, 0, 1, "abc", "def"
a nie tylko "abc", "def"
.
To właśnie te dodatkowe liczby mówią w jaki sposób odbudować dowolną strukturę zagnieżdżoną.
Ciekawostką jest, że nawet repetition levels i definition levels w celu optymalizacji są wcześnie odpowiednio pakowane przy użyciu algorytmu Run Length Encoding / Bit-Packing Hybrid.
Na tym nie koniec, bo nie tylko poziomy są pakowane, ale również same wartości.
W zależności od typu kolumny, wartości mogą być pakowane na różne sposoby, listę wszystkich algorytmów pakujących obsługiwanych przez parqueta (przynajmniej w teorii) znajdziemy
w oficjalnej dokumentacji.
Natomiast informację o tym, jaki algorytm został użyty do spakowania danych przed zapisem znajdziemy w metadanych, pod taką ścieżką RowGroups[x].ColumnChunk[y].PageHeader[z].data_page_header.encoding
Nie jest to natomiast ostatnie słowo parqueta w kontekście optymalizacji!
Kompresja
Po spakowaniu i zapisaniu w formie binarnej naszych danych dla konkretnej strony, każda strona jest dodatkowo kompresowana.
W zależności od implementacji parquet pozwala na użycie różnych algorytmów kompresji:
- UNCOMPRESSED
- SNAPPY
- GZIP
- LZO
- BROTLI
- LZ4
- ZSTD
- LZ4_RAW
Bardzo popularną opcją jest Snappy, która oferuje bardzo dobry kompromis pomiędzy szybkością a stopniem kompresji.
Narzędzia takie jak Apache Spark wręcz używają go domyślnie.
Szyfrowanie
Jedną z ostatnich ciekawszych funkcji, którą chciałbym omówić, jest szyfrowanie!
Tak, parquet pozwala szyfrować dane, szyfrować na wielu poziomach.
- Metadane - zaszyfrowane metadane skutecznie utrudniają odczytanie zawartości pliku, jednak nie jest to niemożliwe
- Dane - zaszyfrowane dane praktycznie uniemożliwiają odczyt
- Kolumny - szczególnie przydatne, jeżeli tylko niektóre kolumny zawierają wrażliwe dane.
- Strony
Uwaga: Szyfrowanie jest jedną z tych, funkcji, których jeszcze nie zdążyłem pokryć w implementacji dla PHP
Z tego powodu nie będę się na ten temat rozpisywał, jak tylko nadaży się okazja do zaimplementowanie tej funkcjonalności, postaram się uzupełnić artykuł.
Szyfrowanie w parquecie opiera się o Parquet Modular Encryption i wykorzystuje AES do szyfrowania danych.
Szyfrowanie, szczególnie wybranych kolumn, wynosi parqueta na wyższy poziom przechowywania danych.
Dzięki temu w stosunkowo łatwy sposób, z niewielkim narzutem,
możemy dodatkowo zabezpieczyć dane, które przechowujemy w plikach parqueta.
Wyobraźmy sobie, że parquet jest używany do przechowywania danych klientów, gdzie kolumna email
oraz phone
zawierają dane wrażliwe.
W tej sytuacji, aż się prosi o to, żeby te dwie kolumny były dodatkowo zabezpieczone. Nawet jeżeli komuś uda się uzyskać fizyczny dostęp do pliku, bez klucza i tak nie
będzie w stanie odczytać danych.
Podsumowanie
To właśnie jest tajemnica parqueta i sposób na wydajność. Zamiast przechowywać dowolne dane w postaci tekstowej, parquet idzie o kilka kroków dalej.
W pierwszej kolejności wymusza schemat danych opierający się o proste lecz niesamowicie elastyczne typy, z których każdy da się
przedstawić w postaci binarnej.
Następnie postać binarna jest odpowiednio pakowana, tak aby unikać zbędnych powtórzeń bajtów, co na samym końcu jest
jeszcze dodatkowo kompresowane za pomocą bardzo wydajnych algorytmów.
Wisienką na torcie są zaawansowane i szczegółówe metadane, dostępne na kilku poziomach, pozwalające odfiltrować
zbędne partycje, lub wręcz całe pliki bez odczytywania ich zawartości.
Ponadto dzięki odpowiedniemu podziałowi logicznemu, nad którym mamy pełną kontrolę (rozmiar grup i stron) możemy decydować co jest dla nas ważniejsze, szybkość czy oszczędność pamięci. Przeszukiwanie czy odczytywanie danych a może bezpieczeństwo, do którego wykorzystamy dodatkowe szyfrowanie?
Parquet to naprawdę potężne narzędzie, które w odpowiednich rękach pozwala na efektywne przechowywanie i przeszukiwanie
ogromnych ilości danych.
Jeżeli ten wpis zainspirował Cię do poeksperymentowania z tym rewelacyjnym formatem danych, daj znać w komentarzach!
Pomoc
Jeśli potrzebujesz pomocy w zakresie budowy centralnego magazynu danych, chętnie Ci pomogę.
Skontaktuj się ze mną, a wspólnie stworzymy rozwiązanie, które będzie idealnie dopasowane do Twoich potrzeb.
Zachęcam również do odwiedzenia serwera Discord - Flow PHP, na którym możemy porozmawiać bezpośrednio.
