Read in English · Lire en français · Auf Deutsch lesen
Sección 5.4 de 5.8 · Intermedio · 23 min de lectura
Tuberías y filtros y arquitecturas de pipeline
Videolección
5 min · narrado en inglés, con subtítulosSolo escuchar (audio)
Leer la transcripción
Algunos problemas son por naturaleza un flujo: los datos entran, pasan por una serie de pasos y salen. En esta lección aprenderás el estilo pensado para eso, qué limita su velocidad y dónde es débil.
Piensa en un túnel de lavado de vehículos. Cada vehículo pasa por una serie de estaciones: remojo, frotado, enjuague, secado. Cada estación hace un solo trabajo, no sabe nada de las demás y pasa el vehículo a la siguiente por una cinta transportadora. Puedes añadir una estación de encerado sin cambiar las demás. Y si el secado es lento, los vehículos empiezan a hacer cola delante de él. El estilo de tuberías y filtros funciona igual con los datos: las estaciones son los filtros, y la cinta transportadora es la tubería.
Este es un pipeline de la plataforma de reparto de comida. Un productor, o fuente, inicia el flujo: lee eventos de pedido. Un transformador cambia cada registro. Este añade el restaurante y la región. Un tester comprueba una condición y deja pasar cada registro o lo descarta. Aquí descarta los pedidos de prueba. Y un consumidor, o sumidero, termina el flujo escribiendo en el almacén de datos. Entre los filtros, tuberías como esta guardan los registros en un búfer y los llevan en un solo sentido. Las restricciones clave: los filtros no comparten estado, y cada uno solo conoce el formato de lo que lee y escribe, nunca a sus vecinos.
Esas restricciones producen directamente las ventajas. Como un filtro no comparte estado, se puede escribir y probar por separado, con una lista de registros de entrada y las salidas que esperas. Se puede reutilizar en otros pipelines, y se pueden añadir, quitar o reordenar filtros con solo cambiar cómo se monta el pipeline. Y como los registros fluyen de uno en uno, un filtro puede empezar con el primer registro antes de que el anterior haya terminado el último. Las etapas se ejecutan de forma concurrente y solo necesitan memoria para unos pocos registros.
El hogar más conocido del estilo es Unix. En 1964, Doug McIlroy escribió que los programas deberían acoplarse como una manguera de jardín. Ken Thompson añadió las tuberías a Unix en 1973, y la idea de herramientas pequeñas unidas por tuberías hizo famoso el estilo. Hoy es la opción por defecto en ingeniería de datos. Los trabajos de extracción, transformación y carga llevan los pedidos de ayer a un almacén de datos, paso a paso. Un compilador clásico también es un pipeline: los caracteres se convierten en tokens, los tokens en un árbol sintáctico, y luego vienen las comprobaciones, la optimización y el código máquina. El procesamiento de medios y de logs funciona de la misma manera. Y una misma estructura puede ejecutarse por lotes sobre una entrada acotada, o como un flujo que nunca termina.
Veamos ahora un ejemplo práctico. La plataforma carga los eventos de pedido en su almacén de datos a través de cuatro filtros. En un solo worker, Parse procesa cincuenta mil registros por segundo, Enrich ocho mil, Validate cuarenta mil y Load doce mil. Un pipeline no puede ir más rápido que su filtro más lento, así que este funciona a ocho mil registros por segundo. En un día de mucha actividad hay un millón de eventos de pedido. Eso lleva ciento veinticinco segundos. Enrich no guarda estado, así que puedes ejecutar dos copias en paralelo, cada una con la mitad de los registros. Esa etapa procesa ahora dieciséis mil por segundo, y Load, con doce mil, pasa a ser el cuello de botella. Los datos del día tardan ahora unos ochenta y tres segundos: un ahorro del treinta y tres por ciento, no del cincuenta por ciento que sugiere una lectura ingenua. Acelerar cualquier cosa que no sea el cuello de botella no cambia nada, así que mide primero.
Las diferencias de velocidad causan un segundo problema. Sin control, Parse empujaría cincuenta mil registros por segundo hacia una etapa Enrich que solo vacía ocho mil. El búfer crecería en cuarenta y dos mil registros cada segundo, hasta agotar la memoria. La respuesta es la back pressure: hacer que la etapa rápida baje a la velocidad de la lenta. Una tubería de Unix lo hace automáticamente, porque una tubería llena bloquea al escritor. Los errores necesitan una política para cada filtro. Un registro mal formado no debe detener el pipeline: envíalo a una cola de mensajes fallidos con el motivo. Y como los reinicios implican que algunos registros se procesen dos veces, los sumideros deben ser idempotentes.
Usa tuberías y filtros cuando el problema sea por naturaleza un flujo de datos en un solo sentido: ingesta, conversión, enriquecimiento, validación y exportación. También aparece dentro de otros estilos, como un trabajo nocturno de informes en un monolito modular. Evítalo como estructura principal de aplicaciones interactivas, en las que los usuarios esperan respuestas inmediatas, y para lógica en la que muchos pasos comparten y actualizan el mismo estado. Y como los filtros no se conocen entre sí a propósito, un fallo detectado en el paso cuatro no puede deshacer fácilmente el paso dos. Si necesitas una reversión coordinada, probablemente necesitas otro estilo.
La sección completa compara el procesamiento por lotes y el streaming, muestra un pipeline en Unix y en Python, y puntúa las características del estilo. A continuación, en la Sección cinco punto cinco, veremos las arquitecturas de microkernel y de plug-ins. Nos vemos allí.
Lo que necesitas saber
- El estilo de tuberías y filtros organiza el procesamiento como una cadena de filtros independientes conectados por tuberías de un solo sentido; los filtros no comparten estado y no conocen a sus vecinos, solo el formato de los datos que leen y escriben.
- Hay cuatro tipos de filtro: productores (fuentes), transformadores, testers (que dejan pasar o descartan registros) y consumidores (sumideros).
- Doug McIlroy propuso acoplar programas como mangueras de jardín en un memorándum de 1964; Ken Thompson añadió las tuberías a Unix en 1973, y la filosofía Unix de herramientas pequeñas unidas por tuberías hizo famoso el estilo.
- Los usos típicos son los procesos ETL y los pipelines de datos, las fases de un compilador, el procesamiento de medios y el procesamiento de logs; la misma estructura funciona por lotes (entrada acotada) o en streaming (entrada no acotada).
- El rendimiento (throughput) de un pipeline lo fija su filtro más lento, y un productor rápido desbordará a un consumidor lento a menos que los búferes estén acotados y la back pressure (contrapresión) frene al productor.
- El estilo puntúa alto en simplicidad, modularidad y reutilización de filtros, y bajo en interactividad y en el manejo de estado compartido o de una recuperación de errores compleja entre pasos.
Definición y origen
En el estilo de tuberías y filtros, el procesamiento es una cadena de pasos independientes. Cada filtro lee datos de su entrada, hace un solo trabajo con ellos y escribe el resultado en su salida. Cada tubería lleva los datos en un solo sentido de un filtro al siguiente, normalmente con un búfer entre ambos. La descripción de Garlan y Shaw (1994) enuncia las restricciones que dan al estilo sus propiedades: los filtros deben ser independientes, no deben compartir estado con otros filtros y no deben conocer la identidad de los filtros situados a ambos extremos de sus tuberías. Un filtro solo conoce el formato de lo que lee y de lo que escribe.
El hogar más conocido del estilo es Unix. En un memorándum fechado el 11 de octubre de 1964, Doug McIlroy escribió que debería haber formas de acoplar programas como una manguera de jardín, enroscando otro tramo cuando hay que tratar los datos de otra manera. Ken Thompson implementó la llamada al sistema pipe y las tuberías en el shell en la Version 3 de Unix, en 1973, y la notación | llegó en la Version 4. La filosofía Unix que surgió de ahí, escribir programas que hagan una sola cosa bien y trabajen juntos a través de una interfaz universal de texto, es el estilo de tuberías y filtros aplicado al conjunto de herramientas de todo un sistema operativo. POSA (1996) catalogó Pipes and Filters como patrón arquitectónico, y Enterprise Integration Patterns (2003), de Hohpe y Woolf, lo aplicó a la mensajería entre sistemas.
Richards y Ford llaman a este mismo estilo arquitectura de pipeline y distinguen cuatro tipos de filtro. Un productor (o fuente, source) inicia el flujo, leyendo de un archivo, una base de datos o una cola. Un transformador cambia cada registro: lo analiza, lo convierte, lo enriquece. Un tester comprueba una condición y deja pasar el registro o lo descarta, a veces enviándolo a una de varias salidas. Un consumidor (o sumidero, sink) termina el flujo, escribiendo en una base de datos, un archivo o una pantalla.
Filtros y tuberías
Cómo funciona
Las restricciones producen directamente las ventajas. Como un filtro no comparte estado y no conoce a sus vecinos, se puede escribir y probar por separado, con una lista de registros de entrada y una lista esperada de salidas; se puede reutilizar en otros pipelines; y se pueden añadir, quitar o reordenar filtros cambiando solo la forma en que se monta el pipeline. Como los registros fluyen de forma incremental, un filtro puede empezar a trabajar con el primer registro antes de que el filtro anterior haya terminado el último, así que las etapas se ejecutan de forma concurrente y todo el pipeline solo necesita memoria para unos pocos registros a la vez, no para todo el conjunto de datos.
La tubería oculta cómo se mueven los datos. En un shell de Unix es un búfer del sistema operativo entre dos procesos; en Linux admite 64 KiB por defecto, y un escritor que lo llena simplemente se bloquea hasta que el lector se pone al día. En un programa Python puede ser un generador, de modo que cada filtro pide el siguiente registro al anterior. En una plataforma de datos puede ser una cola de mensajes o un topic de un log como Apache Kafka, lo que permite que los filtros se ejecuten en máquinas distintas y sobrevivan a los reinicios. El código de los filtros apenas cambia entre estas opciones; las propiedades operativas cambian muchísimo.
Hay variaciones de la forma estricta. Un pipeline puede abrirse en abanico (fan-out: un tester envía registros a dos consumidores distintos) o converger (fan-in: dos productores alimentan a un transformador). Puede ser lineal, lo más fácil de razonar, o un grafo acíclico dirigido de pasos, como en herramientas de flujos de trabajo como Apache Airflow. En sentido estricto, un filtro con estado interno, como uno que cuenta u ordena, rompe la propiedad incremental, porque debe ver toda la entrada antes de producir salida; sort en un pipeline de Unix es un filtro de ese tipo. Estos filtros están permitidos, pero se convierten en los puntos donde hay que pensar en la memoria, la latencia y los reinicios.
Ejemplos: dónde aparecen los pipelines
ETL y pipelines de datos. Los trabajos de extracción, transformación y carga (ETL) mueven datos de los sistemas operacionales a un almacén de datos: extraer los pedidos de ayer, depurarlos y convertirlos, unirlos con los datos de los restaurantes y cargarlos en tablas de informes. Cada paso es un filtro, y las plataformas de datos modernas (Apache Beam, Spark, modelos de dbt, servicios de flujo de datos en la nube) son, en gran medida, herramientas para construir y ejecutar esos pipelines a escala.
Compiladores. Un compilador clásico es un pipeline de fases: el análisis léxico convierte caracteres en tokens, el análisis sintáctico convierte tokens en un árbol sintáctico, el análisis semántico comprueba tipos y nombres, la optimización reescribe una representación intermedia y la generación de código emite código máquina. Cada fase consume una representación y produce la siguiente, así que las fases se pueden desarrollar y sustituir por separado; el proyecto LLVM, por ejemplo, permite que muchos frontends de lenguajes compartan un optimizador y un conjunto de backends. Los compiladores reales relajan el estilo estricto, porque las fases comparten una tabla de símbolos.
Procesamiento de medios. La transcodificación de video y el procesamiento de audio encadenan decodificadores, escaladores, filtros y codificadores. GStreamer construye aplicaciones multimedia explícitamente como pipelines de elementos enlazados mediante pads, y los grafos de filtros de FFmpeg funcionan de la misma manera.
Procesamiento de logs y eventos. Los enviadores de logs como Logstash y Fluent Bit se configuran como etapas de entrada, filtro y salida: leen líneas de log, las descomponen en campos, descartan el ruido de las comprobaciones de estado (health checks), enmascaran los datos personales y las envían a un índice de búsqueda. Los datos de observabilidad de la plataforma de reparto de comida fluyen de esta manera.
Pipelines de características y de inferencia de aprendizaje automático. La preparación de los datos de entrenamiento, y el preprocesamiento y el posprocesamiento alrededor de un modelo, se construyen habitualmente como pipelines, a los que vuelve el Capítulo 12.
# Top ten URLs that returned HTTP 500 in today's access log
grep '" 500 ' access.log \
| awk '{print $7}' \
| sort \
| uniq -c \
| sort -rn \
| head -10import json
def read_lines(path): # producer
with open(path) as f:
for line in f:
yield line
def parse(lines): # transformer
for line in lines:
yield json.loads(line)
def only_failed(events): # tester
for e in events:
if e["status"] == "payment_failed":
yield e
def count_by_provider(events): # consumer
counts = {}
for e in events:
counts[e["provider"]] = counts.get(e["provider"], 0) + 1
return counts
events = only_failed(parse(read_lines("orders.jsonl")))
print(count_by_provider(events))Por lotes y en streaming
La misma estructura funciona en dos modos. El procesamiento por lotes trata una entrada acotada, como todos los pedidos de ayer, se ejecuta hasta completarse y se detiene. Es fácil de razonar: si una ejecución falla, corriges el problema y la vuelves a ejecutar, siempre que cada ejecución sustituya su salida en lugar de añadirse a ella. Los pagos nocturnos a los restaurantes, las facturas mensuales y las cargas del almacén de datos son pipelines por lotes típicos. Su punto débil es el retraso: los resultados tienen la antigüedad de la última ejecución.
El procesamiento de flujos (stream processing) trata una entrada no acotada y procesa cada registro poco después de que llegue: posiciones de repartidores, eventos de pago, clics. Los resultados están frescos, pero aparecen nuevas preguntas. Un flujo nunca termina, así que los agregados deben calcularse sobre ventanas (pedidos por restaurante cada cinco minutos); los registros pueden llegar tarde o desordenados; y el pipeline debe funcionar de forma continua, recuperarse de las caídas sin perder registros ni contarlos dos veces, y aguantar cuando la tasa de entrada se dispara. Frameworks como Kafka Streams, Apache Flink y Apache Beam ofrecen ventanas, checkpoints y almacenes de estado para estos problemas. El modelo de Beam trata el procesamiento por lotes como un caso especial del streaming, con una entrada acotada, así que una misma definición de pipeline puede ejecutarse de las dos formas.
Muchas organizaciones usan ambos: un flujo para paneles operativos frescos y aproximados, y un trabajo por lotes que recalcula las cifras exactas durante la noche. La Sección 7.8 analiza el lado de arquitectura de datos de esta elección.
Ejemplo práctico
Ejemplo práctico: encontrar el cuello de botella
La plataforma de reparto de comida carga los eventos de pedido en su almacén de datos a través de cuatro filtros. Medido en un solo worker, cada filtro puede procesar por sí solo el siguiente número de registros por segundo.
| Capacidades | Parse 50 000/s; Enrich (busca el restaurante y la región) 8000/s; Validate 40 000/s; Load 12 000/s. |
|---|---|
| Rendimiento (throughput) del pipeline | Un pipeline no puede ir más rápido que su filtro más lento: min(50 000, 8000, 40 000, 12 000) = 8000 registros por segundo. |
| Tiempo para los datos de un día | En un día de mucha actividad, la plataforma genera 1 000 000 de eventos de pedido. 1 000 000 ÷ 8000 = 125 segundos. |
| Escalar el cuello de botella | Enrich no tiene estado, así que pueden ejecutarse dos instancias en paralelo, cada una con la mitad de los registros: 2 × 8000 = 16 000/s en esa etapa. |
| Nuevo throughput | min(50 000, 16 000, 40 000, 12 000) = 12 000/s. Ahora el cuello de botella es Load. 1 000 000 ÷ 12 000 ≈ 83 segundos, un ahorro del 33% y no del 50% que sugeriría una lectura ingenua. |
| Qué hacen los búferes | Sin back pressure, Parse metería registros en la tubería previa a Enrich a 50 000/s mientras Enrich vacía 8000/s, así que el búfer crecería en 42 000 registros por segundo hasta agotar la memoria. |
Acelerar cualquier filtro que no sea el cuello de botella no cambia nada; mide primero.
Solo los filtros sin estado se pueden paralelizar de forma tan sencilla. Un filtro que mantiene estado, como los totales acumulados por restaurante, debe particionar su entrada por clave para que cada instancia vea todos los registros de sus claves.
Es el mismo razonamiento que la ley de Amdahl y la utilización de la Sección 2.4: la etapa en serie más lenta fija el límite.
Escalar un pipeline desplaza el cuello de botella
Back pressure y gestión de errores
Cuando un productor es más rápido que un consumidor, algo tiene que ceder. Las opciones son usar un búfer (lo que solo aplaza el problema, a menos que el búfer esté acotado), descartar registros o tomar muestras (aceptable para algunas métricas, nunca para pagos), o aplicar back pressure (contrapresión): hacer que la etapa rápida baje a la velocidad de la lenta. Las tuberías de Unix lo hacen automáticamente, porque una tubería llena bloquea al escritor. Los diseños basados en extracción (pull), como los generadores de Python, lo hacen por construcción, porque no se produce nada hasta que la siguiente etapa lo pide. Los pipelines distribuidos necesitan que se diseñe expresamente: colas acotadas, consumidores que extraen de un log a su propio ritmo, o protocolos como la especificación Reactive Streams de la JVM, en la que un suscriptor indica cuántos registros está listo para recibir. La Sección 9.3 trata a fondo las colas y la back pressure.
Los errores necesitan una política para cada filtro, decidida de antemano. Un registro mal formado (un mensaje envenenado, poison message) no debe detener el pipeline: envíalo a una cola de mensajes fallidos (dead-letter queue) o a un archivo de errores con el motivo, cuéntalo y lanza una alerta si la tasa aumenta. Un fallo transitorio, como que un servicio de consulta agote su tiempo de espera, se reintenta con backoff. Una caída del pipeline necesita un punto de reinicio: los trabajos por lotes se vuelven a ejecutar desde el principio o desde un checkpoint, y los frameworks de streaming guardan en checkpoints su posición y su estado. Como los reinicios implican que algunos registros se procesen dos veces, los consumidores que escriben resultados deben ser idempotentes, por ejemplo haciendo un upsert sobre una clave natural en lugar de insertar a ciegas. Los errores son el punto más débil del estilo: los filtros no se conocen entre sí a propósito, así que un fallo descubierto en el paso cuatro no puede deshacer fácilmente lo que ya hizo el paso dos, y un pipeline que necesite ese tipo de reversión coordinada probablemente usa el estilo equivocado.
Características de tuberías y filtros (despliegue único)
| Característica | Valoración | Motivo |
|---|---|---|
| Sencillez | Alto | Cada filtro hace una sola cosa; el flujo es fácil de dibujar, leer y explicar. |
| Modularidad y reutilización | Alto | Los filtros no comparten estado y solo conocen formatos de datos, así que se pueden reutilizar, reordenar y sustituir. |
| Capacidad de prueba (testabilidad) | Alto | Un filtro se prueba con una entrada de ejemplo y una salida esperada, sin sus vecinos. |
| Costo total | Alta (barato) | Un solo programa o trabajo; poca infraestructura, salvo si es distribuido. |
| Rendimiento (throughput) | De media a alta | Las etapas se ejecutan de forma concurrente y las que no tienen estado se paralelizan, pero el filtro más lento lo limita todo. |
| Latencia e interactividad | Bajo | Pensado para flujos de datos, no para conversaciones de solicitud y respuesta con usuarios; cada etapa añade retraso. |
| Desplegabilidad | De baja a media | Como un solo programa o trabajo, cualquier cambio vuelve a desplegar todo el pipeline; los filtros distribuidos sobre colas se pueden lanzar por separado. |
| Tolerancia a fallos | De baja a media | En un solo proceso, una caída detiene todo el pipeline; hay que añadir checkpoints, reintentos y colas de mensajes fallidos. |
| Estado compartido y transacciones | Bajo | Los filtros son deliberadamente independientes y sin estado; la consistencia o la reversión entre pasos es complicada. |
| Escalabilidad | De baja a media en un solo proceso | Mayor cuando los filtros se distribuyen sobre colas, a costa de la operación y de las garantías de entrega. |
Cuándo usarlo, cuándo no y antipatrones
Usa tuberías y filtros cuando el problema sea por naturaleza un flujo de datos en un solo sentido a través de transformaciones distintas: ingesta, conversión, enriquecimiento, validación, agregación, exportación. Es la opción por defecto en ingeniería de datos, en el procesamiento de archivos y medios, y en cualquier trabajo en el que los pasos cambien más a menudo que el flujo general. También aparece dentro de otros estilos: un trabajo nocturno de informes dentro de un monolito modular, o un servicio de procesamiento de flujos dentro de un sistema dirigido por eventos.
Evítalo como estructura principal de aplicaciones interactivas, en las que los usuarios esperan respuestas inmediatas y conversacionales y el trabajo no es un flujo; para lógica en la que muchos pasos necesitan compartir y actualizar el mismo estado; y para procesos que necesitan una reversión coordinada entre pasos, que requieren una transacción o una saga.
Antipatrones habituales: el filtro dios, un paso que analiza, enriquece, valida y escribe, con lo que se pierden las ventajas del estilo; el estado compartido oculto, filtros que se comunican a través de una tabla de base de datos compartida o una variable global en lugar de la tubería, lo que reintroduce el acoplamiento que el estilo elimina; la deriva de formato, un productor que cambia sin avisar su formato de salida y rompe todos los filtros posteriores, algo que se evita con esquemas explícitos y comprobaciones de esquema (consulta la Sección 7.7); los búferes no acotados, que convierten a un consumidor lento en una caída por falta de memoria; y los sumideros no idempotentes, que cuentan dos veces después de cada reintento.
Hacer cumplir el estilo en el código
Las restricciones del estilo se pueden hacer explícitas. Da a cada filtro la misma firma sencilla (entra un iterable de registros, sale un iterable), para que la composición sea mecánica. Define el formato de los registros en cada tubería como un esquema (una dataclass, un JSON Schema, una definición de Avro o de Protocol Buffers) y valídalo en el límite. Mantén los filtros libres de estado mutable compartido: una prueba de arquitectura puede prohibir que los módulos de filtros importen la capa de base de datos o se importen entre sí, de modo que la única forma de que los datos pasen entre ellos sea la tubería. Prueba cada filtro con pequeños datos de prueba (fixtures) de entrada y salida, y prueba el pipeline montado de extremo a extremo con una muestra representativa, que incluya registros mal formados, para demostrar que la política de errores funciona.
En los pipelines distribuidos, monitoriza el throughput por etapa, la profundidad de las colas y el retraso de los consumidores (consumer lag): una cola que crece delante de una etapa es la señal visible de un cuello de botella o de un filtro que falla, y es la primera métrica sobre la que alertar.
Comprueba lo que has entendido
¿Qué tres restricciones impusieron Garlan y Shaw a los filtros?
Los filtros deben ser entidades independientes, no deben compartir estado con otros filtros y no deben conocer la identidad de los filtros situados antes o después de ellos.
¿Por qué el comando sort de Unix rompe la propiedad incremental de un pipeline?
Debe leer toda su entrada antes de poder producir la primera línea, así que retiene todo el conjunto de datos y retrasa todo lo que viene después hasta que termina su entrada.
Un pipeline tiene etapas que pueden procesar 20 000, 5000 y 15 000 registros por segundo. ¿Cuál es su throughput y qué ocurre si se duplica la etapa intermedia?
5000 por segundo, fijado por la etapa más lenta. Duplicarla hasta 10 000 hace que el pipeline funcione a 10 000 por segundo, todavía por debajo de las otras dos etapas.
¿Por qué el consumidor final de un pipeline que se puede reiniciar debe ser idempotente?
Porque un reinicio o un reintento puede entregar algunos registros dos veces; una escritura idempotente, como un upsert sobre una clave natural, da el mismo resultado por muchas veces que llegue un registro.
Fuentes y lecturas recomendadas
¿Tienes una pregunta sobre esta sección? ArchitectBot, el asistente de estudio con IA del curso, responde primero a partir del curso.
Pregunta: “Nuestro trabajo ETL nocturno tarda cuatro horas y queremos que tarde menos de una. ¿Por dónde deberíamos empezar a buscar?”