¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
Conoce nuestra marca.¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
Conoce nuestra marca.dev
José Alberto Ruiz Casarrubios 18/01/2024 Cargando comentarios…
Cuando hablamos de transacciones dentro del mundo software, la mayoría de las veces pensamos en la típica secuencia de acciones de escritura contra una base de datos que se consolidan con un commit o se deshacen con un rollback si se produce algún fallo en un paso de la secuencia. Si nos hemos adentrado ya en el mundo de las arquitecturas orientadas a eventos y microservicios nos daremos cuenta de que realizar este tipo de acciones no es una tarea fácil. En este post os contamos cómo podemos manejarlo.
En escenarios monolíticos todas las acciones de escritura se suelen ejecutar contra una misma base de datos. Es el propio motor de base de datos el que se encarga de consolidar o deshacer todos los cambios, de forma transparente.
Por ejemplo, pensemos en el típico caso de venta online. Cuando se realiza una compra, se ejecuta la siguiente secuencia de acciones:
Si todo es correcto, todas las acciones se consolida en la base de datos. Si hay un fallo en cualquiera de los pasos, todas las acciones que se hayan ejecutado durante el proceso, se deshacen. La siguiente imagen resume el proceso:
En la imagen, vemos una aplicación monolítica, dividida en módulos, que ataca a una misma base de datos. La acción transaccional es relativamente sencilla porque el peso de la misma recae en el motor de la base de datos, asegurando que sea ACID.
Como hemos visto, cuando atacas a una misma base de datos, las acciones transaccionales son sencillas de implementar, ya que el peso de las mismas recae en el motor de base de datos. Ahora bien, puede que estés inmerso en una “ruptura de monolito” o te hayas lanzado a crear un sistema basado en EDA y microservicios y no tengas tal monolito sino algo como:
En este caso, la acción o flujo transaccional no se realiza contra una única base de datos, sino que involucra a distintas bases de datos que, incluso, pueden ser diferentes productos según las necesidades de cada dominio o contexto.
En este caso, al estar separadas, proporcionar atomicidad, consistencia o aislamiento se complica mucho más que en el caso tradicional. Cada servicio puede disponer de capacidades ACID en su contexto, pero a nivel de flujo no disponemos de la misma capacidad, ya que involucra a varios servicios de forma coordinada:
Una forma de abordar esta necesidad transaccional podría ser el uso de mecanismos como two-phase commit. No vamos a entrar en detalle sobre este mecanismo, ya que no es el objeto del post, pero sí quiero recalcar que es una forma de gestionar las transacciones distribuidas en la que se ‘bloquea’ el estado de cada componente o servicio hasta que todos aseguran que pueden realizar los cambios correspondientes a nivel de datos.
Si pensamos en transacciones muy rápidas, puede encajar, pero podemos tener que enfrentarnos a transacciones o flujos que duran minutos o incluso días. En ese escenario, los componentes tendrían bloqueados sus datos durante todo ese tiempo, lo cual es inmanejable. También tenemos que tener en cuenta que puede haber contextos en los que no se pueda implementar ese “bloqueo” o sea muy complejo hacerlo (bases de datos NoSQL, bus de mensajería, etc.).
Seguramente hayas oído hablar de Sagas. La definición de Saga no es algo nuevo, aunque este término haya adquirido mucha popularidad en los últimos años debido al auge de las arquitecturas de microservicios y orientadas a eventos. Si tienes curiosidad puedes descargarte el paper original de 1987 de Hector Garica-Molina y Kenneth Salem en la que dan nombre a este término.
El término Saga se creó para gestionar aquellas transacciones o flujos de larga duración (minutos, horas, días…), en las que el mecanismo de “two phase commit” no es la mejor alternativa debido a que, como hemos comentado anteriormente, dejaría bloqueadas las “tablas” hasta que finalizara la transacción o flujo.
La idea principal de las Sagas es dividir una transacción de larga duración (Long Lived Transactions (LLT), en inglés) en una secuencia de transacciones más pequeñas y atómicas, que puedan ser manejadas de forma independiente.
Este enfoque encaja perfectamente en arquitecturas distribuidas, como son las orientadas a microservicios y eventos, en las que vamos a tener procesos o flujos transaccionales, que involucran a diferentes contextos y que pueden tener una duración “larga” (segundos, minutos, horas o incluso días)
Siguiendo con el ejemplo del pedido, podríamos modelar la Saga de esta forma:
En la imagen vemos el flujo que sigue la Saga y, asociados a cada paso, vemos los servicios que soportan cada paso transacción individual. Realmente, en el flujo que muestra la imagen solo se está contemplando el caso base o happy path.
¿Qué ocurriría si no se puede realizar el pago? Si no se puede realizar el pago ya hemos reservado el stock y tendríamos el sistema en un estado inconsistente. En el siguiente apartado, vamos a ver cómo podemos gestionar los posibles flujos de error.
Como hemos comentado anteriormente, es importante tener en cuenta que en una Saga podremos tener atomicidad en las transacciones individuales; es decir, en cada paso, pero no en el proceso global. Por lo tanto, tenemos que gestionar de forma manual los posibles fallos en cada paso.
Esto lo podemos traducir a que cada paso del proceso debería “exponer” dos operaciones:
De este modo, si se detecta un error en un paso del flujo, se podría ‘recorrer lo andado’ llamando a las acciones de deshacer de los pasos ya ejecutados. A estas acciones de ‘deshacer’ se las denomina acciones de compensación.
Siempre que estemos trabajando con Sagas tenemos que plantearnos cuáles son las acciones de compensación asociadas a cada paso y modelarlas. Siguiendo con el ejemplo, el proceso quedaría de la siguiente forma:
Puede que estés pensando en todos los posibles errores “técnicos” que pueden ocurrir en cada paso; por ejemplo, si tomamos el paso de “realizar la orden de pago”, podría pasar que la pasarela o base de datos estuvieran caídas, que hubiera problemas de red, etc.
A la hora de trabajar con Sagas, no tenemos que centrarnos en estos errores, sino en los errores propios de negocio, que son los errores que conocemos y podemos modelar. Por ejemplo, que la tarjeta introducida no sea válida, que el producto no se encuentre ya en el catálogo, etc.
Los errores técnicos no son deterministas, pueden ocurrir en cualquier momento y el catálogo es muy amplio. Asociar una acción de compensación a que no se pueda alcanzar la base de datos de un servicio concreto puede terminar en que la acción de compensación no se pueda realizar por el mismo motivo en un servicio anterior y no podemos compensar una compensación. Nos veremos inmersos en un proceso sin fin.
La forma de gestionar esos errores técnicos puede estar en manejar reintentos o, bien, en procesos orientados a garantizar una consistencia eventual (no confundamos consistencia eventual con eventos).
Como puedes ver, a la hora de trabajar con Sagas tenemos que analizar en detalle los casos de uso, los happy paths y no happy paths y, a partir de ahí, realizar un buen diseño que cubra todos los posibles casos, incluyendo las acciones de compensación.
Una vez analizado y evaluado el flujo, puede que tengas que cambiar el orden de la secuencia o incluso plantearte agrupar, crear un pequeño monolito con algunos pasos o crear un monolito completo si la complejidad de la Saga no compensa su beneficio.
Por ejemplo, imagina que en el ejemplo anterior incluimos una notificación por email al cliente cuando el pago se ha realizado. ¿Qué pasaría si después no se puede actualizar el catálogo? ¿Cómo deshacemos el envío de un email? No podemos. Lo que podemos hacer es evaluar si tiene sentido que la notificación se envíe en ese punto o se puede esperar al final de la Saga.
Otro punto que tenemos que tener en cuenta cuando trabajamos con Sagas es que tampoco tenemos la capacidad de “aislamiento” que nos proporciona ACID. En la Saga de ejemplo que estamos manejando en este post podemos pensar qué pasaría si, una vez creada la orden de pedido, hacemos el pago y después actualizamos el stock.
Por ejemplo, supongamos que existe solo un artículo disponible para el producto A del catálogo:
Sin embargo, tal y como lo hemos modelado, comprobando y actualizando el stock antes del pago, intentamos minimizar la ejecución de acciones de compensación asociadas a reembolsos.
Un smell típico de mal diseño en arquitecturas de microservicios es precisamente el abuso de Sagas o Sagas muy complejas. En esos casos, es aconsejable revisar el diseño para determinar si ha llegado a un nivel de granularidad muy bajo (quizá a nivel de entidad pura) y sea conveniente subir un poco ese nivel de granularidad (o pensar en agregados) para simplificar el diseño, la implementación y el mantenimiento.
A la hora de llevar una Saga a la realidad, podemos optar por dos tipos de implementación: orquestación o coreografía. Veamos las características de cada una.
Como su propio nombre indica, este modo sería similar a una orquesta: un componente realiza el rol de director de la orquesta y coordina al resto de componentes para conseguir tocar la pieza musical.
Trasladado al mundo de las sagas, la implementación consistiría en un componente principal que coordina al resto de elementos para realizar los pasos asociados a la secuencia de transacciones individuales. Ese componente principal recibe el nombre de orquestador o controlador y debe implementar el algoritmo asociado a la lógica de negocio de la saga. Volviendo al ejemplo anterior, nuestra Saga en modo orquestador quedaría de la siguiente manera:
Como se puede observar en la imagen, el orquestador coordina las llamadas a las operaciones de los diferentes servicios tanto para la acción de “hacer” como para la compensación en caso de fallo. En este tipo de implementación, al estar toda la lógica de negocio en el orquestador, es sencillo de depurar y evolucionar.
Las llamadas a las diferentes operaciones usualmente suelen ser síncronas, “bloqueando” la ejecución, pero dependiendo de los requerimientos de escalabilidad, podríamos implementar llamadas asíncronas a los servicios siempre que sea el orquestador el que dirija las llamadas.
Cuando hacemos este tipo de implementación, es fácil caer en la sensación de que algunos de los componentes individuales podrían ser integrados en el orquestador; es decir, que nos parezca que son anémicos y que no compensa realizar llamadas cuando podemos integrar esa lógica dentro del principal. Si llegamos a este punto, vuelvo a insistir en hacer foco en el DISEÑO, evaluando dónde debe estar esa lógica, quién es su owner y sus necesidades de evolución de forma autónoma.
Como su propio nombre indica, en la coreografía no hay un elemento que asume el rol de coordinador, sino que todos los elementos involucrados en el flujo o transacción reaccionan ante determinados eventos, ejecutan su lógica individual y comunican el resultado, haciendo que otros elementos reaccionen.
Al contrario que en la orquestación, la lógica de la saga está distribuida en los diferentes componentes individuales que publican y reaccionan ante eventos. Esto hace que seguir la lógica o realizar una depuración es más complejo que en la orquestación. Además, hay que tener cuidado a la hora de diseñar los eventos para que sean realmente útiles para la saga y no sean ambiguos.
Este enfoque tiene como ventaja que puedes añadir o quitar elementos del proceso sin que haya impacto, ya que no hay que modificar el componente orquestador porque no existe y de este modo favorece la evolución. También puede permitir que, en caso de que los requisitos de negocio lo permitan, varios pasos se puedan ejecutar en paralelo y así mejorar el rendimiento.
Por otro lado, los identificadores de correlación siempre son importantes, pero en este caso son vitales, al igual que la idempotencia, ya que puede ocurrir que se dupliquen eventos. En el momento que se crea la saga, se debe crear un identificador de saga que actuará como identificador de correlación. Es aconsejable crear un componente o servicio asociado a la saga que escuche los diferentes eventos que se lanzan y mantenga un log. De esta manera, podremos saber fácilmente en qué estado está cada saga a partir de su identificador.
Al igual que hemos comentado anteriormente, no hay una bala de plata. Dependiendo de las características de tu negocio puede encajar mejor una forma u otra o incluso que tengas que crear un modelo mixto.
Por ejemplo, si seguimos con nuestro ejemplo de compra, en el que uno de los primeros pasos es comprobar y actualizar el stock antes de hacer el pago, el modelo que más encaja es la orquestación, pero podríamos incluir coreografía en el último paso para crear la orden de envío y la actualización del catálogo en paralelo.
Por último, vamos a enumerar algunas de las buenas prácticas a la hora de trabajar con Sagas:
En este post hemos visto un punto clave cuando trabajamos con arquitecturas orientadas a eventos y microservicios, la gestión de los flujos de negocio transaccionales que involucran a varios servicios. No es un tema sencillo de gestionar y puede añadir mucha complejidad a nuestro proyecto. Por ello, entender bien la problemática del flujo de negocio y dedicar tiempo al diseño es clave para trabajar con sagas y no morir en el intento.
Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.
Cuéntanos qué te parece.