Ventajas de Event-Driven Architecture
Estamos acostumbrados a que las aplicaciones tradicionales funcionen como un diálogo: petición-respuesta, petición-respuesta. Pero, ¿y si necesitamos un sistema que funcione como un mensajero moderno? Donde los mensajes llegan cuando ocurre algo, y no porque estemos preguntando continuamente "¿hay algo nuevo?"
Event-Driven Architecture (EDA) – es como pasar de las llamadas telefónicas a un chat grupal. En el sistema antiguo tenías que llamar a cada participante por separado, esperar la respuesta, y si alguien no contestaba, todo el proceso se quedaba atascado. En el chat simplemente envías el mensaje y todos los interesados lo reciben.
Este enfoque da una flexibilidad increíble. ¿Añadir un nuevo participante al chat? Un par de clics y ya está al tanto de todos los eventos (scalability). ¿Alguien está temporalmente desconectado? No pasa nada, se conectará más tarde y leerá los mensajes perdidos (fault tolerance). Cada participante decide por sí mismo a qué mensajes reaccionar y cuáles ignorar (loose coupling). Y todo esto ocurre al instante, sin retardos ni coordinaciones innecesarias (reactivity).
Claro, montar este sistema es más complicado que una simple llamada telefónica. Pero cuando funciona, tendrás una solución flexible y escalable, lista para cualquier cambio.
Características principales de los sistemas distribuidos:
- Múltiples nodos: los datos y la lógica están repartidos entre distintos servicios.
- Independencia de componentes: cada servicio puede ejecutar su tarea sin dependencia directa de los demás.
- Interacciones asíncronas: los servicios pueden "hablar" entre sí vía eventos o mensajes.
Todo parece perfecto... hasta que aparecen problemas de coordinación, especialmente cuando se trata de transacciones.
¿Por qué surgen dificultades con las transacciones?
En aplicaciones monolíticas las transacciones son algo bastante sencillo. Por ejemplo, Spring y Hibernate nos permiten simplemente poner la anotación @Transactional, y la magia ocurre detrás de escena. Actualizas datos en una tabla, luego en otra, y Spring se encarga de que todo sea atómico: o se aplican todos los cambios, o no pasa nada.
Pero en sistemas distribuidos eso ya no es tan fácil. ¿La razón? Los servicios funcionan en aislamiento unos de otros, y cada uno puede tener su propia base de datos.
Problemas en las transacciones distribuidas
- Consistencia de datos: ¿cómo garantizar que los datos en distintos servicios estén "sincronizados" si el servicio principal cae y los cambios en otro servicio ya se aplicaron?
- Repetibilidad de las operaciones: si una transacción quedó parcialmente completada, ¿cómo "deshacer" los cambios ya realizados?
- Retardos y problemas de red: la comunicación entre servicios depende de la red, y aparecen timeouts, mensajes perdidos o solicitudes duplicadas.
- Independencia de los servicios: no queremos que la caída de un servicio "hunda" todo el sistema.
Imagínate la situación: tienes un microservicio para procesar pedidos, otro para debitar dinero de la cuenta bancaria, y un tercero — para enviar notificaciones. ¿Qué pasa si el tercer servicio falla después de debitar el dinero, pero antes de enviar la notificación? El pago se realizó y el cliente piensa que no pasó nada. Un desastre.
Problemas de coordinación entre servicios
¿Y si algo sale mal? En una aplicación monolítica todo es simple: Spring gestiona las transacciones. Pones la anotación @Transactional — y duermes tranquilo. Pero en el mundo distribuido la cosa es mucho más complicada.
Imagina que pides una pizza. En el monolito es como llamar a una sola pizzería: ellos mismos comprueban ingredientes, hacen el pedido, aceptan el pago y organizan la entrega. Si en cualquiera de los pasos algo falla — el pedido se cancela.
En un sistema distribuido cada paso puede ser manejado por un servicio distinto: uno verifica el almacén, otro procesa el pago, otro se ocupa de la entrega. Y aquí empieza lo interesante:
- El servicio de pagos dice "dinero debitado", pero el servicio de entrega no responde. ¿Qué hacer con el dinero?
- El repartidor ya está en camino y resulta que el pago no pasó. ¿Detener al repartidor?
- Por un fallo de red el pedido se procesa dos veces. ¿Cómo evitar enviar dos pizzas al cliente?
¿Por qué esto es complicado?
A diferencia del monolito, donde todos los procesos están controlados centralmente, en un sistema distribuido cada servicio trabaja de forma independiente. No hay un centro único que pueda decir: "Esto no salió bien, retrocedamos todo".
Cada servicio conoce solo su parte del trabajo. El servicio de pagos no sabe qué pasa con la entrega, y el de entrega no sabe si el pago se completó. Y cuando algo falla, coordinar sus acciones se vuelve un desafío real.
Ejemplos de problemas reales:
- Pérdida de mensajes: un servicio envía una petición a otro, pero el mensaje se pierde en la red. ¿Cómo asegurarse de que los cambios se aplicaron?
- Rollback incompleto: si un servicio completó su parte y otro falló, ¿cómo volver todo al estado inicial?
- Duplicación de operaciones: por ejemplo, la señal de débito se envió dos veces por un fallo de red. ¿Cómo evitar el doble cobro?
Estrategias para resolver los problemas
¡No te preocupes! Todos estos problemas se pueden abordar si los tratas con ingeniería. Aquí tienes algunas estrategias que los desarrolladores usan para gestionar transacciones en sistemas distribuidos:
Commit en dos fases (2PC)
Este método coordina varios servicios para que "voten" sobre el éxito de la transacción. Fase 1: todos los servicios dicen "sí, estoy listo". Fase 2: el coordinador confirma los cambios (o los revierte si alguien dijo "no"). Parece sencillo, pero en la práctica es complicado porque:
- 2PC no es adecuado para sistemas de alta carga por su baja performance.
- Si el coordinador falla, todo puede desmoronarse.
Ejemplo de un enfoque trivial (y bastante lento).
@Transactional
public void performDistributedTransaction(List<Service> services) {
try {
for (Service service : services) {
service.prepare(); // Fase 1: preparación
}
for (Service service : services) {
service.commit(); // Fase 2: confirmar cambios
}
} catch (Exception e) {
services.forEach(Service::rollback); // En caso de error: rollback
}
}
Patrón saga
El patrón saga aborda las transacciones con la filosofía de: "Vamos paso a paso". Dividimos el proceso en una serie de acciones y, si algo falla, ejecutamos acciones compensatorias. Por ejemplo:
- Servicio 1: "He debitado el dinero".
- Servicio 2: "He añadido el producto al carrito".
- Si ambos servicios dicen "listo" — éxito. Si el segundo servicio falla, el primero debe compensar su acción — por ejemplo, devolver el dinero a la cuenta.
Así puede verse una saga básica (¡habrá más detalles en la siguiente lección!):
public void processSaga() {
try {
firstService.doAction();
secondService.doAction();
} catch (Exception e) {
firstService.compensate(); // Compensar el cambio en caso de error
}
}
El patrón saga se vuelve especialmente potente si lo combinas con una arquitectura orientada a eventos (por ejemplo, con Kafka).
Event Sourcing
Este enfoque dice: "Guarda solo los eventos, no su resultado". Por ejemplo:
- No guardas el balance en la cuenta, sino cada evento: "Ingreso", "Débito", "Reembolso".
- Si algo sale mal, puedes simplemente "reproducir" los eventos para reconstruir el estado del sistema.
Event Sourcing encaja muy bien con CQRS (¡de esto hablaremos en una de las próximas lecciones!) y Kafka.
Observadores y timeouts
Un enfoque simple pero efectivo. Si envías una petición a otro servicio y no recibes respuesta en cierto tiempo, deshaces los cambios. Lo importante aquí es implementar correctamente los timeouts y los reintentos.
Conclusiones
En la práctica la elección del enfoque depende de tus requisitos:
- Si necesitas consistencia estricta, quizá 2PC sea tu elección (aunque suele evitarse en sistemas de alta carga).
- Si puedes sacrificar consistencia por rendimiento, aplica el patrón saga.
- Si buscas máxima flexibilidad y tolerancia a fallos, fíjate en Event Sourcing.
La idea clave que debes entender: en sistemas distribuidos no hay soluciones perfectas. En su lugar eliges un compromiso entre rendimiento, tolerancia a fallos y nivel de consistencia de los datos.
GO TO FULL VERSION