En los últimos años, el procesamiento del dato con baja latencia, prácticamente en tiempo real, se está convirtiendo en un requisito cada vez más demandado por las empresas en sus procesos Big Data. Es en este contexto donde se introduce el concepto de procesamiento en flujo o stream processing, el cual hace referencia al conjunto de metodologías utilizadas para procesar datos de forma continua para alcanzar bajas latencias en el momento en el que disponemos del dato.
En este post, vamos a profundizar en Structured Streaming, la API de alto nivel que nos proporciona Apache Spark para el procesamiento de flujos de datos masivos en prácticamente tiempo real. Para esto, veremos cuáles son las posibilidades y las limitaciones de esta API, ahondando un poco en sus conceptos básicos y dando las herramientas teóricas necesarias para que puedas plantear la implementación de tu primera ETL en streaming.
Profundizando en Structured Streaming
Structured Streaming está entre nosotros desde la salida de la versión 2.2 de Spark y nos provee una interfaz a alto nivel production-ready para definir nuevos pipelines y adaptar los ya existentes al procesamiento en streaming dentro del marco de trabajo de Apache Spark.
Uno de sus puntos fuertes reside en que hace uso de las mismas operaciones que utilizamos para el procesamiento del dato en batch clásico, haciendo muy transparente y sencilla la transición a un contexto en streaming de procesos batch previamente desarrollados, y permitiéndonos extraer todo el valor que nos ofrece la computación en streaming sin necesidad de añadir o modificar grandes cantidades de código. Esto permite utilizar prácticamente el mismo código para procesar el dato en batches y en stream, lo que facilita el desarrollo de este tipo de aplicaciones al no tener que mantener dos versiones diferentes del código para un mismo pipeline de procesamiento en dos contextos tan ligados.
Antes de esta API, Apache Spark ya disponía de herramientas para dar soporte al procesamiento de datos en streaming gracias a su interfaz Spark Streaming y a su API DStreams.
Este tipo de herramientas han sido utilizadas por múltiples organizaciones para mover sus aplicaciones a un contexto en tiempo real, permitiendo integrar Spark con este paradigma de tratamiento del dato continuo. Sin embargo, estas interfaces están basadas en operaciones y conceptos de relativo bajo nivel, los cuales dificultan el desarrollo y la adaptación a este marco de trabajo, además de complicar la optimización de los pipelines de procesamiento a alto nivel.
Es justo por esto que se plantea la necesidad de una interfaz estrechamente adaptada a la API de DataFrames y Datasets de Spark, permitiendo una integración más sencilla y más optimizada para el desarrollo de aplicaciones en streaming. A diferencia de lo que ocurre con DStreams, una vez implementamos un pipeline de procesamiento para flujos de datos, Structured Streaming se encarga de forma transparente de optimizar todo el procedimiento por debajo según las transformaciones realizadas, de la misma forma que Spark optimiza los planes de ejecución.
Conceptos clave de Structured Streaming
Una vez tenemos una introducción a Structured Streaming en Spark y cuáles son los casos de uso que lo motivan, vamos a repasar los conceptos principales sobre los que se basa, analizando qué cabos hay que atar para poder utilizar esta API en la práctica.
Fuentes de datos de entrada
Podemos definir una fuente de datos de entrada como una abstracción que representa a una entidad que provee un flujo continuo de datos a lo largo del tiempo que puede ser consumido por nuestro proceso. A continuación, se presentan algunas fuentes de datos de entrada disponibles con Structured Streaming:
- Ficheros: Structured Streaming permite ingestar directamente dato de ficheros en un sistema de almacenamiento de archivos, procesando como un flujo de datos aquellos ficheros que sean añadidos o modificados bajo un directorio concreto. Entre los formatos de ficheros a los que Structured Streaming da soporte, podemos observar los más habituales como JSON, CSV, ORC o Parquet.
- Apache Kafka: Esta tecnología es ampliamente conocida por proveer una cola de mensajes distribuida que es capaz de desacoplar aquellas entidades que producen datos de aquellas otras que lo consumen. Dada su relevancia en el mundo del dato en streaming, Structured Streaming provee de una interfaz para consumir un flujo de eventos proveniente de una cola de Kafka.
- Socket TCP: Otra posibilidad reside en consumir un flujo de datos basado en texto conectándose con un servidor TCP mediante un socket. Para poder utilizar esta alternativa es necesario que el flujo de datos esté codificado en UTF-8.
Transformaciones del dato
Como hemos comentado previamente, las transformaciones en un contexto en streaming con Spark son prácticamente idénticas a las transformaciones que llevaríamos a cabo en un contexto en batch, ya que la API de Spark se encarga de forma interna de manejar el dato y aplicar las transformaciones correspondientes independientemente de si se está trabajando en streaming o en batch.
Pese a esto, existen ciertas limitaciones o consideraciones que surgen al aplicar algunas transformaciones en contextos en streaming, como podría ser en el caso de las agregaciones o de las uniones de datos. Por otro lado, también existen algunas operaciones que no tienen sentido al trabajar con flujos de datos sin principio ni fin, como el ordenamiento de datos mediante operaciones de ordenación, y que por defecto no están permitidas al trabajar en streaming.
A continuación, se van a ver diferentes tipos de transformaciones, haciendo especial hincapié en sus limitaciones e implicaciones al trabajar con Structured Streaming.
El primer grupo de transformaciones a comentar es aquel conformado por los procesos de filtrado y selección del dato, como las operaciones filter, select o where. Todo este tipo de operaciones son soportadas sin limitaciones en Structured Streaming, filtrando el dato exactamente de la misma forma que lo harían en formato batch.
La agrupación de datos para la obtención de métricas componen un conjunto de operaciones bien soportadas al trabajar con datos en streaming. Este tipo de operaciones no presentan ningún tipo de limitación, pese a que sí que pueden tener implicaciones importantes que pueden repercutir en tu aplicación en streaming, ya que en el caso de que realices una agregación por un campo Spark almacenará un estado interno del dato con los resultados intermedios para poder mantener una agregación conforme entran datos en streaming.
Spark se encarga de forma interna de manejar toda la complejidad relacionada con este estado interno, manteniendo y actualizando dicha estructura intermedia para garantizar que tus agregaciones permanezcan coherentes en memoria con todo el dato procesado por tu ETL.
Adicionalmente, Spark soporta agregaciones que son específicas del procesamiento de datos en streaming, como las agrupaciones por ventanas de tiempo, dando herramientas para manejar o descartar el dato que entra tarde.
Por último, la unión de datos mediante operaciones join o merge es algo que está soportado con ciertas limitaciones a la hora de procesar flujos de datos en streaming, tanto para el escenario en el que se quiera unir un conjunto de datos estático a un conjunto de datos en streaming como para unir dos conjuntos de datos en streaming diferentes. A continuación se van a comentar las limitaciones existentes para cada uno de estos dos escenarios:
- Stream-Estático: En el caso de que se pretenda hacer una operación del tipo join de un DataFrame en streaming (parte izquierda de la unión) con un DataFrame estático (parte derecha de la unión), están soportadas las operaciones del tipo “inner”, “left outer” y “left semi”. Las operaciones del tipo “right outer” y “full outer” no están soportadas para este escenario y lanzarán un error en caso de que se intenten utilizar en este contexto.
- Stream-Stream: En el segundo escenario en el que se pretende hacer una operación del tipo join de dos DataFrames en streaming diferentes están soportados todos los tipos de operaciones, pero con limitaciones. Estas limitaciones se basan en que al menos uno de los dos conjuntos (el cual depende del tipo concreto de operación de unión) debe tener especificadas restricciones temporales para que la unión del dato sea posible, como por ejemplo dejar explícito cuánto tiempo mantenemos en el estado interno de la aplicación un dato esperando a que llegue su contraparte en otro conjunto de datos.
Sinks de datos y Modos de escritura
Una vez el dato ha sido adquirido de una fuente y posteriormente procesado, es necesario definir qué queremos hacer con él. Es en este punto donde aparece el concepto de sink de datos, que no es más que la abstracción que usamos para representar el sistema externo al que queremos mover o publicar el dato procesado. Este sink funcionará como una especie de adaptador que definirá dónde se escribirán los datos de salida del flujo de datos, asegurándose de que el dato llegue adecuadamente a su destino de una forma robusta y resiliente a errores. Structured Streaming soporta varios tipos de sinks interesantes:
- Sink de ficheros: Este tipo de sink es el más sencillo de todos, ya que simplemente indica que la salida del streaming irá a parar a un directorio concreto de un sistema de ficheros especificado, escribiendo el nuevo dato procesado en forma de nuevos ficheros en este. Al igual que la fuente de datos basada en ficheros, este tipo de sink soporta diversos formatos comunes como JSON, parquet, CSV o texto plano.
- Sink de Apache Kafka: Escribir el dato de salida como un nuevo evento publicado en una cola Kafka puede ser muy interesante para integrar los resultados de tu proceso con otros procesos en streaming que conviven en tu ecosistema. Este tipo de sink se encarga de facilitar esta tarea, publicando en una cola la salida de tu proceso en streaming de forma transparente y sencilla.
- Sink en memoria: Este sink crea una tabla temporal en memoria que permite realizar consultas para obtener resultados en tiempo real sobre el dato procesado. Este tipo de sink, a diferencia de los anteriores, no dispone de recuperación ante posibles fallos, y por lo tanto puede desencadenar una pérdida permanente del dato procesado.
- Sink en consola: Muestra por pantalla el dato procesado en streaming, lo cual es especialmente útil para llevar a cabo tareas de desarrollo o de búsqueda de errores.
Tanto el sink en memoria como el sink en consola desempeñan un papel relegado a la experimentación y el desarrollo, no se recomienda que sean utilizados en un entorno productivo debido a su falta de robustez y a la posibilidad de pérdida permanente del dato. Al igual que ocurre con las fuentes de datos, existen diferentes conectores para poder utilizar como sink diversas tecnologías de terceros que no vienen contempladas por defecto en Structured Streaming.
Para poder escribir o publicar la salida de tu ETL en streaming también es necesario definir el modo de salida con el que será procesado el flujo de datos. Actualmente existen tres tipos de modos de salida soportados por Structured Streaming:
- Modo de adición o append mode: Este modo es el modo por defecto, y consiste simplemente en añadir consecutivamente nuevos registros a la salida resultado a partir del dato procesado en streaming, asegurándose de que cada registro procesado es producido como salida sólamente una vez. Este tipo de modo de salida no tiene sentido y no está soportado cuando en el procesamiento del dato en streaming existen agregaciones que obligan a Spark a mantener un estado interno con datos intermedios que puede ser actualizado.
- Modo completo o complete mode: El modo completo producirá como salida todo el resultado del dato procesado hasta el momento para actualizar la salida en el sink. Este tipo de modo de salida está diseñado para escenarios en los que en el procesado del dato se realizan agregaciones que obligan a mantener un estado interno del dato procesado, el cual es necesario actualizar por completo en la salida cada vez que llega nuevo dato.
- Modo de actualización o update mode: Este tercer modo de salida es muy similar al modo completo, sólo que únicamente producirá como salida aquellos registros del resultado que son diferentes con respecto a las anteriores salidas producidas por el pipeline. Este tipo de modo de salida limita los sinks que puedes utilizar para persistir tus resultados, ya que dicho sink debe soportar actualizaciones a nivel de registro para poder utilizar este modo. En el escenario en el que el procesamiento del dato no contenga agregaciones, este modo de salida es equivalente a utilizar el modo de adición previamente explicado.
Por último, sobre la salida del dato procesado, comentar que existe una configuración adicional de nuestro sink de datos que permite controlar cada cuánto procesar el siguiente mini-batch de datos una vez ha acabado una iteración de procesamiento en streaming, el trigger. Normalmente, el trigger más utilizado es el que se basa en el tiempo de procesamiento, y especifica cuánto tiempo de procesamiento queremos dejar entre mini-batch y mini-batch de nuestro procesamiento en streaming. Si este parámetro no se explicita, Spark por defecto procederá a procesar el siguiente mini-batch de datos en cuanto que acabe de procesar el anterior, tan pronto como pueda.
Conclusión
En este artículo, hemos introducido la API Structured Streaming de Spark, la cual nos permite implementar una ETL en streaming de una forma sencilla y eficaz. Se han visto los conceptos más relevantes a dominar sobre dicha Interfaz, dando las herramientas a nivel teórico para que cualquier ingeniero de datos pueda lanzarse a desarrollar su primer pipeline para procesar flujos de datos en tiempo real.