Saltar al contenido

lección 4

Particionado: organizar terabytes para encontrar lo que necesitas

Hive-style partitioning, partition pruning, el small file problem y estrategias para organizar datos masivos de forma eficiente.

55 min

Imagina una biblioteca con 10 millones de libros. Si todos estuvieran en una sola estantería gigante sin organización, encontrar uno específico requeriría recorrer la estantería entera. Pero si los organizas por género, luego por autor, luego por año — puedes ir directamente a la sección correcta. Eso es exactamente lo que hace el particionado: organizar archivos en carpetas jerárquicas para que los motores de consulta solo lean las carpetas relevantes.

### Qué es particionar y por qué importa

Particionar un dataset es dividirlo en subdirectorios basados en los valores de una o más columnas. En vez de un archivo enorme con todos los datos, tienes miles de archivos pequeños organizados en carpetas. El truco está en que el motor de consultas puede SALTAR carpetas enteras sin abrirlas — esto se llama "partition pruning" y es la razón por la que queries sobre 10 TB tardan segundos en vez de horas.

El estándar de facto es el "Hive-style partitioning": cada nivel de partición es una carpeta cuyo nombre tiene formato columna=valor. Es una convención que todos los motores entienden: Spark, Athena, Presto, DuckDB, PyArrow.

1# Hive-style partitioning: carpetas con nombre columna=valor
2s3://lake/silver/ventas/
3 year=2023/
4 month=01/
5 part-00000.parquet
6 part-00001.parquet
7 month=02/
8 part-00000.parquet
9 ...
10 year=2024/
11 month=01/
12 part-00000.parquet
13 ...
14
15# Query: SELECT * FROM ventas WHERE year=2024 AND month=01
16# → Solo lee: silver/ventas/year=2024/month=01/*.parquet
17# → Ignora TODOS los demás directorios

Hive-style partitioning: la convención que todos los motores entienden

### Elegir la columna de partición: la decisión más importante

Elegir mal la columna de partición es un error que he visto destruir el rendimiento de data lakes enteros. La regla es simple: particiona por la columna que MÁS se filtra en las queries. En el 90% de los casos, esa columna es una fecha (día, mes o año). Pero hay criterios más específicos:

  1. 01.Cardinalidad media: no demasiados valores (no particionar por user_id con 50M de usuarios) ni muy pocos (no particionar por is_active con solo true/false).
  2. 02.Usado en filtros WHERE: si nadie filtra por esa columna, particionarla no ahorra nada.
  3. 03.Distribución uniforme: cada partición debería tener un tamaño similar. Si el 90% de los datos cae en una sola partición, no has ganado mucho.
  4. 04.Crece con el tiempo: la fecha es ideal porque naturalmente crea nuevas particiones cada día/mes sin rebalanceo.

Mi regla de oro para elegir partición: si el 80% de las queries incluyen "WHERE fecha >=..." o "WHERE year =...", particiona por fecha. Punto. No te compliques. Solo añade un segundo nivel si el volumen diario es enorme (+1GB/día por partición).

### El Small File Problem: cuando particionas demasiado

Aquí viene la trampa que atrapa a todo principiante: si particionas con demasiada granularidad, acabas con miles de archivos diminutos. Y muchos archivos pequeños son PEOR que pocos archivos grandes. ¿Por qué? Porque cada archivo tiene un coste fijo de apertura (overhead de filesystem, handshake con S3, lectura del footer Parquet). Si tienes 100.000 archivos de 1KB cada uno, el overhead de abrirlos es mayor que el tiempo de leer los datos.

La regla de la industria: cada archivo Parquet debería tener entre 128MB y 1GB. Si tus archivos son menores a 100MB, tienes un small file problem. Causas comunes: particionar por hora cuando el volumen es bajo, particionar por múltiples columnas simultáneamente (fecha + país + canal = miles de combinaciones), o escrituras frecuentes que generan un archivo por micro-batch.

Y aquí va el número que convierte el consejo en una decisión. Medido sobre 200.000 registros, leyendo siempre lo mismo (un mes, dos columnas):

1Estrategia Ficheros Tamaño total Leer enero
2───────────────────── ──────── ──────────── ──────────
3Sin particionar 1 1,25 MB 8,6 ms
4Por mes (12 part.) 12 2,01 MB 1,9 ms ← 4,5× más rápido
5Por mes+día (336) 336 3,68 MB 18,8 ms ← ya es PEOR
6Por sensor (900) 900 7,88 MB 764,3 ms ← 89× más lento

Pasarse de particiones es peor que no particionar. Y el disco también se paga: los mismos datos ocupan 6× más repartidos en 900 ficheros.

Además, pyarrow tiene un techo duro: se niega a escribir más de 1.024 particiones en una sola llamada (ArrowInvalid, parámetro max_partitions). O sea que el consejo de "no particiones por sensor_id con 10.000 valores" no es solo una buena práctica — es que la librería no te deja.

He visto un lake con 2 millones de archivos de 50KB cada uno. Las queries que deberían tardar 10 segundos tardaban 20 minutos porque el motor pasaba más tiempo LISTANDO y ABRIENDO archivos que leyendo datos. La solución fue compactar: un job nocturno que fusiona archivos pequeños en otros más grandes.

### Particionado con PyArrow: demo completa

1import pyarrow as pa
2import pyarrow.parquet as pq
3import pyarrow.dataset as ds
4import datetime
5import random
6
7# Generar datos de ventas de 3 meses
8n = 100_000
9fechas = [datetime.date(2024, random.randint(1,3), random.randint(1,28)) for _ in range(n)]
10data = pa.table({
11 "fecha": pa.array(fechas),
12 "producto": pa.array([f"P{random.randint(1,50):03d}" for _ in range(n)]),
13 "importe": pa.array([round(random.uniform(10, 500), 2) for _ in range(n)]),
14 "pais": pa.array([random.choice(["ES","FR","IT","DE"]) for _ in range(n)]),
15})
16
17# Escribir con particionado Hive-style por año y mes
18pq.write_to_dataset(
19 data,
20 root_path="lake/ventas_particionadas",
21 partition_cols=["pais"], # Particionar por país
22)
23
24print("Estructura creada:")
25import os
26for root, dirs, files in os.walk("lake/ventas_particionadas"):
27 level = root.replace("lake/ventas_particionadas", "").count(os.sep)
28 indent = " " * level
29 print(f"{indent}{os.path.basename(root)}/")
30 for f in files:
31 print(f"{indent} {f}")

Escribir datos particionados con PyArrow — genera carpetas Hive-style automáticamente

### Partition pruning en acción

1import pyarrow.dataset as ds
2import time
3
4# Leer TODO el dataset particionado
5start = time.time()
6dataset = ds.dataset("lake/ventas_particionadas", format="parquet", partitioning="hive")
7df_all = dataset.to_table().to_pandas()
8t_all = time.time() - start
9print(f"Todo: {len(df_all):,} filas en {t_all:.3f}s")
10
11# Leer SOLO España (partition pruning)
12start = time.time()
13df_es = dataset.to_table(filter=ds.field("pais") == "ES").to_pandas()
14t_es = time.time() - start
15print(f"Solo ES: {len(df_es):,} filas en {t_es:.3f}s")
16print(f"Pruning saltó {(1 - len(df_es)/len(df_all))*100:.0f}% de los datos")

Partition pruning: el motor solo abre las carpetas que coinciden con el filtro

### Estrategias de particionado por caso de uso

  • Datos de eventos/logs: particionar por fecha (year/month/day). Queries típicas filtran por rango temporal.
  • Datos multi-tenant: particionar por tenant_id (empresa). Cada query solo toca un tenant.
  • Datos geográficos: particionar por país o región. Las queries suelen filtrarse por zona.
  • Datos de sensores IoT: particionar por device_id + fecha. Alto volumen por dispositivo.

Cuando no sepas por qué columna particionar, mira las queries más frecuentes de tu equipo. Si el 80% tiene "WHERE fecha BETWEEN..." ahí está tu respuesta. Los datos te dicen cómo quieren ser organizados.

### Compactación: la solución al small file problem

Si ya tienes un small file problem, la solución es compactar: leer todos los archivos pequeños de una partición y reescribirlos como un archivo grande. Es un job que típicamente se ejecuta diariamente o semanalmente. Con PyArrow es trivial:

1import pyarrow.dataset as ds
2import pyarrow.parquet as pq
3
4def compactar_particion(ruta_particion: str, output: str):
5 """Lee todos los archivos de una partición y los reescribe como uno solo."""
6 dataset = ds.dataset(ruta_particion, format="parquet")
7 table = dataset.to_table()
8
9 # Reescribir como un solo archivo con row_group_size óptimo
10 pq.write_table(
11 table,
12 output,
13 row_group_size=1_000_000, # ~128MB por row group
14 compression="snappy",
15 )
16 print(f"Compactado: {dataset.files.__len__()} archivos → 1 archivo")
17 print(f"Filas: {table.num_rows:,}")
18
19# Ejemplo de uso
20compactar_particion("lake/ventas_particionadas/pais=ES", "lake/compactado/pais=ES/data.parquet")

Compactar archivos pequeños en uno grande — job típico de mantenimiento del lake

## ejercicios

[01]

Particionar ventas por fecha

Tienes un DataFrame con 3 meses de ventas. Escríbelo particionado por año y mes usando PyArrow y verifica la estructura de carpetas.

Cargando editor...
[02]

Diagnosticar el small file problem

Escribe una función que analice un directorio de datos y diagnostique si tiene un small file problem. Debe recomendar compactación si los archivos son demasiado pequeños.

Cargando editor...
[03]

Benchmark de partition pruning

Compara el rendimiento de leer datos particionados con filtro vs sin particionado. Demuestra cuánto ahorra el pruning.

Cargando editor...
[04]

Diseñar estrategia de particionado

Te dan 4 casos de uso con sus queries más comunes. Diseña la estrategia de particionado óptima para cada uno y justifica tu decisión.

💡 Resultado esperado


==================================================
Caso: Logs de microservicios
Partición: fecha → servicio
Cargando editor...

Regístrate para guardar tu progreso.

## comentarios

Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.

Inicia sesión para comentar y responder.

cargando comentarios...