Capítulo 4. Estilos de comunicación de microservicios
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Conseguir una buena comunicación entre microservicios es problemático para muchos, debido en gran parte, en mi opinión, al hecho de que la gente gravita hacia un enfoque tecnológico elegido sin considerar primero los diferentes tipos de comunicación que podrían desear. En este capítulo, intentaré desgranar los diferentes estilos de comunicación para ayudarte a comprender los pros y los contras de cada uno, así como qué enfoque se adaptará mejor a tu espacio problemático.
Estudiaremos mecanismos de comunicación síncronos bloqueantes y asíncronos no bloqueantes, y compararemos la colaboración solicitud-respuesta con la colaboración basada en eventos.
Al final de este capítulo deberías estar mucho mejor preparado para comprender las distintas opciones que tienes a tu disposición y tendrás unos conocimientos básicos que te ayudarán cuando empecemos a estudiar cuestiones de implementación más detalladas en los capítulos siguientes.
Del proceso en curso al proceso intermedio
Vale, vamos a a quitarnos de encima primero lo fácil, o al menos lo que espero que sea fácil. A saber, las llamadas entre distintos procesos a través de una red (entre procesos) son muy distintas de las llamadas dentro de un mismo proceso (dentro de un proceso). A un nivel, podemos ignorar esta distinción. Es fácil, por ejemplo, pensar en un objeto que hace una llamada a un método de otro objeto y luego simplemente asignar esta interacción a dos microservicios que se comunican a través de una red. Dejando a un lado el hecho de que los microservicios no son sólo objetos, esta forma de pensar puede meternos en muchos problemas.
Veamos algunas de estas diferencias y cómo podrían cambiar tu forma de pensar sobre las interacciones entre tus microservicios.
Rendimiento
El rendimiento de una llamada dentro de un proceso es fundamentalmente distinto del de una llamada entre procesos. Cuando realizo una llamada dentro de un proceso, el compilador y el tiempo de ejecución subyacentes pueden llevar a cabo toda una serie de optimizaciones para reducir el impacto de la llamada, incluido el alineamiento de la invocación para que sea como si nunca hubiera habido una llamada en primer lugar. Estas optimizaciones no son posibles con las llamadas entre procesos. Hay que enviar paquetes. Es de esperar que la sobrecarga de una llamada entre procesos sea significativa en comparación con la sobrecarga de una llamada dentro del proceso. La primera es muy medible -el ida y vuelta de un solo paquete en un centro de datos se mide en milisegundos-, mientras que la sobrecarga de hacer una llamada a un método es algo de lo que no tienes que preocuparte.
A menudo, esto puede llevarte a replantearte las API. Una API que tiene sentido en el proceso puede no tenerlo en situaciones entre procesos. Puedo hacer mil llamadas a través del límite de una API en proceso sin preocuparme. ¿Quiero hacer mil llamadas de red entre dos microservicios? Tal vez no.
Cuando paso un parámetro a un método, la estructura de datos que paso no suele moverse; lo más probable es que pase un puntero a una posición de memoria. Pasar un objeto o una estructura de datos a otro método no requiere asignar más memoria para copiar los datos.
En cambio, cuando se realizan llamadas entre microservicios a través de una red, los datos tienen que serializarse en alguna forma que pueda transmitirse a través de una red. A continuación, los datos deben enviarse y deserializarse en el otro extremo. Por tanto, puede que tengamos que tener más cuidado con el tamaño de las cargas útiles que se envían entre procesos. ¿Cuándo fue la última vez que fuiste consciente del tamaño de una estructura de datos que estabas pasando dentro de un proceso? La realidad es que probablemente no necesitabas saberlo; ahora sí. Esto podría llevarte a reducir la cantidad de datos que se envían o reciben (quizá no sea algo malo si pensamos en la ocultación de información), elegir mecanismos de serialización más eficientes, o incluso descargar datos a un sistema de archivos y pasar en su lugar una referencia a la ubicación de ese archivo.
Puede que estas diferencias no te causen problemas de inmediato, pero sin duda debes ser consciente de ellas. He visto muchos intentos de ocultar al desarrollador el hecho de que se está produciendo una llamada de red. Nuestro deseo de crear abstracciones para ocultar detalles es una gran parte de lo que nos permite hacer más cosas con más eficacia, pero a veces creamos abstracciones que ocultan demasiado. Un desarrollador debe ser consciente de si está haciendo algo que dará lugar a una llamada de red; de lo contrario, no debería sorprenderte si acabas con algunos desagradables cuellos de botella de rendimiento más adelante, causados por extrañas interacciones entre servicios que no eran visibles para el desarrollador que escribía el código.
Cambio de interfaces
Cuando consideramos cambios en una interfaz dentro de un proceso, el acto de desplegar el cambio es sencillo. El código que implementa la interfaz y el código que llama a la interfaz se empaquetan juntos en el mismo proceso. De hecho, si cambio la firma de un método utilizando un IDE con capacidad de refactorización, a menudo el propio IDE refactorizará automáticamente las llamadas a este método cambiante. La implementación de un cambio de este tipo puede hacerse de forma atómica: ambos lados de la interfaz se empaquetan juntos en un único proceso.
Sin embargo, en la comunicación entre microservicios, el microservicio que expone una interfaz y los microservicios consumidores que utilizan esa interfaz son microservicios que pueden desplegarse por separado. Al hacer un cambio incompatible con el pasado en la interfaz de un microservicio, o bien tenemos que hacer una implementación escalonada con los consumidores, asegurándonos de que se actualizan para utilizar la nueva interfaz, o bien encontrar alguna forma de escalonar el despliegue del nuevo contrato de microservicios. Exploraremos este concepto con más detalle más adelante en este capítulo.
Tratamiento de errores
Dentro de un proceso, si llamo a un método, la naturaleza de los errores tiende a ser bastante sencilla. Simplificando, los errores o son esperados y fáciles de manejar, o son catastróficos hasta el punto de que simplemente propagamos el error por la pila de llamadas. Los errores, en general, son deterministas.
Con un sistema distribuido, la naturaleza de los errores puede ser diferente. Eres vulnerable a una serie de errores que escapan a tu control. Las redes se caen. Los microservicios descendentes pueden no estar disponibles temporalmente. Las redes se desconectan, los contenedores mueren por consumir demasiada memoria y, en situaciones extremas, pueden incendiarse partes de tu centro de datos.1
En su libro Sistemas Distribuidos,2 Andrew Tanenbaum y Maarten Steen desglosan los cinco tipos de modos de fallo que puedes ver al observar una comunicación entre procesos. He aquí una versión simplificada:
- Fallo por colisión
-
Todo iba bien hasta que el servidor se bloqueó. ¡Reinicia!
- Fallo por omisión
-
Has enviado algo, pero no has obtenido respuesta. También incluye situaciones en las que esperas que un microservicio descendente esté lanzando mensajes (quizás incluyendo eventos), y simplemente se detiene.
- Fallo de sincronización
-
Algo ocurrió demasiado tarde (no lo conseguiste a tiempo), ¡o algo ocurrió demasiado pronto!
- Fallo de respuesta
-
Has recibido una respuesta, pero parece incorrecta. Por ejemplo, pediste un resumen del pedido, pero en la respuesta faltan datos necesarios.
- Fallo arbitrario
-
También conocido como fallo bizantino, es cuando algo ha ido mal, pero los participantes son incapaces de ponerse de acuerdo sobre si el fallo se ha producido (o por qué). Tal y como suena, es un mal momento para todos.
Muchos de estos errores suelen ser de naturaleza transitoria: son problemas de corta duración que pueden desaparecer. Considera la situación en la que enviamos una solicitud a un microservicio pero no recibimos respuesta (un tipo de fallo por omisión). Esto podría significar que el microservicio de destino nunca recibió la solicitud en primer lugar, por lo que tenemos que enviarla de nuevo. Otros problemas no pueden resolverse fácilmente y pueden requerir la intervención de un operador humano. Como resultado, puede llegar a ser importante disponer de un conjunto más rico de semántica para devolver errores de forma que permita a los clientes tomar las medidas adecuadas.
HTTP es un ejemplo de protocolo que comprende la importancia de esto. Cada respuesta HTTP tiene un código, y los códigos de las series 400 y 500 se reservan para los errores. Los códigos de error de la serie 400 son errores de solicitud -esencialmente, un servicio posterior está diciendo al cliente que hay algo mal en la solicitud original-. Como tal, probablemente sea algo a lo que debas renunciar: ¿tiene sentido reintentar un 404 Not Found
, por ejemplo? Los códigos de respuesta de la serie 500 se refieren a problemas en el flujo descendente, un subconjunto de los cuales indica al cliente que el problema podría ser temporal. Un 503 Service Unavailable
, por ejemplo, indica que el servidor descendente es incapaz de gestionar la solicitud, pero puede tratarse de un estado temporal, en cuyo caso un cliente ascendente podría decidir reintentar la solicitud. Por otra parte, si un cliente recibe una respuesta 501 Not Implemented
, es poco probable que un reintento sirva de mucho.
Elijas o no un protocolo basado en HTTP para la comunicación entre microservicios, si tienes un rico conjunto de semántica en torno a la naturaleza del error, facilitarás que los clientes lleven a cabo acciones compensatorias, lo que a su vez debería ayudarte a construir sistemas más robustos.
Tecnología para la comunicación entre procesos: Tantas opciones
Y en un mundo en el que tenemos demasiadas opciones y muy poco tiempo, lo obvio es ignorar las cosas.
Seth Godin
La gama de tecnologías de que disponemos para la comunicación entre procesos es enorme. Como resultado, a menudo podemos estar sobrecargados de opciones. A menudo me doy cuenta de que la gente se inclina por la tecnología que le resulta familiar, o quizá por la última tecnología de moda de la que se enteró en una conferencia. El problema es que, cuando te decides por una tecnología concreta, a menudo estás aceptando una serie de ideas y limitaciones que te acompañan. Estas limitaciones pueden no ser las adecuadas para ti, y la mentalidad que hay detrás de la tecnología puede no coincidir realmente con el problema que intentas resolver.
Si estás intentando construir un sitio web, la tecnología de aplicaciones de una sola página como Angular o React es una mala opción. Del mismo modo, intentar utilizar Kafka para peticiones-respuestas no es realmente una buena idea, ya que se diseñó para interacciones más basadas en eventos (temas a los que llegaremos dentro de un momento). Y, sin embargo, veo que la tecnología se utiliza en el lugar equivocado una y otra vez. La gente elige la nueva tecnología brillante (¡como los microservicios!) sin considerar si realmente se ajusta a su problema.
Por eso, cuando se trata del desconcertante abanico de tecnologías de que disponemos para la comunicación entre microservicios, creo que es importante hablar primero del estilo de comunicación que quieres, y sólo después buscar la tecnología adecuada para implementar ese estilo. Con esto en mente, echemos un vistazo a un modelo que he estado utilizando durante varios años para ayudar a distinguir entre los diferentes enfoques para la comunicación entre microservicios, que a su vez puede ayudarte a filtrar las opciones tecnológicas que querrás examinar.
Estilos de comunicación de microservicios
En, Figura 4-1, vemos un esquema del modelo que utilizo para pensar en los distintos estilos de comunicación. Este modelo no pretende ser totalmente exhaustivo (no intento presentar aquí una gran teoría unificada de la comunicación entre procesos), pero proporciona una buena visión general de alto nivel para considerar los distintos estilos de comunicación más utilizados en las arquitecturas de microservicios.
En breve veremos con más detalle los distintos elementos de este modelo, pero antes me gustaría esbozarlos brevemente:
- Bloqueo sincrónico
-
Un microservicio realiza una llamada a otro microservicio y bloquea la operación esperando la respuesta.
- Asíncrono no bloqueante
-
El microservicio que emite una llamada puede seguir procesando tanto si recibe la llamada como si no.
- Solicitud-respuesta
-
Un microservicio envía una petición a otro microservicio solicitando que se haga algo. Espera recibir una respuesta que le informe del resultado.
- Dirigido por eventos
-
Los microservicios emiten eventos, que otros microservicios consumen y ante los que reaccionan en consecuencia. El microservicio que emite el evento desconoce qué microservicios, si los hay, consumen los eventos que emite.
- Datos comunes
-
Aunque no suele considerarse un estilo de comunicación, los microservicios colaboran a través de alguna fuente de datos compartida.
Cuando utilizo este modelo para ayudar a los equipos a decidir el enfoque adecuado, dedico mucho tiempo a comprender el contexto en el que operan. Sus necesidades en términos de comunicación fiable, latencia aceptable y volumen de comunicación van a influir a la hora de elegir una tecnología. Pero, en general, tiendo a empezar por decidir si es más apropiado para la situación un estilo de colaboración de petición-respuesta o uno basado en eventos. Si opto por la solicitud-respuesta, tengo a mi disposición tanto implementaciones síncronas como asíncronas, así que tengo una segunda opción. Sin embargo, si elijo un estilo de colaboración basado en eventos, mis opciones de implementación se limitarán a opciones asíncronas no bloqueantes.
A la hora de elegir la tecnología adecuada, entran en juego muchas otras consideraciones que van más allá del estilo de comunicación: por ejemplo, la necesidad de una comunicación de baja latencia, aspectos relacionados con la seguridad o la capacidad de ampliación. Es poco probable que puedas hacer una elección tecnológica razonada sin tener en cuenta los requisitos (y las limitaciones) de tu espacio problemático específico. Cuando analicemos las opciones tecnológicas en el Capítulo 5, trataremos algunas de estas cuestiones.
Mezcla y combina
Es importante señalar que una arquitectura de microservicios en su conjunto puede tener una mezcla de estilos de colaboración, y esto suele ser lo normal. Algunas interacciones sólo tienen sentido como solicitud-respuesta, mientras que otras lo tienen como impulsadas por eventos. De hecho, es habitual que un único microservicio implemente más de una forma de colaboración. Piensa en un microservicio Order
que expone una API de solicitud-respuesta que permite realizar o modificar pedidos y, a continuación, dispara eventos cuando se realizan estos cambios.
Dicho esto, veamos estos diferentes estilos de comunicación con más detalle.
Patrón: Bloqueo sincrónico
Con una llamada síncrona de bloqueo, un microservicio envía una llamada de algún tipo a un proceso descendente (probablemente otro microservicio) y se bloquea hasta que la llamada se ha completado, y potencialmente hasta que se ha recibido una respuesta. En la Figura 4-2, el Order Processor
envía una llamada al microservicio Loyalty
para informarle de que deben añadirse algunos puntos a la cuenta de un cliente.
Normalmente, una llamada síncrona bloqueante es aquella que está esperando una respuesta del proceso descendente. Esto puede deberse a que el resultado de la llamada es necesario para alguna operación posterior, o simplemente a que quiere asegurarse de que la llamada ha funcionado y realizar algún tipo de reintento en caso contrario. Como resultado, prácticamente todas las llamadas síncronas de bloqueo que veo constituirían también una llamada de solicitud-respuesta, algo que veremos en breve.
Ventajas
Hay algo sencillo y familiar en una llamada sincrónica bloqueante. Muchos de nosotros aprendimos a programar con un estilo fundamentalmente síncrono: leyendo un fragmento de código como un script, con cada línea ejecutándose a su vez, y con la siguiente línea de código esperando su turno para hacer algo. La mayoría de las situaciones en las que habrías utilizado llamadas entre procesos se hicieron probablemente con un estilo síncrono y de bloqueo: ejecutar una consulta SQL en una base de datos, por ejemplo, o hacer una solicitud HTTP a una API descendente.
Cuando se pasa de una arquitectura menos distribuida, como la de un monolito de proceso único, puede tener sentido quedarse con las ideas que resultan familiares cuando hay tantas cosas nuevas.
Desventajas
El principal reto de las llamadas síncronas es el acoplamiento temporal inherente que se produce, un tema que exploramos brevemente en el Capítulo 2. Cuando Order Processor
realiza una llamada a Loyalty
en el ejemplo anterior, el microservicio Loyalty
tiene que estar accesible para que la llamada funcione. Si el microservicio Loyalty
no está disponible, la llamada fallará, y Order Processor
tendrá que decidir qué tipo de acción compensatoria llevar a cabo, que puede consistir en un reintento inmediato, almacenar la llamada para reintentarla más tarde, o quizás renunciar por completo.
Este acoplamiento es bidireccional. Con este estilo de integración, la respuesta suele enviarse a través de la misma conexión de red entrante al microservicio ascendente. Por tanto, si el microservicio Loyalty
quiere enviar una respuesta a Order Processor
, pero la instancia ascendente ha muerto posteriormente, la respuesta se perderá. El acoplamiento temporal aquí no es sólo entre dos microservicios; es entre dos instancias específicas de estos microservicios.
Como el remitente de la llamada está bloqueando y esperando a que el microservicio descendente responda, también se deduce que si el microservicio descendente responde lentamente, o si hay un problema con la latencia de la red, entonces el remitente de la llamada estará bloqueado durante un periodo prolongado de tiempo esperando una respuesta. Si el microservicio Loyalty
está sometido a una carga importante y responde con lentitud a las solicitudes, esto provocará a su vez que el Order Processor
responda con lentitud.
Por tanto, el uso de llamadas síncronas puede hacer que un sistema sea vulnerable a problemas en cascada causados por interrupciones en sentido descendente más fácilmente que el uso de llamadas asíncronas.
Dónde utilizarlo
Para las arquitecturas de microservicios sencillas, no tengo ningún problema con el uso de llamadas síncronas y bloqueantes. Su familiaridad para muchas personas es una ventaja a la hora de familiarizarse con los sistemas distribuidos.
Para mí, este tipo de llamadas empiezan a ser problemáticas cuando empiezas a tener más cadenas de llamadas: en la Figura 4-3, por ejemplo, tenemos un flujo de ejemplo de MusicCorp, en el que estamos comprobando un pago en busca de actividad potencialmente fraudulenta. El servicio Order Processor
llama al servicio Payment
para aceptar el pago. El servicio Payment
, a su vez, quiere comprobar con el microservicio Fraud Detection
si esto debe permitirse o no. A su vez, el microservicio Fraud Detection
necesita obtener información del microservicio Customer
.
Si todas estas llamadas son síncronas y bloqueantes, podríamos enfrentarnos a una serie de problemas. Un problema en cualquiera de los cuatro microservicios implicados, o en las llamadas de red entre ellos, podría provocar el fallo de toda la operación. Esto es independiente del hecho de que este tipo de cadenas largas pueden causar una contención de recursos significativa. Entre bastidores, es probable que Order Processor
tenga una conexión de red abierta esperando respuesta de Payment
. Payment
tiene a su vez una conexión de red abierta esperando respuesta de Fraud Detection
, y así sucesivamente. Tener muchas conexiones que necesitan mantenerse abiertas puede tener un impacto en el sistema en funcionamiento: es mucho más probable que experimentes problemas en los que te quedes sin conexiones disponibles o que sufras una mayor congestión de la red como consecuencia de ello.
Para mejorar esta situación, podríamos reexaminar las interacciones entre los microservicios en primer lugar. Por ejemplo, tal vez saquemos el uso de Fraud Detection
del flujo de compra principal, como se muestra en la Figura 4-4, y en su lugar hagamos que se ejecute en segundo plano. Si encuentra un problema con un cliente concreto, sus registros se actualizan en consecuencia, y esto es algo que podría comprobarse antes en el proceso de pago. Efectivamente, esto significa que estamos haciendo parte de este trabajo en paralelo. Al reducir la longitud de la cadena de llamadas, veremos cómo mejora la latencia global de la operación, y sacaremos a uno de nuestros microservicios (Fraud Detection
) de la ruta crítica del flujo de compra, con lo que tendremos una dependencia menos de la que preocuparnos en lo que es una operación crítica.
Por supuesto, también podríamos sustituir el uso de llamadas bloqueantes por algún estilo de interacción no bloqueante sin cambiar aquí el flujo de trabajo, un enfoque que exploraremos a continuación.
Patrón: No Bloqueo Asíncrono
Con la comunicación asíncrona de, el hecho de enviar una llamada a través de la red no bloquea el microservicio que la emite. Puede continuar con cualquier otro procesamiento sin tener que esperar una respuesta. La comunicación asíncrona no bloqueante se presenta de muchas formas, pero veremos con más detalle los tres estilos más comunes que veo en la arquitectura de microservicios. Son los siguientes
- Comunicación a través de datos comunes
-
El microservicio ascendente modifica algunos datos comunes, que uno o más microservicios utilizan posteriormente.
- Solicitud-respuesta
-
Un microservicio envía una solicitud a otro microservicio pidiéndole que haga algo. Cuando la operación solicitada se completa, con éxito o no, el microservicio ascendente recibe la respuesta. En concreto, cualquier instancia del microservicio ascendente debe ser capaz de gestionar la respuesta.
- Interacción basada en eventos
-
Un microservicio emite un evento, que puede considerarse como una declaración objetiva sobre algo que ha ocurrido. Otros microservicios pueden escuchar los eventos que les interesan y reaccionar en consecuencia.
Ventajas
Con la comunicación asíncrona no bloqueante, el microservicio que realiza la llamada inicial y el microservicio (o microservicios) que recibe la llamada están desacoplados temporalmente. Los microservicios que reciben la llamada no necesitan estar accesibles al mismo tiempo que se realiza la llamada. Esto significa que evitamos los problemas de desacoplamiento temporal que tratamos en el capítulo 2 (ver "Breve nota sobre el acoplamiento temporal").
Este estilo de comunicación también es beneficioso si la funcionalidad activada por una llamada va a tardar mucho tiempo en procesarse. Volvamos a nuestro ejemplo de MusicCorp, y concretamente al proceso de envío de un paquete. En la Figura 4-5, el Order Processor
ha recibido el pago y ha decidido que es hora de enviar el paquete, por lo que envía una llamada al microservicio Warehouse
. El proceso de encontrar los CD, sacarlos de la estantería, empaquetarlos y hacer que los recojan podría llevar muchas horas, y potencialmente incluso días, dependiendo de cómo funcione el proceso real de envío. Por lo tanto, tiene sentido que Order Processor
realice una llamada asíncrona no bloqueante a Warehouse
y que Warehouse
vuelva a llamar más tarde para informar a Order Processor
de su progreso. Se trata de una forma de comunicación asíncrona solicitud-respuesta.
Si intentáramos hacer algo parecido con llamadas síncronas bloqueantes, tendríamos que reestructurar las interacciones entre Order Processor
y Warehouse
-no sería factible que Order Processor
abriera una conexión, enviara una petición, bloqueara cualquier otra operación al llamar al hilo, y esperara una respuesta durante lo que podrían ser horas o días.
Desventajas
Los principales inconvenientes de la comunicación asíncrona no bloqueante, en relación con la comunicación síncrona bloqueante, son el nivel de complejidad y la variedad de opciones. Como ya hemos señalado, hay distintos estilos de comunicación asíncrona entre los que elegir: ¿cuál es el más adecuado para ti? Cuando empezamos a indagar en cómo se implementan estos diferentes estilos de comunicación, hay una lista potencialmente desconcertante de tecnología que podríamos examinar.
Si la comunicación asíncrona no se corresponde con tus modelos mentales de la informática, adoptar un estilo asíncrono de comunicación te resultará difícil al principio. Y como exploraremos más a fondo cuando analicemos en detalle los distintos estilos de comunicación asíncrona, hay muchas formas diferentes e interesantes en las que puedes meterte en muchos problemas.
Dónde utilizarlo
En última instancia, al considerar si la comunicación asíncrona es adecuada para ti, también tienes que considerar qué tipo de comunicación asíncrona quieres elegir, ya que cada tipo tiene sus propias ventajas y desventajas. En general, sin embargo, hay algunos casos de uso específicos que me harían optar por alguna forma de comunicación asíncrona. Los procesos de larga duración son un candidato obvio, como exploramos en la Figura 4-5. También podrían ser un buen candidato las situaciones en las que tengas largas cadenas de llamadas que no puedas reestructurar fácilmente. Profundizaremos en esto cuando veamos tres de las formas más comunes de comunicación asíncrona: las llamadas de petición-respuesta, la comunicación basada en eventos y la comunicación a través de datos comunes.
Patrón: Comunicación a través de datos comunes
Un estilo de comunicación de que abarca multitud de implementaciones es la comunicación a través de datos comunes. Este patrón se utiliza cuando un microservicio coloca datos en una ubicación definida, y otro microservicio (o potencialmente varios microservicios) hace uso de los datos. Puede ser tan sencillo como que un microservicio deposite un archivo en una ubicación, y en algún momento posterior otro microservicio recoja ese archivo y haga algo con él. Este estilo de integración es fundamentalmente asíncrono por naturaleza.
Un ejemplo de este estilo puede verse en la Figura 4-6, donde el New Product Importer
crea un archivo que luego será leído por losmicroservicios Inventory
y Catalog
, que se encuentran aguas abajo.
Este patrón es, en cierto modo, el patrón general de comunicación entre procesos más común que verás y, sin embargo, a veces no lo vemos como un patrón de comunicación en absoluto; creo que en gran medida se debe a que la comunicación entre procesos suele ser tan indirecta que resulta difícil de detectar.
Aplicación
Para aplicar este patrón, necesitas algún tipo de almacén persistente para los datos. En muchos casos, un sistema de archivos puede ser suficiente. He construido muchos sistemas que simplemente escanean periódicamente un sistema de archivos, observan la presencia de un nuevo archivo y reaccionan en consecuencia. También podrías utilizar algún tipo de almacén robusto de memoria distribuida, por supuesto. Cabe señalar que cualquier microservicio posterior que vaya a actuar sobre estos datos necesitará su propio mecanismo para identificar que hay nuevos datos disponibles: el sondeo es una solución frecuente a este problema.
Dos ejemplos comunes de este patrón son el lago de datos y el almacén de datos. En ambos casos, estas soluciones suelen diseñarse para ayudar a procesar grandes volúmenes de datos, pero podría decirse que se encuentran en extremos opuestos del espectro en lo que respecta al acoplamiento. Con un lago de datos, las fuentes cargan los datos en bruto en el formato que consideren oportuno, y se espera que los consumidores posteriores de estos datos en bruto sepan cómo procesar la información. Con un almacén de datos, el propio almacén es un depósito de datos estructurados. Los microservicios que envían datos al almacén de datos deben conocer la estructura del almacén de datos: si la estructura cambia de forma incompatible con el pasado, habrá que actualizar a estos productores.
Tanto con el almacén de datos como con el lago de datos, se supone que el flujo de información es en una sola dirección. Un microservicio publica datos en el almacén de datos común, y los consumidores posteriores leen esos datos y llevan a cabo las acciones apropiadas. Este flujo unidireccional puede facilitar el razonamiento sobre el flujo de información. Una implementación más problemática sería el uso de una base de datos compartida en la que varios microservicios leyeran y escribieran en el mismo almacén de datos, un ejemplo del que hablamos en el Capítulo 2 cuando exploramos el acoplamiento común: la Figura4-7 muestra tanto Order Processor
como Warehouse
actualizando el mismo registro.
Ventajas
Este patrón puede implementarse de forma muy sencilla, utilizando tecnología comúnmente entendida. Si puedes leer o escribir en un archivo o leer y escribir en una base de datos, puedes utilizar este patrón. El uso de tecnología prevalente y bien entendida también permite la interoperabilidad entre distintos tipos de sistemas, incluidas las antiguas aplicaciones de mainframe o los productos de software personalizables listos para usar (COTS). Los volúmenes de datos también son menos preocupantes en este caso: si vas a enviar muchos datos de una sola vez, este patrón puede funcionar bien.
Desventajas
Los microservicios de consumo descendente normalmente sabrán que hay nuevos datos que procesar mediante algún tipo de mecanismo de sondeo, o quizás a través de un trabajo temporizado activado periódicamente. Esto significa que es poco probable que este mecanismo sea útil en situaciones de baja latencia. Por supuesto, puedes combinar este patrón con algún otro tipo de llamada que informe a un microservicio posterior de que hay nuevos datos disponibles. Por ejemplo, podría escribir un archivo en un sistema de archivos compartido y luego enviar una llamada al microservicio interesado informándole de que hay nuevos datos que puede desear. Esto puede cerrar la brecha entre los datos que se publican y los que se procesan. En general, sin embargo, si utilizas este patrón para volúmenes muy grandes de datos, es menos probable que la baja latencia ocupe un lugar destacado en tu lista de requisitos. Si te interesa enviar mayores volúmenes de datos y que se procesen más en "tiempo real", entonces sería más adecuado utilizar algún tipo de tecnología de streaming como Kafka.
Otra gran desventaja, y algo que debería ser bastante obvio si recuerdas nuestra exploración del acoplamiento común en la Figura 4-7, es que el almacén de datos común se convierte en una fuente potencial de acoplamiento. Si ese almacén de datos cambia de estructura de algún modo, puede romper la comunicación entre microservicios.
La robustez de la comunicación también dependerá de la robustez del almacén de datos subyacente. Esto no es una desventaja estrictamente hablando, pero es algo a tener en cuenta. Si vas a soltar un archivo en un sistema de archivos, quizá quieras asegurarte de que el propio sistema de archivos no va a fallar de formas interesantes.
Dónde utilizarlo
Donde realmente brilla este patrón es al permitir la interoperabilidad entre procesos que pueden tener restricciones en cuanto a la tecnología que pueden utilizar. Hacer que un sistema existente hable con la interfaz GRPC de tu microservicio o se suscriba a su tema Kafka puede ser más conveniente desde el punto de vista del microservicio, pero no desde el punto de vista de un consumidor. Los sistemas antiguos pueden tener limitaciones en cuanto a la tecnología que pueden soportar y pueden tener elevados costes de cambio. Por otro lado, incluso los viejos sistemas mainframe deberían poder leer datos de un archivo. Por supuesto, todo esto depende de que se utilice una tecnología de almacenamiento de datos que esté ampliamente soportada; también podría implementar este patrón utilizando algo como una caché Redis. Pero, ¿puede tu antiguo sistema mainframe comunicarse con Redis?
Otro punto dulce importante para este patrón es compartir grandes volúmenes de datos. Si necesitas enviar un archivo de varios gigabytes a un sistema de archivos o cargar unos cuantos millones de filas en una base de datos, entonces este patrón es el camino a seguir.
Patrón: Comunicación Solicitud-Respuesta
Con solicitud-respuesta, un microservicio envía una solicitud a un servicio descendente pidiéndole que haga algo y espera recibir una respuesta con el resultado de la solicitud. Esta interacción puede realizarse mediante una llamada síncrona bloqueante, o podría implementarse de forma asíncrona no bloqueante. Un ejemplo sencillo de esta interacción se muestra en la Figura 4-8, donde el microservicio Chart
, que recopila los CD más vendidos de distintos géneros, envía una petición al servicio Inventory
solicitando los niveles actuales de existencias de algunos CD.
Recuperar datos de otros microservicios como éste es un caso de uso común para una llamada solicitud-respuesta. A veces, sin embargo, sólo necesitas asegurarte de que se hace algo. En la Figura 4-9, el microservicio Warehouse
recibe una solicitud de Order Processor
en la que se le pide que reserve existencias. Order Processor
sólo necesita saber que las existencias se han reservado correctamente para poder continuar con el cobro. Si no se pueden reservar las existencias -tal vez porque un artículo ya no está disponible-, se puede cancelar el pago. El uso de llamadas de solicitud-respuesta en situaciones como ésta, en las que las llamadas deben completarse en un orden determinado, es habitual.
Implementación: Síncrono frente a asíncrono
Las llamadas de solicitud-respuesta de este tipo pueden implementarse con un estilo síncrono bloqueante o asíncrono no bloqueante. Con una llamada síncrona, lo normal es que se abra una conexión de red con el microservicio descendente, y que la solicitud se envíe a través de esta conexión. La conexión se mantiene abierta mientras el microservicio ascendente espera a que el microservicio descendente responda. En este caso, el microservicio que envía la respuesta en realidad no necesita saber nada sobre el microservicio que envió la solicitud: sólo está devolviendo cosas a través de una conexión entrante. Si esa conexión muere, quizá porque muera la instancia del microservicio ascendente o descendente, entonces podríamos tener un problema.
Con una solicitud-respuesta asíncrona, las cosas son menos sencillas. Revisemos el proceso asociado a la reserva de existencias. En la Figura 4-10, la solicitud de reserva de existencias se envía como un mensaje a través de algún tipo de intermediario de mensajes (exploraremos los intermediarios de mensajes más adelante en este capítulo). En lugar de que el mensaje vaya directamente al microservicio Inventory
desde Order Processor
, se coloca en una cola. El Inventory
consume mensajes de esta cola cuando puede. Lee la solicitud, lleva a cabo el trabajo asociado de reservar el stock, y luego necesita enviar la respuesta de vuelta a una cola de la que Order Processor
está leyendo. El microservicio Inventory
necesita saber hacia dónde encaminar la respuesta. En nuestro ejemplo, envía esta respuesta de vuelta a otra cola que a su vez es consumida por Order Processor
.
Por tanto, con una interacción asíncrona no bloqueante, el microservicio que recibe la solicitud necesita saber implícitamente a dónde dirigir la respuesta o bien que se le diga a dónde debe ir la respuesta. Al utilizar una cola, tenemos la ventaja añadida de que varias solicitudes pueden almacenarse en la cola a la espera de ser gestionadas. Esto puede ser útil en situaciones en las que las solicitudes no pueden gestionarse con la suficiente rapidez. El microservicio puede consumir la siguiente solicitud cuando esté lista, en lugar de verse desbordado por demasiadas llamadas. Por supuesto, mucho depende entonces de que la cola absorba estas peticiones.
Cuando un microservicio recibe una respuesta de este modo, puede necesitar relacionar la respuesta con la solicitud original. Esto puede ser un reto, ya que puede haber pasado mucho tiempo, y dependiendo de la naturaleza del protocolo utilizado, la respuesta puede no volver a la misma instancia del microservicio que envió la solicitud. En nuestro ejemplo de reserva de existencias como parte de la realización de un pedido, necesitaríamos saber cómo asociar la respuesta "existencias reservadas" a un pedido determinado para poder seguir procesando ese pedido concreto. Una forma sencilla de hacerlo sería almacenar cualquier estado asociado a la solicitud original en una base de datos, de modo que cuando llegue la respuesta, la instancia receptora pueda recargar cualquier estado asociado y actuar en consecuencia.
Una última observación: es probable que todas las formas de interacción solicitud-respuesta requieran algún tipo de gestión del tiempo de espera para evitar problemas en los que el sistema se bloquee esperando algo que quizá nunca ocurra. La forma de implementar esta función de tiempo de espera puede variar en función de la tecnología de implementación, pero será necesaria. Veremos los tiempos de espera con más detalle en el Capítulo 12.
Dónde utilizarlo
Las llamadas solicitud-respuesta tienen mucho sentido en cualquier situación en la que se necesite el resultado de una solicitud antes de poder seguir procesándola. También encajan muy bien en situaciones en las que un microservicio quiere saber si una llamada no ha funcionado para poder llevar a cabo algún tipo de acción compensatoria, como un reintento. Si alguna de las dos se ajusta a tu situación, entonces la solicitud-respuesta es un enfoque sensato; la única cuestión que queda es decidir entre una implementación síncrona o asíncrona, con las mismas compensaciones que hemos comentado antes.
Patrón: Comunicación dirigida por eventos
La comunicación basada en eventos tiene un aspecto bastante extraño en comparación con las llamadas de petición-respuesta. En lugar de que un microservicio pida a otro que haga algo, un microservicio emite eventos que pueden o no ser recibidos por otros microservicios. Se trata de una interacción inherentemente asíncrona, ya que los escuchadores de eventos se ejecutarán en su propio hilo de ejecución.
Un evento es una declaración sobre algo que ha ocurrido, casi siempre algo que ha ocurrido dentro del mundo del microservicio que emite el evento. El microservicio que emite el evento no tiene conocimiento de la intención de otros microservicios de utilizar el evento, y de hecho puede que ni siquiera sepa que existen otros microservicios. Emite el evento cuando es necesario, y ahí acaban sus responsabilidades.
En la Figura 4-11, vemos que Warehouse
emite eventos relacionados con el proceso de empaquetado de un pedido. Estos eventos son recibidos por dos microservicios, Notifications
y Inventory
, y reaccionan en consecuencia. El microservicio Notifications
envía un correo electrónico para informar a nuestro cliente de los cambios en el estado del pedido, mientras que el microservicio Inventory
puede actualizar los niveles de existencias a medida que los artículos se empaquetan en el pedido del cliente.
La Warehouse
se limita a emitir eventos, suponiendo que las partes interesadas reaccionarán en consecuencia. Desconoce quiénes son los destinatarios de los eventos, lo que hace que las interacciones basadas en eventos estén mucho menos acopladas en general. Si comparas esto con una llamada solicitud-respuesta, puede que te cueste un poco hacerte a la idea de la inversión de la responsabilidad. Con la solicitud-respuesta, podríamos esperar que Warehouse
le dijera al microservicio Notifications
que enviara correos electrónicos cuando fuera apropiado. En un modelo así, Warehouse
necesitaría saber qué eventos requieren notificación al cliente. Con una interacción basada en eventos, en cambio, estamos trasladando esa responsabilidad al microservicio Notifications
.
La intención detrás de un evento podría considerarse lo contrario de una solicitud. El emisor del evento deja que los receptores decidan qué hacer. Con la solicitud-respuesta, el microservicio que envía la solicitud sabe lo que debe hacerse y le dice al otro microservicio lo que cree que debe ocurrir a continuación. Por supuesto, esto significa que en la solicitud-respuesta, el solicitante tiene que saber lo que puede hacer el destinatario, lo que implica un mayor grado de acoplamiento de dominio. Con la colaboración basada en eventos, el emisor de eventos no necesita saber lo que pueden hacer los microservicios descendentes y, de hecho, puede que ni siquiera sepa que existen; como resultado, el acoplamiento se reduce enormemente.
La distribución de la responsabilidad que vemos en nuestras interacciones basadas en eventos puede reflejar la distribución de la responsabilidad que vemos en las organizaciones que intentan crear equipos más autónomos. En lugar de centralizar toda la responsabilidad, queremos trasladarla a los propios equipos para que puedan funcionar de forma más autónoma, un concepto que volveremos a tratar en el Capítulo 15. En este caso, estamos trasladando la responsabilidad de Warehouse
a Notifications
y Inventory
-esto puede ayudarnos a reducir la complejidad de microservicios como Warehouse
y conducir a una distribución más uniforme de la "inteligencia" en nuestro sistema. Exploraremos esta idea con más detalle cuando comparemos coreografía y orquestación en el Capítulo 6.
Aplicación
Hay dos aspectos principales que debemos considerar aquí: una forma de que nuestros microservicios emitan eventos, y una forma de que nuestros consumidores se enteren de que esos eventos han ocurrido.
Tradicionalmente, los corredores de mensajes como RabbitMQ intentan gestionar ambos problemas. Los productores utilizan una API para publicar un evento en el intermediario. El intermediario gestiona las suscripciones, permitiendo a los consumidores ser informados cuando llega un evento. Estos intermediarios pueden incluso gestionar el estado de los consumidores, por ejemplo, ayudándoles a hacer un seguimiento de los mensajes que han visto antes. Estos sistemas se diseñan normalmente para ser escalables y resistentes, pero eso no sale gratis. Puede añadir complejidad al proceso de desarrollo, porque es otro sistema que puedes necesitar ejecutar para desarrollar y probar tus servicios. También pueden ser necesarias máquinas y conocimientos adicionales para mantener esta infraestructura en funcionamiento. Pero una vez que lo está, puede ser una forma increíblemente eficaz de implementar arquitecturas poco acopladas y basadas en eventos. En general, soy un fan.
Pero ten cuidado con el mundo del middleware, del que el intermediario de mensajes es sólo una pequeña parte. Las colas en sí mismas son cosas perfectamente sensatas y útiles. Sin embargo, los proveedores tienden a querer empaquetar un montón de software con ellas, lo que puede llevar a que se introduzcan cada vez más funciones inteligentes en el middleware, como demuestran cosas como el bus de servicios empresariales. Asegúrate de que sabes lo que obtienes: mantén tu middleware tonto, y mantén la inteligencia en los puntos finales.
Otro enfoque es intentar utilizar HTTP como forma de propagar eventos. Atom es una especificación compatible con REST que define la semántica (entre otras cosas) para publicar feeds de recursos. Existen muchas bibliotecas cliente que permiten crear y consumir estos feeds. Así, nuestro servicio de atención al cliente podría limitarse a publicar un evento en un feed de este tipo cada vez que cambie nuestro servicio de atención al cliente. Nuestros consumidores se limitarían a sondear el feed en busca de cambios. Por un lado, el hecho de que podamos reutilizar la especificación Atom existente y las bibliotecas asociadas es útil, y sabemos que HTTP maneja muy bien la escala. Sin embargo, este uso de HTTP no es bueno a baja latencia (donde sobresalen algunos corredores de mensajes), y todavía tenemos que lidiar con el hecho de que los consumidores necesitan hacer un seguimiento de los mensajes que han visto y gestionar su propio calendario de sondeo.
He visto a gente pasarse siglos implementando más y más de los comportamientos que se obtienen fuera de la caja con un corredor de mensajes adecuado para hacer que Atom funcione para algunos casos de uso. Por ejemplo, el patrón de consumidor en competencia describe un método por el que haces surgir varias instancias de trabajador para que compitan por los mensajes, lo que funciona bien para escalar el número de trabajadores con el fin de gestionar una lista de trabajos independientes (volveremos sobre ello en el próximo capítulo). Sin embargo, queremos evitar el caso en que dos o más trabajadores vean el mismo mensaje, ya que acabaremos haciendo la misma tarea más veces de las necesarias. Con un corredor de mensajes, una cola estándar se encargará de esto. Con Atom, ahora tenemos que gestionar nuestro propio estado compartido entre todos los trabajadores para intentar reducir las posibilidades de reproducir el esfuerzo.
Si ya dispones de un corredor de mensajes bueno y resistente, considera la posibilidad de utilizarlo para gestionar la publicación y suscripción a eventos. Si aún no tienes uno, échale un vistazo a Atom, pero ten en cuenta la falacia del coste hundido. Si te das cuenta de que cada vez quieres más y más del soporte que te proporciona un agente de mensajes, en un momento dado puede que quieras cambiar de enfoque.
En cuanto a lo que enviamos realmente a través de estos protocolos asíncronos, se aplican las mismas consideraciones que con la comunicación síncrona. Si estás satisfecho con la codificación de solicitudes y respuestas mediante JSON, sigue con ella.
¿Qué hay en un Evento?
En Figura 4-12, vemos cómo se emite un evento desde el microservicio Customer
, informando a las partes interesadas de que un nuevo cliente se ha registrado en el sistema. Dos de los microservicios descendentes, Loyalty
y Notifications
, se interesan por este evento. El microservicio Loyalty
reacciona al recibir el evento creando una cuenta para el nuevo cliente para que pueda empezar a ganar puntos, mientras que el microservicio Notifications
envía un correo electrónico al cliente recién registrado dándole la bienvenida a las maravillosas delicias de MusicCorp.
Con una solicitud, estamos pidiendo a un microservicio que haga algo y proporcionando la información necesaria para que se lleve a cabo la operación solicitada. Con un evento, estamos transmitiendo un hecho que puede interesar a otras partes, pero como el microservicio que emite un evento no puede ni debe saber quién recibe el evento, ¿cómo sabemos qué información pueden necesitar otras partes del evento? ¿Qué debe contener exactamente el evento?
Sólo una identificación
Una opción es que el evento sólo contenga un identificador del cliente recién registrado, como se muestra en la Figura 4-13. El microservicio Loyalty
sólo necesita este identificador para crear la cuenta de fidelización correspondiente, por lo que dispone de toda la información que necesita. Sin embargo, aunque el microservicio Notifications
sabe que tiene que enviar un correo electrónico de bienvenida cuando se recibe este tipo de evento, necesitará información adicional para hacer su trabajo: al menos una dirección de correo electrónico, y probablemente también el nombre del cliente para dar al correo electrónico ese toque personal. Como esta información no está en el evento que recibe el microservicio Notifications
, no tiene más remedio que obtenerla del microservicio Customer
, algo que vemos en la Figura 4-13.
Este enfoque tiene algunos inconvenientes. En primer lugar, el microservicio Notifications
ahora tiene que conocer el microservicio Customer
, lo que añade un acoplamiento de dominio adicional. Aunque el acoplamiento de dominio, tal y como expusimos en el Capítulo 2, se encuentra en el extremo más laxo del espectro del acoplamiento, nos gustaría evitarlo siempre que sea posible. Si el evento que recibiera el microservicio de Notification
contuviera toda la información que necesita, no sería necesaria esta llamada de retorno. La devolución de llamada del microservicio receptor también puede dar lugar al otro gran inconveniente, a saber, que en una situación con un gran número de microservicios receptores, el microservicio que emite el evento podría recibir un aluvión de peticiones como resultado. Imagina que cinco microservicios diferentes recibieran el mismo evento de creación de cliente y todos necesitaran solicitar información adicional: todos tendrían que enviar inmediatamente una solicitud al microservicio Customer
para obtener lo que necesitan. A medida que aumenta el número de microservicios interesados en un evento concreto, el impacto de estas llamadas podría llegar a ser significativo.
Eventos totalmente detallados
La alternativa, que yo prefiero, es poner en un evento todo lo que de otro modo te parecería bien compartir a través de una API. Si dejas que el microservicio Notifications
pida la dirección de correo electrónico y el nombre de un cliente determinado, ¿por qué no poner esa información en el evento en primer lugar? En la Figura 4-14, vemos este enfoque:Notification
s es ahora más autosuficiente y puede hacer su trabajo sin necesidad de comunicarse con el microservicio Customer
. De hecho, puede que nunca necesite saber que el microservicio Customer
existe.
Además de que los eventos con más información pueden permitir un acoplamiento más suelto, los eventos con más información también pueden servir como registro histórico de lo ocurrido a una entidad determinada. Esto podría ayudarte a implantar un sistema de auditoría, o tal vez incluso proporcionar la capacidad de reconstituir una entidad en determinados momentos, lo que significa que estos eventos podrían utilizarse como parte de una fuente de eventos, un concepto que exploraremos brevemente dentro de un momento.
Aunque este enfoque es sin duda mi preferencia, no está exento de inconvenientes. En primer lugar, si los datos asociados a un evento son grandes, nos puede preocupar el tamaño del evento. Los corredores de mensajes modernos (suponiendo que utilices uno para implementar tu mecanismo de difusión de eventos) tienen límites bastante generosos para el tamaño de los mensajes; el tamaño máximo por defecto de un mensaje en Kafka es de 1 MB, y la última versión de RabbitMQ tiene un límite superior teórico de 512 MB para un solo mensaje (¡desde el límite anterior de 2 GB!), aunque cabría esperar que hubiera algunos problemas de rendimiento interesantes con mensajes grandes como éste. Pero incluso el 1 MB que se nos concede como tamaño máximo de un mensaje en Kafka nos da mucho margen para enviar bastantes datos. En última instancia, si te estás aventurando en un espacio en el que empiezas a preocuparte por el tamaño de tus eventos, yo recomendaría un enfoque híbrido en el que parte de la información esté en el evento, pero otros datos (más grandes) puedan consultarse si es necesario.
En la Figura 4-14, Loyalty
no necesita conocer la dirección de correo electrónico ni el nombre del cliente, y sin embargo los recibe a través del evento. Esto podría plantear problemas si intentamos limitar el alcance de qué microservicios pueden ver qué tipo de datos; por ejemplo, podría querer limitar qué microservicios pueden ver información personal identificable (o IPI), detalles de tarjetas de pago o datos sensibles similares. Una forma de resolver esto podría ser enviar dos tipos diferentes de eventos: uno que contenga PII y pueda ser visto por algunos microservicios, y otro que excluya la PII y pueda ser difundido más ampliamente. Esto añade complejidad en cuanto a la gestión de la visibilidad de los distintos eventos y a garantizar que ambos eventos se disparen realmente. ¿Qué ocurre cuando un microservicio envía el primer tipo de evento pero muere antes de que se pueda enviar el segundo?
Otra consideración es que, una vez que introducimos datos en un evento, pasan a formar parte de nuestro contrato con el mundo exterior. Tenemos que ser conscientes de que si eliminamos un campo de un evento, podemos quebrantar a las partes externas. La ocultación de información sigue siendo un concepto importante en la colaboración basada en eventos: cuantos más datos pongamos en un evento, más suposiciones tendrán las partes externas sobre el evento. Mi regla general es que me parece bien incluir información en un evento si me parece bien compartir los mismos datos a través de una API de solicitud-respuesta.
Dónde utilizarlo
La colaboración basada en eventos prospera en situaciones en las que la información quiere ser difundida, y en situaciones en las que te complace invertir la intención. Alejarse de un modelo de decirle a otras cosas lo que tienen que hacer y, en su lugar, dejar que los microservicios descendentes lo resuelvan por sí mismos tiene un gran atractivo.
En una situación en la que te centras en el acoplamiento débil más que en otros factores, la colaboración basada en eventos va a tener un atractivo obvio.
La nota de precaución es que a menudo hay nuevas fuentes de complejidad que salen a la luz con este estilo de colaboración, especialmente si has tenido una exposición limitada a él. Si no estás seguro de esta forma de comunicación, recuerda que nuestra arquitectura de microservicios puede contener (y probablemente contendrá) una mezcla de diferentes estilos de interacción. No hace falta que te lances de lleno a la colaboración basada en eventos; quizá puedas empezar con un solo evento y seguir a partir de ahí.
Personalmente, me encuentro gravitando hacia la colaboración basada en eventos casi por defecto. Parece que mi cerebro se ha reconfigurado de tal manera que estos tipos de comunicación me parecen obvios. Esto no es del todo útil, ya que puede ser complicado intentar explicar por qué es así, aparte de decir que me parece lo correcto. Pero esto no es más que mi propio prejuicio: me inclino de forma natural hacia lo que conozco, basándome en mis propias experiencias. Es muy posible que mi atracción por esta forma de interacción se deba casi exclusivamente a mis malas experiencias anteriores con sistemas excesivamente acoplados. Puede que sólo sea el general que libra la última batalla una y otra vez sin considerar que quizás esta vez sea realmente diferente.
Lo que sí diré, dejando a un lado mis propios prejuicios, es que veo muchos más equipos que sustituyen las interacciones solicitud-respuesta por interacciones basadas en eventos que lo contrario.
Procede con precaución
Algunos de estas cosas asíncronas parecen divertidas, ¿verdad? Las arquitecturas basadas en eventos parecen conducir a sistemas significativamente más desacoplados y escalables. Y pueden hacerlo. Pero estos estilos de comunicación conllevan un aumento de la complejidad. No se trata sólo de la complejidad necesaria para gestionar la publicación y suscripción a mensajes, como acabamos de comentar, sino también de la complejidad en los demás problemas a los que nos podemos enfrentar. Por ejemplo, al considerar una solicitud-respuesta asíncrona de larga duración, tenemos que pensar qué hacer cuando vuelve la respuesta. ¿Regresa al mismo nodo que inició la solicitud? Si es así, ¿qué ocurre si ese nodo está caído? Si no, ¿necesito almacenar información en algún sitio para poder reaccionar en consecuencia? La asincronización de corta duración puede ser más fácil de gestionar si tienes las API adecuadas, pero aun así, es una forma diferente de pensar para los programadores que están acostumbrados a las llamadas de mensajes síncronos intraproceso.
Es hora de un cuento con moraleja. En 2006, trabajaba en la creación de un sistema de fijación de precios para un banco. Observábamos los acontecimientos del mercado y determinábamos qué elementos de una cartera debían revalorizarse. Una vez que determinábamos la lista de cosas sobre las que trabajar, las poníamos todas en una cola de mensajes. Utilizábamos una rejilla para crear un grupo de trabajadores de fijación de precios, lo que nos permitía ampliar y reducir la granja de precios a petición. Estos trabajadores utilizaban el patrón de consumidores en competencia, cada uno engullendo mensajes lo más rápido posible hasta que no quedaba nada por procesar.
El sistema estaba en marcha y nos sentíamos bastante satisfechos. Sin embargo, un día, justo después de publicar una versión, nos encontramos con un grave problema: nuestros trabajadores seguían muriendo. Y morían. Y morían.
Finalmente, localizamos el problema. Se había introducido un error por el que un determinado tipo de solicitud de fijación de precios provocaba la caída de un trabajador. Utilizábamos una cola transaccionada: cuando el trabajador moría, su bloqueo sobre la solicitud caducaba, y la solicitud de tarificación volvía a la cola, sólo para que otro trabajador la recogiera y muriera. Se trataba de un ejemplo clásico de lo que Martin Fowler denomina una conmutación por error catastrófica.
Aparte del fallo en sí, no habíamos especificado un límite máximo de reintentos para el trabajo en la cola. Así que arreglamos el fallo y configuramos un reintento máximo. Pero también nos dimos cuenta de que necesitábamos una forma de ver y, potencialmente, reproducir estos mensajes erróneos. Acabamos teniendo que implementar un hospital de mensajes (o cola de letra muerta), donde los mensajes se enviaban si fallaban. También creamos una interfaz de usuario para ver esos mensajes y reintentarlos si era necesario. Este tipo de problemas no son evidentes a primera vista si sólo estás familiarizado con la comunicación síncrona punto a punto.
La complejidad asociada a las arquitecturas basadas en eventos y a la programación asíncrona en general me lleva a pensar que debes ser cauteloso en cuanto a la impaciencia con la que empiezas a adoptar estas ideas. Asegúrate de que dispones de un buen sistema de monitoreo, y considera seriamente el uso de identificadores de correlación, que te permiten rastrear las peticiones a través de los límites de los procesos, como veremos en profundidad en el Capítulo 10.
También recomiendo encarecidamente consultar Enterprise Integration Patterns, de Gregor Hohpe y Bobby Woolf,4 que contiene muchos más detalles sobre los distintos patrones de mensajería que puedes considerar en este espacio.
Pero también tenemos que ser honestos sobre los estilos de integración que podríamos considerar "más sencillos": los problemas asociados a saber si las cosas han funcionado o no no se limitan a las formas asíncronas de integración. Con una llamada síncrona bloqueante, si se produce un tiempo de espera, ¿se debe a que la solicitud se perdió y la parte descendente no la recibió? ¿O la solicitud llegó, pero la respuesta se perdió? ¿Qué haces en esa situación? Si vuelves a intentarlo, pero la solicitud original sí llegó, ¿qué haces? (Aquí es donde entra en juego la idempotencia, tema que tratamos en el Capítulo 12).
Podría decirse que, en lo que respecta a la gestión de fallos, las llamadas de bloqueo síncronas pueden causarnos tantos dolores de cabeza como las anteriores a la hora de averiguar si las cosas han sucedido (o no). Sólo que esos dolores de cabeza pueden resultarnos más familiares.
Resumen
En este capítulo, he desglosado algunos de los estilos clave de comunicación entre microservicios y he hablado de las distintas ventajas y desventajas. No siempre hay una única opción correcta, pero espero haber detallado suficiente información sobre las llamadas síncronas y asíncronas y los estilos de comunicación basados en eventos y de solicitud-respuesta para ayudarte a tomar la decisión correcta en tu contexto. Mis propios prejuicios hacia la colaboración asíncrona y basada en eventos son una función no sólo de mis experiencias, sino también de mi aversión al acoplamiento en general. Pero este estilo de comunicación conlleva una complejidad significativa que no puede ignorarse, y cada situación es única.
En este capítulo, he mencionado brevemente algunas tecnologías específicas que pueden utilizarse para implementar estos estilos de interacción. Ahora estamos preparados para comenzar la segunda parte de este libro: la implementación. En el próximo capítulo exploraremos más a fondo la implementación de la comunicación entre microservicios.
1 Historia real.
2 Maarten van Steen y Andrew S. Tanenbaum, Distributed Systems, 3ª ed. (Scotts Valley, CA: CreateSpace Independent Publishing Platform, 2017).
3 Ten en cuenta que esto está muy simplificado: he omitido por completo el código de gestión de errores, por ejemplo. Si quieres saber más sobre async/await, concretamente en JavaScript, el Tutorial moderno de JavaScript es un buen lugar para empezar.
4 Gregor Hohpe y Bobby Woolf, Enterprise Integration Patterns (Boston: Addison-Wesley, 2003).
Get Construyendo Microservicios, 2ª Edición now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.