Translation Notice

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

Parquet - Introducción

Parquet - Introducción

Fecha de Publicación September 20, 2025 00:00
parquet binario columnar formato de archivos compresión de datos metadatos cifrado Apache Thrift Flow PHP

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

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:

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:

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:

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:

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:

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

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:

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:

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í:

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í:

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:

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

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:

  1. Leemos FileMetadata
  2. Encontramos el RowGroup apropiado y buscamos el ColumnChunk relevante para nosotros
  3. Teniendo ColumnChunk obtendremos la dirección file_offset del comienzo de ColumnChunk 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í:

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:

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:

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.

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.

Consultoría