Patrones arquitectónicos convenientes

¡Hola, Habr!

En vista de los acontecimientos actuales debido al coronavirus, varios servicios de Internet han comenzado a recibir una mayor carga. Por ejemplo, Una de las cadenas minoristas del Reino Unido simplemente cerró su sitio de pedidos en línea., porque no había suficiente capacidad. Y no siempre es posible acelerar un servidor simplemente agregando equipos más potentes, pero las solicitudes de los clientes deben procesarse (o irán a parar a la competencia).

En este artículo hablaré brevemente sobre prácticas populares que le permitirán crear un servicio rápido y tolerante a fallos. Sin embargo, de los posibles esquemas de desarrollo, seleccioné solo aquellos que actualmente están fácil de usar. Para cada elemento, tiene bibliotecas listas para usar o tiene la oportunidad de resolver el problema utilizando una plataforma en la nube.

Escalado horizontal

El punto más simple y conocido. Convencionalmente, los dos esquemas de distribución de carga más comunes son el escalamiento horizontal y vertical. En un caso de permanente permite que los servicios se ejecuten en paralelo, distribuyendo así la carga entre ellos. En el segundo solicita servidores más potentes u optimiza el código.

Por ejemplo, tomaré el almacenamiento de archivos en la nube abstracto, es decir, algún análogo de OwnCloud, OneDrive, etc.

A continuación se muestra una imagen estándar de dicho circuito, pero solo demuestra la complejidad del sistema. Después de todo, necesitamos sincronizar de alguna manera los servicios. ¿Qué pasa si el usuario guarda un archivo desde la tableta y luego quiere verlo desde el teléfono?

Patrones arquitectónicos convenientes
La diferencia entre los enfoques: en el escalado vertical, estamos listos para aumentar la potencia de los nodos, y en el escalado horizontal, estamos listos para agregar nuevos nodos para distribuir la carga.

CQRS

Segregación de responsabilidad de consultas de comando Un patrón bastante importante, ya que permite a diferentes clientes no sólo conectarse a diferentes servicios, sino también recibir los mismos flujos de eventos. Sus beneficios no son tan obvios para una aplicación simple, pero son extremadamente importantes (y simples) para un servicio ocupado. Su esencia: los flujos de datos entrantes y salientes no deben cruzarse. Es decir, no puede enviar una solicitud y esperar una respuesta; en cambio, envía una solicitud al servicio A, pero recibe una respuesta del servicio B.

La primera ventaja de este enfoque es la capacidad de interrumpir la conexión (en el sentido amplio de la palabra) mientras se ejecuta una solicitud larga. Por ejemplo, tomemos una secuencia más o menos estándar:

  1. El cliente envió una solicitud al servidor.
  2. El servidor inició un largo tiempo de procesamiento.
  3. El servidor respondió al cliente con el resultado.

Imaginemos que en el punto 2 se cortó la conexión (o la red se volvió a conectar, o el usuario fue a otra página rompiendo la conexión). En este caso, será difícil para el servidor enviar una respuesta al usuario con información sobre qué se procesó exactamente. Usando CQRS, la secuencia será ligeramente diferente:

  1. El cliente se ha suscrito a las actualizaciones.
  2. El cliente envió una solicitud al servidor.
  3. El servidor respondió "solicitud aceptada".
  4. El servidor respondió con el resultado a través del canal del punto “1”.

Patrones arquitectónicos convenientes

Como puede ver, el esquema es un poco más complejo. Además, aquí falta el enfoque intuitivo de solicitud-respuesta. Sin embargo, como puede ver, una interrupción de la conexión mientras se procesa una solicitud no provocará un error. Además, si efectivamente el usuario está conectado al servicio desde varios dispositivos (por ejemplo, desde un teléfono móvil y desde una tableta), puedes asegurarte de que la respuesta llegue a ambos dispositivos.

Curiosamente, el código para procesar mensajes entrantes se vuelve el mismo (no al 100%) tanto para eventos influenciados por el propio cliente como para otros eventos, incluidos los de otros clientes.

Sin embargo, en realidad obtenemos una ventaja adicional debido al hecho de que el flujo unidireccional se puede manejar de forma funcional (usando RX y similares). Y esto ya es una gran ventaja, ya que, en esencia, la aplicación se puede hacer completamente reactiva y también utilizando un enfoque funcional. Para los programas gordos, esto puede ahorrar significativamente recursos de desarrollo y soporte.

Si combinamos este enfoque con el escalado horizontal, como beneficio adicional, obtenemos la capacidad de enviar solicitudes a un servidor y recibir respuestas de otro. De esta manera, el cliente puede elegir el servicio que más le convenga y el sistema interno aún podrá procesar los eventos correctamente.

Abastecimiento de eventos

Como saben, una de las principales características de un sistema distribuido es la ausencia de un tiempo común, una sección crítica común. Para un proceso, puede realizar una sincronización (en los mismos mutex), dentro de la cual está seguro de que nadie más está ejecutando este código. Sin embargo, esto es peligroso para un sistema distribuido, ya que requerirá una sobrecarga y también acabará con toda la belleza del escalado: todos los componentes seguirán esperando a uno.

De aquí obtenemos un hecho importante: un sistema distribuido rápido no se puede sincronizar porque entonces reduciremos el rendimiento. Por otro lado, muchas veces necesitamos cierta coherencia entre los componentes. Y para esto puedes usar el enfoque con eventual consistencia, donde se garantiza que si no hay cambios en los datos durante un período de tiempo después de la última actualización (“eventualmente”), todas las consultas devolverán el último valor actualizado.

Es importante comprender que para las bases de datos clásicas se utiliza con bastante frecuencia. consistencia fuerte, donde cada nodo tiene la misma información (esto a menudo se logra en el caso en que la transacción se considera establecida solo después de que el segundo servidor responde). Aquí hay algunas relajaciones debido al nivel de aislamiento, pero la idea general sigue siendo la misma: se puede vivir en un mundo completamente armonizado.

Sin embargo, volvamos a la tarea original. Si parte del sistema se puede construir con eventual consistencia, entonces podemos construir el siguiente diagrama.

Patrones arquitectónicos convenientes

Características importantes de este enfoque:

  • Cada solicitud entrante se coloca en una cola.
  • Mientras procesa una solicitud, el servicio también puede colocar tareas en otras colas.
  • Cada evento entrante tiene un identificador (que es necesario para la deduplicación).
  • La cola funciona ideológicamente según el esquema de "solo agregar". No puede eliminar elementos ni reorganizarlos.
  • La cola funciona según el esquema FIFO (perdón por la tautología). Si necesita realizar una ejecución paralela, en un momento debe mover objetos a diferentes colas.

Permítanme recordarles que estamos considerando el caso del almacenamiento de archivos en línea. En este caso, el sistema se verá así:

Patrones arquitectónicos convenientes

Es importante que los servicios del diagrama no signifiquen necesariamente un servidor independiente. Incluso el proceso puede ser el mismo. Otra cosa es importante: ideológicamente, estas cosas están separadas de tal manera que se puede aplicar fácilmente una escala horizontal.

Y para dos usuarios el diagrama se verá así (los servicios destinados a diferentes usuarios se indican en diferentes colores):

Patrones arquitectónicos convenientes

Bonificaciones de tal combinación:

  • Los servicios de procesamiento de información están separados. Las colas también están separadas. Si necesitamos aumentar el rendimiento del sistema, entonces sólo necesitamos lanzar más servicios en más servidores.
  • Cuando recibimos información de un usuario, no tenemos que esperar hasta que los datos se guarden por completo. Al contrario, basta con responder “ok” y poco a poco empezar a trabajar. Al mismo tiempo, la cola suaviza los picos, ya que la adición de un nuevo objeto se produce rápidamente y el usuario no tiene que esperar a que se complete todo el ciclo.
  • Como ejemplo, agregué un servicio de deduplicación que intenta fusionar archivos idénticos. Si funciona durante mucho tiempo en el 1% de los casos, el cliente apenas lo notará (ver arriba), lo cual es una gran ventaja, ya que ya no estamos obligados a ser XNUMX% rápidos y confiables.

Sin embargo, las desventajas son inmediatamente visibles:

  • Nuestro sistema ha perdido su estricta coherencia. Esto significa que si, por ejemplo, se suscribe a diferentes servicios, teóricamente puede obtener un estado diferente (ya que es posible que uno de los servicios no tenga tiempo de recibir una notificación de la cola interna). Otra consecuencia es que el sistema ya no tiene hora común. Es decir, es imposible, por ejemplo, ordenar todos los eventos simplemente por hora de llegada, ya que los relojes entre servidores pueden no estar sincronizados (además, la misma hora en dos servidores es una utopía).
  • Ahora ningún evento se puede revertir simplemente (como se podría hacer con una base de datos). En su lugar, debe agregar un nuevo evento: evento de compensación, que cambiará el último estado al requerido. Como ejemplo de un área similar: sin reescribir el historial (lo cual es malo en algunos casos), no puedes revertir una confirmación en git, pero puedes hacer una especial compromiso de reversión, que esencialmente simplemente devuelve el estado anterior. Sin embargo, tanto la confirmación errónea como la reversión permanecerán en la historia.
  • El esquema de datos puede cambiar de una versión a otra, pero los eventos antiguos ya no podrán actualizarse al nuevo estándar (ya que, en principio, los eventos no se pueden cambiar).

Como puede ver, Event Sourcing funciona bien con CQRS. Además, implementar un sistema con colas eficientes y convenientes, pero sin separar los flujos de datos, ya es difícil en sí mismo, porque habrá que agregar puntos de sincronización que neutralizarán todo el efecto positivo de las colas. Al aplicar ambos enfoques a la vez, es necesario ajustar ligeramente el código del programa. En nuestro caso, al enviar un archivo al servidor, la respuesta es solo “ok”, lo que solo significa que “se guardó la operación de agregar el archivo”. Formalmente, esto no significa que los datos ya estén disponibles en otros dispositivos (por ejemplo, el servicio de deduplicación puede reconstruir el índice). Sin embargo, después de un tiempo, el cliente recibirá una notificación con el estilo "el archivo X se ha guardado".

Como resultado:

  • El número de estados de envío de archivos está aumentando: en lugar del clásico "archivo enviado", aparecen dos: "el archivo se ha agregado a la cola en el servidor" y "el archivo se ha guardado en el almacenamiento". Esto último significa que otros dispositivos ya pueden empezar a recibir el archivo (ajustado por el hecho de que las colas funcionan a diferentes velocidades).
  • Debido a que la información de envío ahora llega a través de diferentes canales, necesitamos encontrar soluciones para recibir el estado de procesamiento del archivo. Como consecuencia de esto: a diferencia de la clásica solicitud-respuesta, el cliente se puede reiniciar mientras procesa el archivo, pero el estado de este procesamiento en sí será correcto. Además, este artículo funciona, esencialmente, desde el primer momento. Como consecuencia: ahora somos más tolerantes con los fracasos.

Sharding

Como se describió anteriormente, los sistemas de abastecimiento de eventos carecen de una coherencia estricta. Esto significa que podemos utilizar varios almacenamientos sin ninguna sincronización entre ellos. Al abordar nuestro problema, podemos:

  • Separe los archivos por tipo. Por ejemplo, se pueden decodificar imágenes/vídeos y seleccionar un formato más eficiente.
  • Cuentas separadas por país. Debido a muchas leyes, esto puede ser necesario, pero este esquema de arquitectura brinda esa oportunidad automáticamente.

Patrones arquitectónicos convenientes

Si desea transferir datos de un almacenamiento a otro, los medios estándar ya no son suficientes. Desafortunadamente, en este caso, debe detener la cola, realizar la migración y luego iniciarla. En el caso general, los datos no se pueden transferir "sobre la marcha", sin embargo, si la cola de eventos está almacenada por completo y tiene instantáneas de estados de almacenamiento anteriores, entonces podemos reproducir los eventos de la siguiente manera:

  • En Event Source, cada evento tiene su propio identificador (idealmente, no decreciente). Esto significa que podemos agregar un campo al almacenamiento: la identificación del último elemento procesado.
  • Duplicamos la cola para que todos los eventos puedan procesarse para varios almacenamientos independientes (el primero es aquel en el que los datos ya están almacenados y el segundo es nuevo, pero aún está vacío). La segunda cola, por supuesto, aún no se está procesando.
  • Lanzamos la segunda cola (es decir, comenzamos a reproducir eventos).
  • Cuando la nueva cola esté relativamente vacía (es decir, la diferencia de tiempo promedio entre agregar un elemento y recuperarlo es aceptable), puede comenzar a cambiar los lectores al nuevo almacenamiento.

Como puede ver, no teníamos, ni todavía tenemos, una coherencia estricta en nuestro sistema. Sólo existe una coherencia eventual, es decir, una garantía de que los eventos se procesen en el mismo orden (pero posiblemente con diferentes retrasos). Y, al usarlo, podemos transferir datos con relativa facilidad sin detener el sistema al otro lado del mundo.

Por lo tanto, siguiendo con nuestro ejemplo sobre el almacenamiento de archivos en línea, dicha arquitectura ya nos brinda una serie de ventajas:

  • Podemos acercar objetos a los usuarios de forma dinámica. De esta manera podrá mejorar la calidad del servicio.
  • Podemos almacenar algunos datos dentro de las empresas. Por ejemplo, los usuarios empresariales a menudo requieren que sus datos se almacenen en centros de datos controlados (para evitar fugas de datos). Mediante la fragmentación podemos admitir esto fácilmente. Y la tarea es aún más sencilla si el cliente dispone de una nube compatible (por ejemplo, Azure autohospedado).
  • Y lo más importante es que no tenemos que hacer esto. Después de todo, para empezar, estaríamos muy contentos con un almacenamiento para todas las cuentas (para empezar a trabajar rápidamente). Y la característica clave de este sistema es que, aunque es ampliable, en la etapa inicial es bastante sencillo. Simplemente no es necesario escribir inmediatamente código que funcione con un millón de colas independientes, etc. Si es necesario, esto se podrá hacer en el futuro.

Alojamiento de contenido estático

Este punto puede parecer bastante obvio, pero sigue siendo necesario para una aplicación cargada más o menos estándar. Su esencia es simple: todo el contenido estático se distribuye no desde el mismo servidor donde se encuentra la aplicación, sino desde servidores especiales dedicados específicamente a esta tarea. Como resultado, estas operaciones se realizan más rápido (nginx condicional sirve archivos más rápidamente y menos costoso que un servidor Java). Además de la arquitectura CDN (Red de entrega de contenidos) nos permite ubicar nuestros archivos más cerca de los usuarios finales, lo que tiene un efecto positivo en la comodidad de trabajar con el servicio.

El ejemplo más simple y estándar de contenido estático es un conjunto de scripts e imágenes para un sitio web. Con ellos, todo es simple: se conocen de antemano y luego el archivo se carga en los servidores CDN, desde donde se distribuyen a los usuarios finales.

Sin embargo, en realidad, para contenido estático, puede utilizar un enfoque algo similar a la arquitectura lambda. Volvamos a nuestra tarea (almacenamiento de archivos en línea), en la que necesitamos distribuir archivos a los usuarios. La solución más sencilla es crear un servicio que, para cada solicitud del usuario, haga todas las comprobaciones necesarias (autorización, etc.) y luego descargue el archivo directamente desde nuestro almacenamiento. La principal desventaja de este enfoque es que el contenido estático (y un archivo con una determinada revisión es, de hecho, contenido estático) lo distribuye el mismo servidor que contiene la lógica empresarial. En su lugar, puedes hacer el siguiente diagrama:

  • El servidor proporciona una URL de descarga. Puede tener la forma file_id + clave, donde clave es una minifirma digital que otorga derecho a acceder al recurso durante las próximas XNUMX horas.
  • El archivo se distribuye mediante nginx simple con las siguientes opciones:
    • Almacenamiento en caché de contenido. Dado que este servicio se puede ubicar en un servidor separado, nos hemos dejado una reserva para el futuro con la capacidad de almacenar en el disco todos los archivos descargados más recientes.
    • Comprobación de la clave en el momento de la creación de la conexión.
  • Opcional: procesamiento de contenidos en streaming. Por ejemplo, si comprimimos todos los archivos en el servicio, podemos descomprimirlos directamente en este módulo. Como consecuencia: las operaciones IO se realizan donde pertenecen. Un archivador en Java asignará fácilmente mucha memoria adicional, pero reescribir un servicio con lógica empresarial en condicionales Rust/C++ también puede resultar ineficaz. En nuestro caso, se utilizan diferentes procesos (o incluso servicios) y, por lo tanto, podemos separar de manera bastante efectiva la lógica empresarial y las operaciones de IO.

Patrones arquitectónicos convenientes

Este esquema no es muy similar a distribuir contenido estático (ya que no cargamos el paquete estático completo en alguna parte), pero en realidad, este enfoque se ocupa precisamente de distribuir datos inmutables. Además, este esquema se puede generalizar a otros casos en los que el contenido no es simplemente estático, sino que se puede representar como un conjunto de bloques inmutables y no eliminables (aunque se pueden agregar).

Como otro ejemplo (para reforzar): si ha trabajado con Jenkins/TeamCity, entonces sabrá que ambas soluciones están escritas en Java. Ambos son procesos de Java que manejan tanto la orquestación de compilaciones como la gestión de contenidos. En particular, ambos tienen tareas como "transferir un archivo/carpeta desde el servidor". Como ejemplo: emisión de artefactos, transferencia de código fuente (cuando el agente no descarga el código directamente del repositorio, sino que el servidor lo hace por él), acceso a registros. Todas estas tareas se diferencian en su carga de IO. Es decir, resulta que el servidor responsable de la lógica empresarial compleja debe al mismo tiempo ser capaz de impulsar de forma eficaz grandes flujos de datos a través de sí mismo. Y lo más interesante es que dicha operación se puede delegar al mismo nginx exactamente de la misma manera (excepto que la clave de datos debe agregarse a la solicitud).

Sin embargo, si volvemos a nuestro sistema, obtenemos un diagrama similar:

Patrones arquitectónicos convenientes

Como puede verse, el sistema se ha vuelto radicalmente más complejo. Ahora ya no es sólo un miniproceso que almacena archivos localmente. Ahora lo que se requiere no es el soporte más simple, control de versiones de API, etc. Por lo tanto, una vez dibujados todos los diagramas, es mejor evaluar en detalle si la extensibilidad vale el costo. Sin embargo, si desea poder ampliar el sistema (incluso para trabajar con un número aún mayor de usuarios), tendrá que buscar soluciones similares. Pero, como resultado, el sistema está preparado arquitectónicamente para una mayor carga (casi todos los componentes se pueden clonar para escalamiento horizontal). El sistema se puede actualizar sin detenerlo (simplemente algunas operaciones se ralentizarán ligeramente).

Como dije al principio, ahora varios servicios de Internet han comenzado a recibir una mayor carga. Y algunos de ellos simplemente empezaron a dejar de funcionar correctamente. De hecho, los sistemas fallaron precisamente en el momento en que se suponía que la empresa ganaría dinero. Es decir, en lugar de aplazar la entrega, en lugar de sugerir a los clientes "planificar su entrega para los próximos meses", el sistema simplemente decía "ir a sus competidores". De hecho, éste es el precio de la baja productividad: las pérdidas ocurrirán precisamente cuando las ganancias serían mayores.

Conclusión

Todos estos enfoques se conocían antes. El mismo VK lleva mucho tiempo utilizando la idea del alojamiento de contenido estático para mostrar imágenes. Muchos juegos en línea utilizan el esquema Sharding para dividir a los jugadores en regiones o separar ubicaciones del juego (si el mundo en sí lo es). El enfoque de Event Sourcing se utiliza activamente en el correo electrónico. La mayoría de las aplicaciones comerciales en las que se reciben datos constantemente se basan en un enfoque CQRS para poder filtrar los datos recibidos. Bueno, el escalado horizontal se ha utilizado en muchos servicios durante bastante tiempo.

Sin embargo, lo más importante es que todos estos patrones se han vuelto muy fáciles de aplicar en aplicaciones modernas (si son apropiadas, por supuesto). Las nubes ofrecen fragmentación y escalamiento horizontal de inmediato, lo cual es mucho más fácil que solicitar diferentes servidores dedicados en diferentes centros de datos. CQRS se ha vuelto mucho más fácil, aunque sólo sea gracias al desarrollo de bibliotecas como RX. Hace unos 10 años, un sitio web poco común podía respaldar esto. Event Sourcing también es increíblemente fácil de configurar gracias a los contenedores ya preparados con Apache Kafka. Hace 10 años esto habría sido una innovación, ahora es algo común. Lo mismo ocurre con el alojamiento de contenido estático: gracias a tecnologías más convenientes (incluido el hecho de que existe documentación detallada y una gran base de datos de respuestas), este enfoque se ha vuelto aún más sencillo.

Como resultado, la implementación de una serie de patrones arquitectónicos bastante complejos se ha vuelto mucho más simple, lo que significa que es mejor examinarlos más de cerca con anticipación. Si en una aplicación de hace diez años se abandonó una de las soluciones anteriores debido al alto costo de implementación y operación, ahora, en una nueva aplicación, o después de la refactorización, puede crear un servicio que ya será arquitectónicamente extensible ( en términos de rendimiento) y preparados para nuevas solicitudes de los clientes (por ejemplo, para localizar datos personales).

Y lo más importante: no utilice estos enfoques si tiene una aplicación sencilla. Sí, son hermosos e interesantes, pero para un sitio con una visita máxima de 100 personas, a menudo puedes arreglártelas con un monolito clásico (al menos por fuera, todo el interior se puede dividir en módulos, etc.).

Fuente: habr.com

Añadir un comentario