StackPractices
intermediate Por Mathias Paulenko

Patrones de Procesamiento por Lotes

Diseña pipelines robustos de procesamiento por lotes para grandes volúmenes de datos con reintentos, idempotencia y observabilidad.

Visión General

La mayoría de pipelines de datos, trabajos ETL y reportes nocturnos siguen funcionando por lotes. Trabajar sobre chunks acotados en lugar de un stream interminable mantiene la lógica fácil de razonar — a cambio hay que pensar con detalle en idempotencia, tolerancia a fallos y observabilidad.

Cuándo Usar

Recurre a estos patrones cuando:

  • Procesas datasets grandes que no caben en memoria. Consulta Retry Logic para manejar fallos transitorios.
  • Construyes pipelines ETL para data warehouses
  • Generas reportes o agregaciones nocturnas
  • Migras datos entre sistemas con ventanas de mantenimiento

Descarta el procesamiento por lotes cuando los eventos necesitan latencia inferior al minuto, el dataset cabe holgadamente en memoria o el trabajo llega como un stream continuo: para esos casos encaja mejor un framework de streaming o una cola de mensajes.

Solución

Pipeline Resiliente de Procesamiento por Lotes (Python)

import logging
from typing import Callable, List, Iterator

class BatchProcessor:
    def __init__(self, batch_size: int = 1000, max_retries: int = 3):
        self.batch_size = batch_size
        self.max_retries = max_retries
        self.processed = 0
        self.failed = []

    def process(
        self,
        items: Iterator[dict],
        handler: Callable[[List[dict]], None]
    ) -> dict:
        batch = []
        for item in items:
            batch.append(item)
            if len(batch) >= self.batch_size:
                self._execute(batch, handler)
                batch = []

        if batch:
            self._execute(batch, handler)

        return {"processed": self.processed, "failed": len(self.failed)}

    def _execute(self, batch: List[dict], handler: Callable):
        for attempt in range(self.max_retries):
            try:
                handler(batch)
                self.processed += len(batch)
                return
            except Exception as e:
                logging.warning(f"Batch fallido (intento {attempt + 1}): {e}")
                if attempt == self.max_retries - 1:
                    self.failed.extend(batch)

Seguimiento Idempotente de Trabajos (SQL)

CREATE TABLE job_runs (
    job_id VARCHAR(64) PRIMARY KEY,
    started_at TIMESTAMP NOT NULL DEFAULT NOW(),
    completed_at TIMESTAMP,
    status VARCHAR(20) CHECK (status IN ('running', 'completed', 'failed')),
    checksum VARCHAR(64)
);

-- Antes de comenzar, verifica si ya está completado
SELECT * FROM job_runs WHERE job_id = 'daily_report_2025_01_15' AND status = 'completed';

Explicación

Antes de confiar un pipeline por lotes a producción, asegúrate de que cumple tres propiedades:

  1. Idempotencia: el mismo trabajo, ejecutado dos veces, tiene que llegar al mismo resultado. Usa IDs de trabajo y checksums para saltarte el trabajo ya procesado. Consulta Endpoints Idempotentes para patrones de deduplicación.
  2. Tolerancia a fallos: un lote malo no debería tumbar todo el trabajo. Implementa reintentos con backoff exponencial y una dead-letter queue.
  3. Observabilidad: necesitas ver progreso, throughput y errores mientras corre el trabajo, así que emite métricas de ítems procesados, latencia y tasa de fallos — la receta de logging estructurado cubre el formato de log.

Estrategia de chunking: el tamaño del lote es un equilibrio entre memoria y throughput — demasiado pequeño y el overhead domina, demasiado grande y coquetea con el OOM.

flowchart diagram: Datos de origen

Variantes

PatrónCaso de usoCompromiso
Procesamiento por chunksArchivos grandes, límites de memoriaMás simple, mayor latencia
Workers paralelosTransformaciones CPU-boundComplejo, necesita coordinación
MapReduceAgregación distribuidaEscala horizontalmente
Change Data CaptureSincronización incrementalRequiere soporte de la fuente

Tamaño de los Lotes y Control del Backpressure

Empieza con 100-1000 ítems por lote y haz benchmarks con datos reales. Para inserts en base de datos, 500-2000 filas por lote balancean throughput y tamaño de transacción; para procesamiento de archivos, 10-50 MB por chunk evita OOM en contenedores de 512 MB. Mide la memoria con resource.getrusage(resource.RUSAGE_SELF).ru_maxrss en Python. Las APIs limitadas por red toleran lotes más grandes (500-5000) que amortizan la latencia por request, mientras que las transformaciones CPU-bound rinden mejor con lotes pequeños (100-500) que se paralelizan bien entre workers.

Cuando el sistema de destino no da abasto, regula al productor en lugar de dejar crecer la cola. Un semáforo limita las escrituras de lotes en vuelo:

from threading import Semaphore

rate_limiter = Semaphore(10)  # máximo 10 escrituras de lote concurrentes

El tamaño del pool de conexiones ya acota la concurrencia de escritura en base de datos. En Celery, usa worker_prefetch_multiplier = 1 para que los workers no descarguen trabajo de más; en consumers de Kafka, ajusta max.poll.records. Vigila la latencia del destino: cuando supere aproximadamente 2 veces la línea base, reduce el tamaño del lote o pausa el productor.

Particionado y data skew

Divide los trabajos grandes por una clave de partición para que los workers corran en paralelo: por fecha (df['created_at'].dt.strftime('%Y-%m-%d')), por hash (df['id'] % num_partitions) o por rango (WHERE id BETWEEN 0 AND 99999). Los layouts estilo Hive (data/year=2025/month=01/day=15/data.parquet) funcionan con Spark, Presto y Athena, y el particionado declarativo de PostgreSQL (CREATE TABLE orders PARTITION BY RANGE (created_at)) te da la misma división dentro de la base de datos.

El skew aparece cuando una partición eclipsa a las demás: compruébalo con Counter(df['partition_key']).most_common(10); si la mayor es 10 veces la mediana, tienes un straggler. Añade sal a la clave caliente (key + '_' + str(random.randint(0, 9))), procesa las subparticiones de forma independiente y combina los resultados al final.

Fiabilidad: Reintentos, Checkpoints y Dead Letters

Reintentos. Vuelve a intentar primero el lote completo; baja al reintento por ítem solo para los registros que siguen fallando. El backoff exponencial con jitter evita que todos los reintentos lleguen a la vez, y tenacity resuelve ambas cosas limpiamente:

import random
from tenacity import retry, stop_after_attempt, wait_exponential, wait_random

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=4, max=60) + wait_random(0, 2)
)
def process_batch(batch):
    ...

En Celery, usa autoretry_for = (ConnectionError,) con retry_backoff = True en la tarea. Registra cada reintento con el ID del lote para que el equipo pueda distinguir ruido transitorio de una dependencia que se está cayendo.

Checkpoints. Un trabajo de seis horas que falla a las 5:55 debería reanudar, no reiniciar. Persiste el progreso tras cada lote:

CREATE TABLE job_checkpoints (
    job_id VARCHAR(64),
    batch_number INT,
    status VARCHAR(20),
    PRIMARY KEY (job_id, batch_number)
);

Al reiniciar, ejecuta SELECT MAX(batch_number) FROM job_checkpoints WHERE job_id = 'daily_etl' AND status = 'completed' y reanuda desde el siguiente lote. Los checkpoints en archivo también funcionan: escribe un JSON en S3 o en disco local tras cada lote y usa renombrado atómico (escribe en un archivo temporal y luego os.replace) para que un fallo a mitad de escritura no corrompa el checkpoint. Los trabajos distribuidos pueden coordinar checkpoints con Redis o etcd.

Dead letters. Aísla los ítems que fallan de forma permanente para inspeccionarlos, en lugar de descartarlos o tumbar la ejecución:

CREATE TABLE dead_letters (
    id SERIAL PRIMARY KEY,
    job_id VARCHAR(64),
    item JSONB,
    error TEXT,
    created_at TIMESTAMP DEFAULT NOW()
);

Guarda el ítem, el error y el ID del lote, y reprocésalos con un trabajo separado cada hora una vez corregida la causa raíz. AWS SQS lo hace de forma nativa con RedrivePolicy y maxReceiveCount; en Celery, enruta los fallos a un exchange DLQ dedicado.

Cargas Incrementales y Evolución del Esquema

Procesa solo lo que cambió desde la última ejecución guardando una marca de agua (high-water mark):

CREATE TABLE job_watermarks (
    job_id VARCHAR(64) PRIMARY KEY,
    last_processed_id BIGINT,
    last_processed_at TIMESTAMP
);

-- Siguiente ejecución: solo filas nuevas
SELECT * FROM orders
WHERE id > (SELECT last_processed_id FROM job_watermarks WHERE job_id = 'orders_etl')
ORDER BY id LIMIT 10000;

Las marcas basadas en timestamp (updated_at > last_processed_at) sirven cuando las filas se actualizan in situ, pero se pierden los borrados físicos: la replicación lógica los captura (PostgreSQL pgoutput, binlog de MySQL vía Debezium).

Los esquemas también cambian a mitad de pipeline. Añade una columna schema_version (ALTER TABLE orders ADD COLUMN schema_version INT DEFAULT 1), haz que el trabajo bifurque según su valor (transform_v2(record) if record['schema_version'] == 2 else transform_v1(record)) y ejecuta ambas versiones en paralelo hasta verificar la migración. Los equipos con Avro o Protobuf obtienen la misma garantía de un schema registry con evolución retrocompatible; los pipelines JSON pueden versionar esquemas por $id y validar con JSON Schema.

Calidad de Datos, Validación y Reconciliación

Ejecuta la validación antes de que los datos lleguen al destino, no después. Great Expectations cubre las comprobaciones habituales en pocas líneas:

batch.expect_column_values_to_not_be_null("id")
batch.expect_column_values_to_be_unique("id")
batch.expect_column_values_to_be_between("amount", 0, 10000)

Pon en cuarentena las filas que fallen (INSERT INTO quarantine SELECT * FROM staging WHERE amount IS NULL OR amount < 0) y emite un reporte por lote — {"batch_id": 42, "total": 1000, "valid": 995, "invalid": 5} — con una alerta cuando la tasa de inválidos supere el 1%.

Reconcilia la salida contra el origen para detectar pérdidas silenciosas. Compara el conteo de filas por lote y, para datos financieros, las sumas:

SELECT (SELECT COUNT(*) FROM source.orders WHERE date = '2025-01-15')
     - (SELECT COUNT(*) FROM target.orders WHERE date = '2025-01-15') AS discrepancy;

Persiste los resultados en una tabla reconciliation_results y alerta sobre cualquier varianza distinta de cero en cuestión de minutos: un trabajo nocturno que pierde un 2% de filas durante un mes es mucho más difícil de reconstruir que uno detectado la misma noche.

Orquestación y Programación

Un cron job en un solo nodo basta para pipelines simples: añade flock para que ejecuciones solapadas no se corrompan entre sí:

flock -n /tmp/batch.lock python batch_job.py

En Kubernetes, CronJob con concurrencyPolicy: Forbid te da la misma garantía. El trabajo distribuido pertenece a una cola de verdad: Celery/Redis ofrece enrutado de tareas, reintentos y gestión de dead letters, mientras que AWS Step Functions y Google Cloud Workflows proporcionan máquinas de estado gestionadas sin mantener brokers.

Airflow orquesta DAGs de lotes con varios pasos:

with DAG(
    "daily_etl",
    schedule="@daily",
    default_args={"retries": 3, "retry_delay": timedelta(minutes=5)},
) as dag:
    process_task = PythonOperator(task_id="process", python_callable=process_batch)

Define sla para alertas de plazo, usa Pools para limitar la concurrencia entre DAGs y mapea tareas sobre particiones con TaskGroup y expand. En serverless, mantén cada invocación dentro del timeout (15 minutos en Lambda), devuelve ReportBatchItemFailures para que SQS reencole solo los ítems fallidos y encadena los lotes a través de la cola en lugar de ejecutar un único trabajo largo.

Archivos Más Grandes que la Memoria

Procesa los archivos fila a fila en streaming en lugar de cargarlos enteros. csv.DictReader combina bien con more_itertools.chunked:

import csv
from more_itertools import chunked

with open("large.csv") as f:
    for batch in chunked(csv.DictReader(f), 1000):
        process(batch)

Pandas expone el mismo patrón con pd.read_csv("large.csv", chunksize=10000), y Node.js hace streaming con csv-parse. Los archivos binarios o de registros de tamaño fijo piden otra herramienta: mmap mapea el archivo en memoria virtual y el sistema operativo pagina los datos bajo demanda en lugar de cargarlo entero:

import mmap

with open("large.bin", "r+b") as f:
    mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
    for i in range(0, len(mm), 65536):
        process(mm[i:i + 65536])
    mm.close()

Para archivos de más de 10 GB, usa split -l 1000000 large.csv chunk_ y procesa las partes en paralelo entre varios workers.

Lo que funciona

  • Diseña para idempotencia: cada trabajo debe poder reintentarse con seguridad
  • Registra todo: inicio del trabajo, fin y resultado de cada lote
  • Usa transacciones: envuelve las escrituras por lotes en transacciones de base de datos
  • Monitoriza la profundidad de cola: alerta cuando los lotes pendientes superen umbrales
  • Implementa circuit breakers: deja de reintentar si el destino está caído

Errores Comunes

  1. No manejar fallos parciales: un lote de 1000 donde falla 1 necesita reintento individual
  2. Ignorar los límites de memoria: cargar datasets enteros en RAM tumba el proceso
  3. Falta de checkpointing: un trabajo de 6 horas que falla a las 5:55 debe reiniciar desde cero
  4. Pérdida silenciosa de datos: errores registrados en logs pero invisibles para el equipo
  5. Sin estrategia de rollback: los trabajos fallidos dejan la base de datos en estado inconsistente

Solución de Problemas

  • La salida del pipeline no coincide con lo esperado: valida los esquemas de entrada, los estados intermedios y el conteo de filas en cada paso.
  • La calidad de datos se degrada con el tiempo: añade validaciones y detección de anomalías; define SLIs de frescura, completitud y exactitud.
  • El trabajo falla de forma intermitente: busca condiciones de carrera, dependencias externas y contención de recursos; reintenta con idempotencia y backoff acotado.
  • Los cambios de esquema rompen a los consumidores: usa schema registries y evolución retrocompatible.
  • Los costes de almacenamiento crecen sin esperarlo: audita la retención de particiones, la compresión y las copias duplicadas; archiva los datos fríos y define políticas de ciclo de vida.

Lectura Adicional

Ver También

Preguntas frecuentes

¿Pueden los trabajos por lotes garantizar procesamiento exactly-once?

Exactly-once requiere escrituras idempotentes más checkpoints transaccionales. Escribe la salida del lote y el checkpoint en una sola transacción: BEGIN; INSERT INTO results SELECT * FROM staging; INSERT INTO job_checkpoints VALUES ('job_1', 42, 'completed'); COMMIT; — un fallo revierte ambas cosas y el lote se reintenta limpiamente. En base de datos, INSERT ... ON CONFLICT DO NOTHING absorbe lotes duplicados; para APIs externas, envía una cabecera Idempotency-Key por lote. Los pipelines de Kafka necesitan enable.idempotence=true y transactional.id en el producer, más isolation.level=read_committed en el consumer. Exactly-once tiene un coste real: mide si at-least-once con escrituras idempotentes ya cubre tu caso.

¿Cómo pruebo la corrección de los trabajos por lotes?

Crea fixtures deterministas (test_data = [{'id': i, 'value': i * 2} for i in range(10000)]) y prueba cada propiedad: ejecuta el trabajo dos veces y verifica salida idéntica para la idempotencia; inyecta un handler que falle (raise Exception('simulated failure') pasado un umbral) y comprueba los reintentos y el registro de fallos; mata el trabajo a mitad de ejecución y confirma que reanuda desde el último checkpoint. El property-based testing con Hypothesis (@given(st.lists(st.dictionaries(st.text(), st.integers())))) genera casos límite que nunca escribirías a mano, y testcontainers levanta una base de datos real para trabajos basados en SQL.

¿Cuál es la mejor forma de deduplicar registros en un trabajo por lotes?

Los duplicados exactos se resuelven con DISTINCT en SQL. Los difusos necesitan blocking más matching: agrupa los registros por una clave compartida (los últimos 4 dígitos de un teléfono, por ejemplo) y compara dentro de cada bloque con distancia de Levenshtein o similitud de Jaccard. La librería dedupe lo automatiza en Python; datasketch ofrece MinHash LSH para datasets demasiado grandes para comparación por pares. Procesa por lotes para acotar la memoria: deduplica 10.000 registros cada vez y combínalos con los resultados anteriores.

¿Cómo proceso por lotes datos de series temporales que llegan tarde?

Agrupa los registros en ventanas fijas con SELECT date_trunc('hour', timestamp) AS window, COUNT(*) FROM events GROUP BY 1 y define un watermark (watermark = current_time - 5 minutes) para que cada ventana solo se procese cuando pase el margen de llegadas tardías. Particiona la retención por tiempo (FOR VALUES FROM ('2025-01-01') TO ('2025-02-01')) y elimina particiones viejas en lugar de borrar filas. Para el downsampling, agrega los datos crudos en tablas resumen de 1 minuto, 1 hora y 1 día en trabajos por lotes separados.

¿Cómo combino datos de múltiples fuentes en un solo trabajo por lotes?

Prepara cada fuente en su propia tabla de staging y combínalas con reglas de prioridad explícitas:

INSERT INTO merged
SELECT COALESCE(a.id, b.id),
       COALESCE(a.email, b.email),
       GREATEST(a.updated_at, b.updated_at)
FROM staging_source_a a
FULL OUTER JOIN staging_source_b b ON a.id = b.id;

Usa CASE WHEN a.updated_at > b.updated_at THEN a.email ELSE b.email END cuando la recencia decida los conflictos, guarda una marca de agua por fuente en una tabla source_watermarks y mantén una tabla field_mapping para las diferencias de esquema entre fuentes.

¿Debería versionar la salida de los lotes y cómo?

Sí: el versionado es lo que hace barato el rollback. La vía simple es una columna batch_version (ALTER TABLE orders ADD COLUMN batch_version INT DEFAULT 1), de modo que DELETE FROM orders WHERE batch_version = 42 deshace una ejecución mala y WHERE batch_version = 41 permite consultar el pasado. Los formatos lakehouse lo traen de serie: Delta Lake soporta versionAsOf y RESTORE, Apache Iceberg guarda historial por snapshot, y la extensión temporal_tables de PostgreSQL mantiene el historial completo de filas para consultas FOR SYSTEM_TIME AS OF.

¿Qué es un data contract y cómo lo aplico en un trabajo por lotes?

Un data contract es el esquema que ambas partes — productor y consumidor — acuerdan respetar. Aplícalo validando cada lote entrante contra el esquema del contrato antes de cargarlo: jsonschema.validate(batch_data, schema) para pipelines JSON, o Protobuf/Avro para contratos tipados con schema registry (POST /subjects/orders-value/versions). Ante una violación, falla de forma visible y regístrala (CREATE TABLE contract_violations (contract_id INT, batch_id INT, error_message TEXT, violated_at TIMESTAMP)); ante cambios incompatibles, incrementa la versión del contrato y avisa a los consumidores antes de desplegar.

¿Cómo enmascaro datos PII durante el procesamiento por lotes para GDPR o CCPA?

Aplica el enmascarado dentro del paso de transformación, no en el momento de lectura:

def mask_email(email: str) -> str:
    local, domain = email.split('@')
    return local[:2] + '***@' + domain

masked = [{**r, 'email': mask_email(r['email'])} for r in batch]

Tokeniza (hash(email + salt)) en lugar de enmascarar cuando necesites integridad referencial entre tablas. Las extensiones de anonimización y las políticas de masking de PostgreSQL llevan la misma lógica a la capa de base de datos, y pgaudit registra quién accedió a datos sin enmascarar.

¿Cómo cifro datos sensibles durante el procesamiento por lotes?

Para cifrado a nivel de columna en PostgreSQL, pgcrypto lo resuelve: INSERT INTO orders (id, ssn) VALUES (1, pgp_sym_encrypt('123-45-6789', key)). Para archivos y payloads encaja el paquete cryptography de Python (cifrado simétrico Fernet); los objetos en S3 usan --sse aws:kms. Guarda las claves en un almacén gestionado (AWS KMS, HashiCorp Vault), rótalas de forma periódica, usa claves distintas por entorno y nunca registres en logs ni valores descifrados ni las propias claves.

¿Cómo monitorizo el cumplimiento de SLAs en trabajos por lotes?

Define las expectativas por trabajo en una tabla job_slas (job_id, max_duration_minutes, alert_threshold_minutes), registra cada ejecución en job_executions y calcula el cumplimiento directamente:

SELECT j.job_id,
       EXTRACT(EPOCH FROM (j.completed_at - j.started_at))/60 AS actual_minutes,
       s.max_duration_minutes
FROM job_executions j
JOIN job_slas s ON j.job_id = s.job_id;

Mide la frescura por separado — EXTRACT(EPOCH FROM (NOW() - MAX(updated_at)))/3600 te dice cuánto lleva desactualizada la tabla destino — y alerta cuando una ejecución supere su umbral o la frescura se pase del SLA.