
Parquet - Introducción
Parquet, un formato de archivo binario y columnar creado para el almacenamiento y búsqueda eficiente de datos.
En internet hay montones de artículos sobre Parquet, entonces ¿por qué uno más?
Esta es mi perspectiva sobre este fantástico formato, que es básicamente el resultado de mi experiencia trabajando en
escribir una implementación de Parquet en PHP puro.
Para aquellos que llegaron aquí por casualidad, mencionaré que soy el autor del primer framework de procesamiento
de datos en PHP, llamado Flow PHP.
Como corresponde a un Data Frame, Flow debe poder leer y escribir datos en varios formatos, incluyendo Parquet
Sin embargo, como la única implementación que encontré era básicamente un port directo de C#, que además no maneja completamente las estructuras profundamente anidadas y tiene muchas funciones faltantes, decidí como ejercicio de aprendizaje, escribir mi propia implementación desde cero, lo que resultó ser una experiencia extremadamente valiosa pero también muy divertida.
Por qué Parquet
- Formato Binario - hasta 10x archivos más pequeños
- Metadatos - acceso más fácil a datos seleccionados
- Esquema - garantía de estructura correcta
- Compresión - reducción adicional de tamaño
- Cifrado - a nivel de archivo, metadatos, columnas o páginas
Formato Binario
Gracias a que este formato está orientado a columnas, no filas, permite una compresión de datos muy eficiente, lo que se traduce en un tamaño de archivo significativamente menor. Sin mucho esfuerzo, Parquet puede comprimir datos hasta 10 veces, comparado con formatos tradicionales como CSV o XML.
Entonces, si los mismos datos guardados en formato CSV ocupan 1GB, en formato Parquet pueden ocupar solo 100MB.
Para este artículo generé 2 archivos, uno en formato CSV, otro en formato Parquet.
La estructura de estos archivos es muy simple, contiene 10 columnas y 10 millones de filas, que se ven más o menos así:
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}]"
El efecto de compresión es realmente impresionante:
4.1G Sep 20 18:32 orders.csv
437M Sep 20 18:47 orders.parquet
Esto se traduce no solo en costos de almacenamiento, sino también de procesamiento de datos.
Especialmente cuando nuestros datos viven en la nube, ya sea en Azure Bucket o AWS S3. Uno de los mayores factores que afectan
la factura no es el tamaño de los datos, sino cuánto tráfico usamos para leer/escribir esos datos.
Así que reduciendo el tamaño del archivo, reducimos no solo el costo de almacenarlo, sino también de procesarlo. Es importante entender que el procesamiento es realmente cualquier forma de acceso, es decir, escritura/lectura.
Esto se reduce a que, eligiendo el formato de archivo apropiado, los ahorros pueden ser realmente significativos, especialmente cuando hablamos de mayores cantidades de datos.
¿Qué significa exactamente que Parquet es un formato binario?
Significa más o menos que los datos se almacenan en forma binaria, es decir, de una manera que no se puede leer directamente usando editores de texto populares.
Pero todo finalmente se almacena en forma binaria, ¿no?
Sí, generalmente los archivos de texto también son archivos binarios, la diferencia es que en archivos de texto la estructura del archivo es siempre la misma y cada información se guarda de la misma manera.
Por ejemplo, si quisiéramos guardar "12345" en un archivo de texto, la versión binaria se vería así:
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 misma cadena guardada en formato binario como int32 (entero en forma de 32 bits) se vería así:
INTEGER: 12345
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Integer: 0 0 48 57
Binary: 00000000 00000000 00110000 00111001
└─byte─┘ └─byte─┘ └─byte─┘ └─byte─┘
Total: 4 bytes for a 32-bit integer
Notemos que en el caso de guardar un entero en forma binaria, no se puede simplemente leer de izquierda a derecha (o viceversa). Aquí ya debemos saber cómo interpretar esos bits para entender qué significan. En el caso de archivos de texto no tenemos este problema, ya que sabemos que cada carácter se guarda en forma de 8 bits.
Más o menos por eso cualquier editor de texto puede abrir cualquier archivo de texto y mostrarnos algo que tendrá más o menos sentido.
Sin embargo, si tratamos de abrir un archivo tipo Parquet en un editor de texto, obtendremos una cadena de caracteres que parece muy aleatoria y no tiene mucho sentido.
Columnar / Por Filas
La mejor manera de explicar la diferencia entre estos formatos es con visualización.
En el modelo clásico por filas cada fila contiene todas las columnas, como por ejemplo en formato CSV
+------+------+------+
| Col1 | Col2 | Col3 |
+------+------+------+
| A1 | B1 | C1 |
| A2 | B2 | C2 |
| A3 | B3 | C3 |
+------+------+------+
El formato columnar es interesante porque en lugar de almacenar datos fila por fila, los almacena columna por columna.
+------+------+------+------+
| Col1 | A1 | A2 | A3 |
+------+------+------+------+
| Col2 | B1 | B2 | B3 |
+------+------+------+------+
| Col3 | C1 | C2 | C3 |
+------+------+------+------+
Almacenar datos en formato columnar trae muchos beneficios, como:
- Mucho mejor capacidad de compresión de datos
- Capacidad de leer solo columnas seleccionadas
- Capacidad de cifrar columnas seleccionadas o todas
En el caso del formato por filas, para leer solo una columna, tenemos que revisar todo el archivo de todos modos.
En el caso del formato columnar podemos leer solo las columnas que nos interesan.
Esto es especialmente útil en el caso de conjuntos de datos muy grandes, donde a menudo necesitamos solo parte de la información.
Inmutable
Debido a la forma en que los datos se almacenan en formato columnar, los archivos Parquet son inmutables.
Esto no significa que no se puedan modificar. Se puede, pero la única operación sensata es agregar datos al final.
¿Por qué? Parquet almacena datos en formato columnar, lo que significa que si tenemos una columna email
todas las filas (en un grupo de filas y página dados - de esto más adelante) estarán escritas una tras otra.
Intentar modificar una fila es por tanto imposible, porque requeriría mover prácticamente todo el archivo.
Sin embargo, es posible agregar un nuevo grupo de filas al final del archivo. Esto se hace removiendo metadatos del final del archivo, que temporalmente van a la memoria. En su lugar se escribe el nuevo grupo de filas (que también debe agregarse a los metadatos), y luego al final se escriben los metadatos nuevamente.
Por esta razón, si queremos eliminar algo de un archivo Parquet, en la práctica tenemos que reescribir todo el archivo, omitiendo los datos no deseados.
Estructura Fuerte
Parquet es un formato basado en tipado fuerte. Esto significa que la estructura de todo el archivo está definida y almacenada en el pie de página, gracias a lo cual es suficiente leer solo el segmento apropiado para entender qué datos tenemos en el archivo, y en qué regiones del archivo están guardados esos datos.
Podemos pensar en esto como un mapa del archivo, un mapa que nos dirá dónde exactamente en el archivo están los datos que nos interesan.
Así es más o menos como se ve la estructura simplificada de un archivo en formato Parquet:
+-----------------+
| PAR1 |
+-----------------+
| Data |
| ............. |
| ............. |
+-----------------+
| File Metadata |
+-----------------+
| PAR1 |
+-----------------+
En el ejemplo anterior vemos 3 elementos:
PAR1
- es decir, "Parquet Magic Bytes" - 4 bytes que abren y cierran archivos en formato ParquetData
- aquí se guardan todas las columnas (de esto más adelante)Metadata
- metadatos, es decir, el mapa del archivo
El primer paso para leer correctamente un archivo Parquet es verificar si los primeros 4 bytes son PAR1
.
Si es así, debemos saltar al final del archivo (seek) y leer los últimos 4 bytes.
Si el final y el comienzo del archivo contienen PAR1
podemos proceder a leer los metadatos.
Para esto retrocedemos 8 bytes desde el final del archivo y leemos 4 bytes representando el tamaño de los metadatos.
En otras palabras, leemos los bytes -8
a -4
Esos 4 bytes son un integer
que nos dice en cuántos bytes están escritos los metadatos. Teniendo
esta información podemos leer los metadatos, que están serializados de forma binaria usando Apache Thrift
Apache Thrift
Apache Thrift es una herramienta muy inteligente que permite la serialización binaria de interfaces/tipos en prácticamente cualquier lenguaje de programación.
Aquí podemos ver cómo se ve la definición de metadatos en formato Parquet.
Este formato se parece un poco a pseudocódigo, que luego usando la aplicación apropiada se usa para generar código en un lenguaje de programación dado.
Aquí podemos ver cómo se ve el código generado en PHP.
Cuando ya tenemos las estructuras/interfaces/modelos generados podemos proceder a la lectura.
<?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))
)
)
);
Para esto necesitaremos la biblioteca Thrift para el lenguaje de programación elegido. Todas las implementaciones están disponibles en el repositorio apache/thrift.
Teniendo acceso a $metadata
podemos comenzar a analizar nuestro archivo para entender su estructura.
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
}
La información clave sobre el archivo se almacena en la estructura FileMetaData
.
Las más importantes de ellas son:
version
- versión del formato Parquetnum_rows
- número de filas en el archivoschema
- esquema de datosrow_groups
- aquí se almacenan nuestros datos
Versiones del Formato
Al momento de escribir este artículo el formato Parquet ya estaba disponible en versión 2.12.0
.
Los cambios más cruciales entre las versiones 1.0 y 2.0 son:
- Nuevos esquemas de codificación: DELTA_BINARY_PACKED para números, DELTA_BYTE_ARRAY para strings, RLE_DICTIONARY reemplazando PLAIN_DICTIONARY
- Estructura Data Page V2: Eliminó la sobrecarga de metadatos, permitió filtrado a nivel de página
Aunque la versión 2.0 introduce muchas mejoras, los jugadores más grandes aún usan la versión 1 por defecto.
Número de Filas
Esta información puede parecer poco intuitiva al principio en el contexto del formato columnar.
Sin embargo, debemos recordar que el formato columnar es solo una forma de almacenar valores, no la estructura de datos.
A pesar de que los datos están agrupados por columnas y su tipo, la lectura/escritura aún ocurre de manera clásica, es decir, fila por fila.
La diferencia es que no leemos una fila a la vez, sino todo un grupo de filas, cargando en memoria columna por columna, y luego reconstruyendo las filas basándose en los índices apropiados.
Recordando que para escribir datos apropiadamente en formato columnar debemos operar en grupos lógicos, no en filas individuales. Podemos de manera relativamente fácil gestionar la relación entre memoria y cantidad de operaciones IO.
La escritura y lectura desde memoria es más rápida que la escritura y lectura desde disco (aunque no siempre).
Aumentando la cantidad de filas que se escribirán en un grupo, reducimos el número de grupos, es decir, el número de operaciones IO.
Así aumentamos la velocidad de escritura/lectura, a la vez que aumentamos el uso de memoria.
Esto también funciona al revés, reduciendo la cantidad de filas en un grupo, aumentamos el número de grupos en el archivo, así aumentando el número de operaciones IO.
Tamaño del grupo, no cantidad de filas - Parquet permite definir no la cantidad de filas, sino el tamaño máximo
del grupo de filas.
Sin embargo, hay que recordar que estos no son valores absolutos (de esto un poco más adelante), entonces
algunos grupos pueden ser más pequeños/grandes que el tamaño permitido y esto depende principalmente de la implementación de la biblioteca
de Parquet.
En la documentación del formato Parquet encontraremos información de que el tamaño sugerido del grupo es 512Mb - 1Gb
.
Sin embargo, vale la pena abordar esto con un poco de sentido común, especialmente si para lectura/escritura no dependemos de HDFS (Hadoop Distributed File System).
El valor sugerido se establece de tal manera que un grupo de filas quepa en un bloque HDFS, garantizando que la lectura
ocurra desde exactamente un nodo.
Vale la pena recordar esto, sin embargo, si no planeamos usar Parquet con un sistema de archivos distribuido, grupos de filas más pequeños permitirán ahorrar bastante memoria.
Un muy buen ejemplo de cuándo los grupos más pequeños son más eficientes es el caso donde quisiéramos leer solo una pequeña sección de filas desde el medio del archivo (paginación).
Asumiendo que necesitamos leer solo 100 filas de un archivo que contiene 10 millones de filas, establecer un tamaño de grupo más pequeño permitirá ahorrar mucho en memoria. ¿Por qué?
Si dividimos 10 millones en digamos 10 grupos, cada grupo contiene 1 millón de filas. Esto significa que en la práctica debemos leer todo el grupo, y luego extraer solo las 100 filas que nos interesan.
En el caso de establecer un tamaño de grupo más pequeño, que permita dividir 10 millones en 1000 grupos, analizando los metadatos del archivo, podremos saltar una mayor cantidad de grupos y cargar en memoria una cantidad mucho menor de filas.
La decisión sobre el tamaño del grupo de filas debe ser considerada tanto para el rendimiento de escritura como de lectura del archivo específico. La configuración apropiada se traduce directamente en el uso de recursos lo que finalmente se traduce en dinero.
Esquema
Lentamente llegamos al núcleo de Parquet, es decir, Row Groups
. Pero antes de analizar su estructura, debemos
volver a otro aspecto muy importante de Parquet, el esquema de datos.
Comencemos con los tipos de datos. Parquet consiste en tipos físicos y lógicos.
Tipos Físicos
Los tipos físicos son los tipos de datos básicos que se usan para almacenar valores en el archivo Parquet. Son tipos como:
- Boolean
- Byte Array
- Double
- Fixed Len Byte Array
- Float
- Int32
- Int64
- Int96 - (deprecado - usado solo por implementaciones más antiguas)
Los tipos lógicos son tipos que se usan para representar estructuras de datos más complejas. Se puede pensar en ellos como una extensión de los tipos físicos.
Tipos Lógicos
- Bson
- Date
- Decimal
- Enum
- Integer
- Json
- List
- Map
- String
- Time
- Timestamp
- Uuid
La estructura actual siempre se puede verificar en la fuente, apache/parquet-format
Además de la división en tipos lógicos y físicos, Parquet también distingue columnas planas y anidadas.
Columnas planas son aquellas que almacenan un solo valor, por ejemplo, Int32
, Boolean
, Float
, etc.
Columnas anidadas son aquellas que almacenan más de un valor, por ejemplo, List
, Map
, etc.
En principio existen 3 tipos de columnas anidadas:
- List
- Map
- Struct
Struct, es un tipo especial de columna que permite anidar cualquier otro tipo, permitiendo crear prácticamente cualquier estructura de datos.
Usando los tipos anteriores podemos modelar prácticamente cualquier estructura de datos, y luego almacenarla y buscarla eficientemente.
Veamos entonces las definiciones Thrift SchemaElement
y algunos elementos relacionados.
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 mayoría de valores debería ser bastante obvia, pero veamos FieldRepetitionType
.
Este valor nos dice si una columna dada es requerida, opcional o repetible.
Si una columna es requerida, significa que el valor no puede ser null.
Si una columna es opcional el valor puede ser null, y si es repetible, significa que puede contener múltiples valores (por ejemplo, una lista).
Así es como puede verse el esquema de un archivo de pedidos (en 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;
}
}
}
}
Tipos Anidados
Para entender completamente la estructura de grupos de filas primero debemos entender cómo Parquet aplana los tipos anidados.
Mientras que estructuras simples como address
del ejemplo anterior se pueden reducir básicamente a 4 columnas simples:
address.street
- Stringaddress.city
- Stringaddress.zip
- Stringaddress.country
- String
En el caso de Map
o List
la situación es un poco más complicada.
Por ejemplo, si quisiéramos aplanar Map<string,int32>
obtendríamos algo así:
map_column.key_value.key
- Stringmap_column.key_value.value
- Int32
Así que para el ejemplo anterior la ruta plana a sku
se vería así:
items.list.element.sku
, mientras que la estructura plana completa se vería así:
┌─────────────────────────────┬──────────────────────── 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 │
└─────────────────────────────┴──────────────────────────┴──────────────┴────────────┴────────────────┴────────────────┘
Grupos de Filas
+-----------------------------------+
| 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 |
+-----------------------------------+
De acuerdo con lo que ya sabemos, un archivo Parquet está dividido en grupos de filas, la escritura al archivo en resumen funciona así:
- 1) crear archivo y agregar 4 bytes
PAR1
- 2) crear estructura de metadatos basada en el esquema y mantenerla en memoria
- 3) aplanar la fila pasada (verificando si coincide con el esquema)
- 4) escribir la fila aplanada en memoria en forma binaria
-
5) verificar si el tamaño del grupo de filas que actualmente tenemos en memoria cabe en el tamaño máximo permitido
- a) escribir grupo de filas al archivo
- b) actualizar metadatos en memoria agregándoles metadatos del grupo que acabamos de escribir
- 6) volver al paso 2
- 7) Escribir metadatos al final del archivo después de escribir todos los grupos de filas
-
8) Cerrar archivo con 4 bytes
PAR1
Por supuesto esta descripción está muy simplificada, en realidad es un poco más compleja, además diferentes implementaciones pueden diferir en detalles.
Enfoquémonos en la estructura del grupo de filas, veamos primero las definiciones 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
}
Ya en esta etapa se ve cuánta información sobre un grupo específico de filas se almacena en los metadatos.
Por ahora enfoquémonos en tres campos:
file_offset
- es decir, cuántos bytes desde el comienzo del archivo hay que saltar para leer el grupo dadototal_byte_size
- en cuántos bytes está escrito el grupo de filascolumns
- información detallada sobre cada columna escrita dentro del grupo dado
Importante: cada grupo de filas siempre contiene todas las columnas definidas en el esquema.
Incluso si a lo largo de todo el grupo una columna contiene solo valores null.
Chunks de Columna
Profundicemos y miremos la definición 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;
}
Recuerda: Todo lo que hemos visto hasta ahora sigue siendo parte de los metadatos.
Esto significa que toda esta información sobre columnas, grupos de filas o los datos mismos la obtenemos leyendo
solo el final del archivo, independientemente de si el archivo tiene 1MB o 1TB.
Aquí llegamos básicamente al lugar que nos permite leer datos del archivo.
Pero antes de que esto suceda debemos conocer la última estructura de datos necesaria para la lectura.
Páginas de Datos
Pages
, es decir, otra división lógica en la estructura del archivo Parquet.
Row Group -> Column Chunk -> Data Pages
RowGroup
- grupo de filas (partición)ColumnChunk
- cada grupo de filas contiene exactamente 1ColumnChunk
para cada columna en el grupoData Page
- página, la unidad lógica más pequeña en Parquet que agrega datos
En realidad, leer Parquet se reduce a analizar la estructura de metadatos, localizar la dirección del comienzo de un grupo específico de filas, luego una columna específica en el grupo, y luego iterar y leer datos de cada página.
Pero antes de empezar a leer páginas, debemos entender si estamos lidiando con DataPage
, IndexPage
o DictionaryPage
.
Para esto primero leemos PageHeader
es decir, el encabezado de la página, cuya definición Thrift se ve así
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;
}
Para leer el encabezado debemos conocer su dirección relativa al comienzo del archivo, así es como podemos calcularlo para un grupo de filas y columna seleccionados:
- Leemos
FileMetadata
- Encontramos el
RowGroup
apropiado y buscamos elColumnChunk
relevante para nosotros - Teniendo
ColumnChunk
obtendremos la direcciónfile_offset
del comienzo deColumnChunk
relativa al comienzo del archivo.
Importante: En esta etapa aún no necesitamos cargar físicamente los bytes en memoria.
Es suficiente que creemos un stream
que permita leer datos directamente del archivo.
Lo primero que hay que leer es el encabezado, PageHeader
, haciéndolo usando Thrift, pasando
el stream y estableciendo apropiadamente la dirección del comienzo obtendremos la estructura de datos PageHeader
, que nos dirá exactamente cómo leer
la página misma.
Existen 3 tipos de páginas:
DataPage
Página que contiene representación binaria de datos de la columna seleccionada de las filas que fueron al grupo de filas seleccionado.
Es el tipo de página más simple y directo. Contiene "solo" datos.
Leyendo una columna tipo entero, lo que nos interesa es realmente el número de filas en el grupo específico (cada fila es un valor en DataPage
).
Así que sabiendo que en este grupo tenemos digamos 100 valores, sabemos que tenemos que leer 400 bytes (int32 se escribe en 4 bytes).
Bueno, pero ¿qué pasa cuando la columna es opcional? Eso significa que puede contener valores null.
Aquí la situación se vuelve un poco más complicada porque necesitamos saber qué filas contienen valor null.
¿De dónde viene este conocimiento?
Definition Levels
La situación se complica un poco, al principio escribí que DataPage
contiene solo datos, y ahora agrego algunos Definition Levels
.
En realidad la estructura de data page se ve más o menos así:
Parquet Data Page: int32
=======================================
[ Repetition Levels ]: 0, 0, 0, 0, 0
---------------------------------------
[ Definition Levels ]: 1, 0, 1, 1, 0
---------------------------------------
[ Values ]: 42, 73, 19
=======================================
Por ahora, enfoquémonos solo en Definition Levels
y Values
. Es muy fácil notar la relación entre ellos.
La cantidad de Definition Level
y Repetition Levels
en cada página siempre es igual a la cantidad de valores en la columna.
Sin importar si hay nulls o no. Definition Levels
nos dicen si una fila dada contiene un valor o null.
En base a esto, podemos determinar fácilmente la cantidad total de Values
no vacíos lo que nos permitirá leerlos.
En el ejemplo anterior tenemos 5 filas, de las cuales 3 constituyen valores, ya que int32
lo escribimos en 4 bytes,
ya sabemos que tenemos que leer en total 12 bytes.
También sabemos que al transformar la columna en filas, la primera fila contendrá el valor 42
, la segunda null
,
la tercera 73
, la cuarta 19
y la quinta null
.
Importante: Repetition Levels
y Definition Levels
son sin embargo mucho más complicados, un poco más adelante.
Así más o menos se presenta la estructura de DataPage
.
DictionaryPage
Si los datos los guardamos en DataPage
, ¿qué propósito tiene DictionaryPage
?
Bueno, DictionaryPage
es una página que contiene un diccionario de valores.
Diccionario, usado para leer datos, especialmente en el caso de columnas que contienen valores repetibles.
Funciona más o menos así, que leyendo ColumChunk
, empezamos desde la primera página, si esta página es DictionaryPage
,
sabemos que estamos lidiando con un diccionario (en realidad lo sabemos desde el principio, porque está escrito en los metadatos de la columna).
Si por ejemplo leemos una columna con alta repetibilidad, ej. una columna con nombre de país, en lugar de escribir en DataPage
el nombre completo del país para cada fila,
escribimos solo su posición en el diccionario.
En el caso de tal columna la primera página en la columna será DictionaryPage
, y las siguientes serán DataPage
.
La diferencia es que en DataPage
en lugar del valor completo, habrá posiciones en el diccionario, que mantendremos en memoria para reconstruir las filas.
Importante: Cada ColumnChunk
puede contener solo una página DictionaryPage
.
Esto puede dar ahorros enormes, en lugar de digamos escribir de forma binaria la palabra Polonia
10 mil veces, es decir, 60k bytes,
escribiremos solo la posición en el índice (es decir, 4 bytes), que adicionalmente serán empaquetados usando el algoritmo Run Length Encoding / Bit-Packing Hybrid.
Que, también basándose en la repetibilidad de valores consecutivos reducirá la cantidad total de bytes necesarios.
IndexPage
El último tipo de página es IndexPage
.
Esta página no contiene datos, por lo que no es necesaria para lectura ni escritura.
Cada ColumnChunk
puede contener solo una página tipo IndexPage
y siempre se encuentra al final, después de DictionaryPage
y todas las DataPage
.
El propósito de esta página es almacenar estadísticas sobre ColumnChunk
, como valores Min/Max
, cantidad de nulls
o manera de ordenamiento para cada página en un ColumnChunk
específico.
Esto permite filtrado rápido y encontrar solo páginas específicas dentro de un ColumnChunk
dado, lo que acelera significativamente la búsqueda en el archivo, si nos interesan informaciones específicas.
Atención: Cada ColumnChunk
en sus metadatos contiene estadísticas similares a IndexPage
, pero no para cada página sino para todo el ColumnChunk
.
Gracias a esto, en primera instancia podemos saltar columnas completas que no nos interesan y luego incluso páginas específicas, reduciendo al mínimo absoluto la cantidad de datos que tenemos que leer.
Considerando que esta información se encuentra en los metadatos del archivo, incluso los archivos Parquet más grandes pueden ser leídos y filtrados instantáneamente incluso si solo están disponibles a través de la red.
Es suficiente que logremos leer los metadatos, en base a ellos localizar un grupo específico de filas, luego una columna seleccionada y al final páginas específicas.
Obtendremos de esta manera una localización muy precisa de nuestros datos, que podremos leer usando el encabezado Http Range Header
.
Esta es precisamente una de las razones por las que Parquet es tan poderoso, ya no hablamos de descargar brutalmente e iterar sobre un archivo de gigabytes. Parquet permite con precisión de cirujano descargar y leer solo las áreas del archivo que realmente nos interesan.
Dremel
Discutiendo la estructura de DataPage
mencioné Definition Levels
y Repetition Levels
.
El ejemplo discutido fue muy simple, porque se refería a una columna simple (int32), por lo que Repetition Levels
no tienen aplicación en absoluto.
La situación cambia diametralmente cuando estamos lidiando con una columna anidada, ej. estructura, lista o mapa.
Veamos un ejemplo.
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
Volviendo a la parte anterior de este artículo, específicamente a tipos anidados.
Sabemos que nuestros datos después del aplanamiento se verán así:
items.list.element.sku
-"abc","def"
items.list.element.quantity
-1,2
items.list.element.price
-100,200
Tenemos aquí 3 columnas, cada una de ellas estará en un Column Chunk
separado y cada una contendrá
una o más páginas.
Entonces, ¿cómo basándose en estos dos valores (Repetition / Definition Levels)
las bibliotecas que leen archivos saben qué tan profundo en la estructura están los valores y a qué elemento pertenecen?
¿Qué pasa si nuestra estructura se viera así:
[{"sku":"abc", "quantity": 1, "price": 100}, {"sku":null, "quantity": 10, "price": 100}, {"sku":"def", "quantity": 2, "price": 200}]
(en el segundo elemento sku tiene valor null).
¿Qué pasa cuando la estructura está mucho más anidada, cómo sabemos qué valor va a qué nivel de anidamiento?
La respuesta a esta y muchas otras preguntas la encontraremos en el documento publicado por Google Dremel: Interactive Analysis of Web-Scale Datasets que describe cómo Google almacena y busca estructuras de datos anidadas.
La herramienta usada por Google se llama Dremel y es un sistema distribuido de búsqueda de grandes conjuntos de datos.
Se basa en 2 algoritmos, Shredding
y Assembling
, que están descritos muy brevemente en el documento anterior.
Atención: Describir el funcionamiento exacto de estos algoritmos está fuera del alcance de este ya largo artículo.
Si aparece interés en el tema, trataré de abordar también este hilo en próximos artículos.
Estos algoritmos se basan en estas 3 definiciones:
- Repetition Levels
- Definition Levels
- Values
Como ya mencionamos Definition Level
determina si una fila dada contiene un valor, o no, Repetition Level
que en el caso de columnas planas siempre es 0.
Para estructuras determinará si el valor (o null) debe ser repetido, y en qué nivel de profundidad.
Atención: El conocimiento de cómo funcionan exactamente los algoritmos de Dremel, no es necesario para el uso óptimo de Parquet.
Por esta razón, no me extenderé sobre este tema, sin embargo, si aparece interés en el tema, trataré de abordar también este hilo en próximos artículos.
Abajo presentaré solo más o menos cómo se verán los datos aplanados.
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" }
}
}
Es decir, en realidad escribimos 0, 1, 0, 1, "abc", "def"
y no solo "abc", "def"
.
Son precisamente estos números adicionales los que dicen cómo reconstruir cualquier estructura anidada.
Es curioso que incluso los repetition levels y definition levels para optimización son empaquetados apropiadamente usando el algoritmo Run Length Encoding / Bit-Packing Hybrid.
Ahí no termina, porque no solo los niveles son empaquetados, sino también los valores mismos.
Dependiendo del tipo de columna, los valores pueden ser empaquetados de diferentes maneras, la lista de todos los algoritmos de empaquetado soportados por Parquet (al menos en teoría) la encontraremos
en la documentación oficial.
Mientras que la información sobre qué algoritmo se usó para empaquetar los datos antes de escribir la encontraremos en los metadatos, bajo tal ruta RowGroups[x].ColumnChunk[y].PageHeader[z].data_page_header.encoding
¡Pero esta no es la última palabra de Parquet en el contexto de optimización!
Compresión
Después de empaquetar y escribir en forma binaria nuestros datos para una página específica, cada página es adicionalmente comprimida.
Dependiendo de la implementación Parquet permite el uso de diferentes algoritmos de compresión:
- UNCOMPRESSED
- SNAPPY
- GZIP
- LZO
- BROTLI
- LZ4
- ZSTD
- LZ4_RAW
Una opción muy popular es Snappy, que ofrece un muy buen compromiso entre velocidad y grado de compresión.
Herramientas como Apache Spark incluso lo usan por defecto.
Cifrado
Una de las últimas características interesantes que quiero discutir es ¡el cifrado!
Sí, Parquet permite cifrar datos, cifrar en múltiples niveles.
- Metadatos - metadatos cifrados efectivamente dificultan la lectura del contenido del archivo, pero no es imposible
- Datos - datos cifrados prácticamente imposibilitan la lectura
- Columnas - especialmente útil si solo algunas columnas contienen datos sensibles.
- Páginas
Atención: El cifrado es una de esas características que aún no he cubierto en la implementación para PHP
Por esta razón no me extenderé sobre este tema, tan pronto como tenga la oportunidad de implementar esta funcionalidad, trataré de completar el artículo.
El cifrado en Parquet se basa en Parquet Modular Encryption y utiliza AES para cifrar datos.
El cifrado, especialmente de columnas seleccionadas, eleva Parquet a un nivel superior de almacenamiento de datos.
Gracias a esto de manera relativamente fácil, con poco overhead,
podemos proteger adicionalmente los datos que almacenamos en archivos Parquet.
Imaginemos que Parquet se usa para almacenar datos de clientes, donde la columna email
y phone
contienen datos sensibles.
En esta situación, se pide que estas dos columnas estén adicionalmente protegidas. Incluso si alguien logra obtener acceso físico al archivo, sin la clave aún no
podrá leer los datos.
Resumen
Ese es precisamente el secreto de Parquet y la forma de lograr eficiencia. En lugar de almacenar datos arbitrarios en forma textual, Parquet va varios pasos más allá.
En primera instancia fuerza un esquema de datos basado en tipos simples pero increíblemente flexibles, cada uno de los cuales puede ser
representado en forma binaria.
Luego la forma binaria es apropiadamente empaquetada, para evitar repeticiones innecesarias de bytes, lo que al final es
adicionalmente comprimido usando algoritmos muy eficientes.
La cereza del pastel son los metadatos avanzados y detallados, disponibles en varios niveles, permitiendo filtrar
particiones innecesarias, o incluso archivos completos sin leer su contenido.
Además, gracias a la división lógica apropiada, sobre la cual tenemos control completo (tamaño de grupos y páginas) podemos decidir qué es más importante para nosotros, velocidad o ahorro de memoria. Búsqueda o lectura de datos o tal vez seguridad, para la cual utilizaremos cifrado adicional.
Parquet es realmente una herramienta poderosa que en las manos correctas permite el almacenamiento y búsqueda eficiente
de enormes cantidades de datos.
Si este artículo te inspiró a experimentar con este formato de datos revelador, ¡hazme saber en los comentarios!
Ayuda
Si necesitas ayuda en la construcción de un almacén central de datos, estaré encantado de ayudarte.
Contáctame, y juntos crearemos una solución que se adapte perfectamente a tus necesidades.
También te animo a visitar el servidor Discord - Flow PHP, donde podemos hablar directamente.
