¿Por qué empezamos con los mensajes?
Cuando comenzamos nuestro viaje hacia contextos delimitados, queríamos mantener el pequeño equipo, centrado, con el que habíamos disfrutado como un solo equipo apoyando a un monolito. Una estrategia que adoptamos fue limitar nuestras dependencias entre los equipos y, por extensión, los diferentes contextos delimitados. No queríamos introducir un acoplamiento temporal de tiempo de ejecución entre los componentes desarrollados por los diferentes equipos porque eso reduciría la autonomía de estos equipos y limitaría nuestra capacidad de desarrollar los productos que queríamos desarrollar de la manera en que queríamos desarrollarlos.
Por ejemplo, imagina que un equipo está desarrollando una API y tienen como objetivo un tiempo de respuesta de 50 milisegundos para el 98% de las solicitudes. Si tienen que depender del tiempo de ejecución de otro equipo que tiene un tiempo de respuesta de 20 milisegundos para el 90% de las solicitudes, pero un tiempo de respuesta promedio de 200 milisegundos para el resto de las solicitudes. El primer equipo no tiene ninguna esperanza de lograr razonablemente su objetivo por su cuenta. Puede que sean capaces de poner algo de trabajo técnico en el equipo que proporcionó el API original.

A fin de evitar el problema del acoplamiento de contextos cruzados y limitar la posibilidad de que las interrupciones parciales se conviertan en fallos en cascada al desglosar nuestro monolito en contextos delimitados independientes, elegimos un modelo de integración asincrónico primero. En ese momento, teníamos un pequeño equipo con experiencia limitada en arquitecturas basadas en mensajes, por lo que no establecimos muchas reglas sobre cómo se debía llevar a cabo. Lo que sabíamos en ese momento era que necesitábamos una infraestructura de mensajería común, el nombramiento es realmente importante, los mensajes deben ser idempáticos, y que tendríamos que adaptarnos a medida que ganáramos experiencia.
¿Cómo emitimos los mensajes?
Todos nuestros mensajes son emitidos usando un patrón de mensajes de publicación/suscripción.
Elegimos a RabbitMQ como nuestro agente de mensajes. Un par de miembros de nuestro equipo tenían experiencia en su manejo en producción y nos pareció una elección razonable basada en nuestros criterios:- Evitar un solo punto de fallo (apoya la agrupación)- Publicar/Suscribirse (a través de intercambios de fanout)- Entrega fiable (con mensajes duraderos)- Equipos de desarrollo políglota (AMQP tiene un amplio apoyo)
Como teníamos equipos trabajando en múltiples idiomas y en múltiples pilas, elegimos el mínimo común denominador para el formato del mensaje: Documentos UTF-8 JSON.
No hicimos ninguna regla sobre qué forma deben tener los mensajes o cómo deben ser usados.
¿Cómo nombramos los mensajes?
Inicialmente elegimos pensar en los mensajes como eventos comerciales que ocurren dentro de un contexto delimitado y que podrían ser de interés dentro de otro contexto delimitado. Nuestra estrategia de denominación refleja eso. También estábamos en un período de rápido crecimiento a través de adquisiciones y no sabíamos cómo integrar a todos nuestros nuevos socios. En lugar de adivinar, lo tuvimos en cuenta en nuestra convención de nombres.
Nombramos todos nuestros eventos/mensajes/intercambios editoriales de la siguiente manera:
ps.bounded-context.event-name.vX
- ps es la organización o empresa que es la fuente de este mensaje.
- El contexto delimitado es el nombre del contexto (y estos pueden ser mapeados a equipos).
- El nombre del evento es un nombre en tiempo pasado y semántico para lo que pasó.
- X es un valor entero que indica la versión. v0 es un borrador de mensaje.
También tenemos una convención de nombres para las colas de suscriptores:
ps.bounded-context.event-name.vX= >org.other-context.listener-name
- ps.bounded-context.event-name.vX es el nombre completo del intercambio al que está ligada la cola.
- org es la organización o empresa que está consumiendo este mensaje.
- otro contexto es el nombre del contexto suscriptor (y estos pueden ser mapeados a los equipos).
- El nombre del oyente es una cadena utilizada por el suscriptor para identificar el proceso de recepción de los mensajes.
Todo este rigor nos ayuda a saber quién está publicando qué y quién está escuchando con el objetivo principal de facilitar la comunicación entre esos equipos cuando hay un descubrimiento, cambios o malentendidos.
Versionado de mensajes
Como cualquier ingeniero experimentado sabe, tu primera suposición es raramente tu mejor suposición, así que empezamos con la suposición de que necesitaríamos cambiar nuestros mensajes con el tiempo. Como estamos usando los cuerpos de los mensajes JSON, añadir campos es un cambio que no se rompe, pero quitar campos, renombrar campos o cambiar tipos de datos típicamente sí lo es. Cuando un equipo hace un cambio no rompedor en un mensaje, no hay necesidad de incrementar el número de versión.
Cuando se requiera un cambio de última hora, ese equipo publicará el nuevo mensaje en un nuevo intercambio mientras continúa publicando el antiguo mensaje. Esta fase de publicación dual permite a los consumidores migrar de lo viejo a lo nuevo sin un impacto serio. Esperamos que cada consumidor de un mensaje sea un buen ciudadano en el sistema y migre a los nuevos mensajes de manera oportuna. Cuando no hay más consumidores del viejo mensaje, dejamos de publicarlo y limpiamos el código y la infraestructura. Ningún equipo debe publicar 3 versiones del mismo mensaje al mismo tiempo.
Estos mensajes se consideran como borradores y pueden evolucionar rápidamente sin tener en cuenta las repercusiones para los consumidores. Cuando el editor y los suscriptores han llegado a un acuerdo sobre la forma del mensaje, se pasa a la v1 y entran en vigor las normas estándar para los cambios.
¿Qué pasó después?
Durante los siguientes años, desmontamos nuestro monolito en más de 30 contextos separados. Los equipos responsables de cada uno de ellos comenzaron a publicar mensajes. Otros equipos se suscribieron a esos mensajes. Todo parecía funcionar.
Encontramos que los equipos usaban los mensajes principalmente para dos propósitos:1. Procesar la coreografía1. Replicación de datos
Mientras trabajaban para lograr estos propósitos, se encontraban con tres problemas comunes:1. Datos de Bootstrapping1. Mensajes perdidos1. Mensajes fuera de orden
Mensajes para la coreografía del proceso
El uso de mensajes para la coreografía de procesos asíncronos es una clara victoria. Cuando se pueden deconstruir los procesos temporalmente, se puede entregar valor más rápidamente y fácilmente desde un sistema distribuido. Para un ejemplo, por favor vea el clásico de Gregor Hohpe: Your Coffee Shop Doesn$0027t Use Two-Phase Commit.
Los mensajes de este tipo tienden a tener nombres como:- usuario-firmado en- tarjeta de crédito- usuario-localización-detectado-curso-añadido al-canal-curso-eliminado-del-canal
El cuerpo de este estilo de mensaje se limita típicamente a los datos relevantes a ese evento. Por ejemplo, el curso agregado al canal tendría:- el ID del canal- el ID del curso- un URI al recurso del canal (lo que ayuda a un contexto acotado que no tiene ninguna información para este canal, todavía)- la marca de tiempo cuando el curso fue agregado, por supuesto
Estos mensajes inician o continúan un proceso como vender a un usuario una suscripción a nuestro producto o actualizar un caché de datos locales de la fuente de la verdad.
Mensajes para la replicación de datos
Compartir una base de datos con otro equipo es a menudo una receta para el dolor. Hacer cambios en el recurso compartido requiere planificación y coordinación y, la mayoría de las veces, provoca retrasos en la entrega de las características. Cuando las barreras para el cambio se vuelven demasiado altas, a las personas inteligentes se les ocurren ideas inteligentes como almacenar múltiples tipos de datos en un solo campo. Debido a muchas experiencias dolorosas con este tipo de comportamiento, nos impusimos una dura regla de que evitaríamos el acoplamiento estructural, de protocolo, temporal y de acceso que viene de compartir un esquema de base de datos. Por supuesto, este límite creó un nuevo problema: ¿cómo comparten los equipos que necesitan los mismos datos?
Un ejemplo es nuestro catálogo de cursos. Sólo un contexto limitado es la fuente de la verdad para los cursos, pero la mayor parte del sistema sólo necesita acceso de lectura a los datos del curso. Una respuesta simple, arquitectónicamente consistente, se presentó inmediatamente. ¡Podemos publicar un mensaje cada vez que se actualiza un curso! Así es como nació ps.monolith.course-updated.v1.
Nombrar los mensajes del estilo de replicación de datos es tan fácil que se puede hacer de forma programada:- usuario-actualizado- curso-actualizado-canal-actualizado
El cuerpo del mensaje también es bastante sencillo:- una única entidad serializada JSON
Estos mensajes se publican por código de repositorio justo después de que la base de datos devuelve la escritura. Los suscriptores deserializan el objeto y escriben los campos de interés en su almacén de datos local. En ese momento, no parecía haber ningún problema con el uso de nuestra infraestructura de mensajería para este tipo de replicación de datos. A medida que más equipos seguían esta estrategia, nos dimos cuenta de un par de problemas con el uso de la mensajería para la replicación de datos.
Datos de Bootstrapping
El primer problema que encontramos es cómo obtener el conjunto inicial de datos. Si estás suscrito a un flujo, obtendrás todas las nuevas actualizaciones, pero si algo no ha cambiado, ni siquiera sabrás que existe. La solución obvia fue usar patrones ETL bien conocidos para empezar y confiar en el flujo de mensajes para mantenerse al día.
Mensajes perdidos
Si se pierde un mensaje, puede terminar con registros faltantes en su almacén de datos local. Cuando esto sucede, necesitas una forma de curar tu base de datos local. Hemos desarrollado nuestro sistema para incluir API de recursos que pueden ser llamados por los suscriptores de los mensajes si (y sólo si) les faltan datos en su base de datos local. Esto condujo al siguiente patrón para todos los datos que se dominan en otro contexto limitado:
publicvoidProcessMessage(FooUpdatedMessagemessage){WriteFooToDB(BuildFooFromMessage(message)); }publicFooLoadFoo(stringfooId){varfoo=GetFooFromDB(fooId);if(foo==null){foo=GetFooFromAPI(fooId);WriteFooToDB(foo);}returnfoo;}
Mensajes Fuera de Orden
Después de resolver el problema de los datos faltantes, aprendimos que a veces se pueden publicar múltiples actualizaciones para la misma entidad en rápida sucesión. Con múltiples procesadores de mensajes en diferentes máquinas es posible que procesemos estos mensajes fuera de servicio, lo que conduce a datos erróneos en la base de datos local. Si no hay mensajes adicionales para activar otra actualización, los datos en el disco no son eventualmente consistentes, simplemente son eternamente erróneos.
Nuestra solución a este problema fue añadir un TTL a cada registro. Si lees los datos que están más allá del TTL, entonces regresa a la API como si no tuvieras datos para esa entidad.
Algunos equipos intentaron otra opción para evitar el problema del procesamiento fuera de servicio. En lugar de incluir la entidad serializada en sus mensajes actualizados, sólo incluyen la URI del recurso. Esto significa que el procesamiento de los mensajes es mucho más parlanchín, ya que cada mensaje requiere una solicitud HTTP para obtener los datos, pero siempre se recupera la última versión de los datos, lo que hace que los mensajes de replicación de datos sean más idempáticos.
Planes actuales para la replicación de datos
Después de haber adquirido experiencia en la forma en que nuestros equipos estaban implementando la pauta de «asincronía-primero», nos dimos cuenta de que el mayor problema que tenemos es la replicación de datos. También estaba claro que nuestras soluciones existentes eran insuficientes para la causa. Actualmente estamos explorando mecanismos de replicación de datos más robustos, incluyendo el uso del registro de commits distribuido por Apache Kafka y los esclavos de lectura que dependen de la replicación tradicional de la base de datos. A medida que estos experimentos nacientes se desarrollen y se adopten con más equipos, seguiremos aprendiendo y compartiendo nuestro aprendizaje.
Otra estrategia que exploraríamos es la creación de caches compartidos de datos comunes. Por ejemplo, la mayoría de las partes de nuestra aplicación necesitan acceso a nuestra biblioteca de contenidos. ¿Deberíamos haber creado una base de datos SQL con esclavos de sólo lectura en cada contexto delimitado? ¿O tal vez cargar el estado actual de cada objeto de contenido en un cubo S3? Evitamos esto por el tipo de acoplamiento que introduce, así como por las dificultades para tener éxito en la modelización de datos a nivel empresarial. Incluso si se consigue que el modelo de datos de la empresa sea correcto la primera vez, evolucionarlo se hace muy difícil.
¿Qué haríamos diferente si empezáramos de nuevo?
Una cosa de la que seríamos más conscientes es la necesidad de datos en todos los contextos. Una pregunta que hemos empezado a hacer cuando nos dividimos es: «Para tomar decisiones dentro de este contexto, ¿necesitamos acceder a todos los datos para los que otro contexto es la fuente de la verdad?». Si la respuesta es afirmativa, la pregunta de seguimiento es si estamos dividiendo los contextos delimitados en una veta natural o en una artificial.
Conclusión
Hemos visto muchos beneficios al adoptar un enfoque asincrónico de integración entre equipos y entre contextos. Nuestros equipos pueden (en su mayoría) diseñar, desarrollar y entregar características y valor a nuestro usuario sin interferir unos con otros. Todavía estamos aprendiendo a medida que nuestro sistema evoluciona. A medida que aprendemos, evolucionamos nuestros patrones y recomendaciones.
Categorías: technicalTags: architecture, messaging