Patróns arquitectónicos cómodos

Ola Habr!

Á luz dos acontecementos actuais debido ao coronavirus, unha serie de servizos de Internet comezaron a recibir unha maior carga. Por exemplo, Unha das cadeas de venda polo miúdo do Reino Unido simplemente deixou o seu sitio de pedidos en liña., porque non había capacidade suficiente. E non sempre é posible acelerar un servidor simplemente engadindo equipos máis potentes, pero as solicitudes dos clientes deben ser procesadas (ou irán aos competidores).

Neste artigo falarei brevemente de prácticas populares que che permitirán crear un servizo rápido e tolerante a fallos. Non obstante, entre os posibles esquemas de desenvolvemento, seleccionei só os que están actualmente doado de usar. Para cada elemento, tes bibliotecas preparadas ou tes a oportunidade de resolver o problema usando unha plataforma na nube.

Escalado horizontal

O punto máis sinxelo e coñecido. Convencionalmente, os dous esquemas de distribución de carga máis comúns son o escalado horizontal e vertical. No primeiro caso permite que os servizos funcionen en paralelo, distribuíndo así a carga entre eles. No segundo ordenas servidores máis potentes ou optimizas o código.

Por exemplo, tomarei almacenamento de ficheiros abstractos na nube, é dicir, algún análogo de OwnCloud, OneDrive, etc.

A continuación móstrase unha imaxe estándar deste circuíto, pero só demostra a complexidade do sistema. Despois de todo, necesitamos sincronizar os servizos dalgún xeito. Que pasa se o usuario garda un ficheiro da tableta e despois quere velo desde o teléfono?

Patróns arquitectónicos cómodos
A diferenza entre os enfoques: no escalado vertical, estamos preparados para aumentar a potencia dos nodos, e no escalado horizontal, estamos preparados para engadir novos nodos para distribuír a carga.

CQRS

Segregación de responsabilidades de consulta de comandos Un patrón bastante importante, xa que permite que diferentes clientes non só se conecten a diferentes servizos, senón que tamén reciban os mesmos fluxos de eventos. Os seus beneficios non son tan obvios para unha aplicación sinxela, pero é extremadamente importante (e sinxelo) para un servizo ocupado. A súa esencia: os fluxos de datos entrantes e saíntes non deben cruzarse. É dicir, non pode enviar unha solicitude e esperar unha resposta; en cambio, envía unha solicitude ao servizo A, pero recibe unha resposta do servizo B.

A primeira vantaxe deste enfoque é a capacidade de romper a conexión (no sentido amplo da palabra) mentres se executa unha solicitude longa. Por exemplo, tomemos unha secuencia máis ou menos estándar:

  1. O cliente enviou unha solicitude ao servidor.
  2. O servidor comezou un longo tempo de procesamento.
  3. O servidor respondeu ao cliente co resultado.

Imaxinemos que no punto 2 a conexión foi cortada (ou a rede volveuse a conectar, ou o usuario foi a outra páxina, rompendo a conexión). Neste caso, será difícil para o servidor enviar unha resposta ao usuario con información sobre o que se procesou exactamente. Usando CQRS, a secuencia será lixeiramente diferente:

  1. O cliente subscribiuse ás actualizacións.
  2. O cliente enviou unha solicitude ao servidor.
  3. O servidor respondeu "solicitude aceptada".
  4. O servidor respondeu co resultado a través da canle do punto "1".

Patróns arquitectónicos cómodos

Como podes ver, o esquema é un pouco máis complexo. Ademais, aquí falta o enfoque intuitivo solicitude-resposta. Non obstante, como podes ver, unha interrupción da conexión ao procesar unha solicitude non provocará un erro. Ademais, se de feito o usuario está conectado ao servizo desde varios dispositivos (por exemplo, desde un teléfono móbil e desde unha tableta), podes asegurarte de que a resposta chega a ambos os dous dispositivos.

Curiosamente, o código para procesar as mensaxes entrantes pasa a ser o mesmo (non 100%) tanto para eventos que foron influenciados polo propio cliente como para outros eventos, incluídos os doutros clientes.

Non obstante, en realidade obtemos unha bonificación adicional debido ao feito de que o fluxo unidireccional pódese xestionar nun estilo funcional (usando RX e similares). E isto xa é unha vantaxe importante, xa que, en esencia, a aplicación pódese facer completamente reactiva e tamén mediante un enfoque funcional. Para os programas gordos, isto pode aforrar significativamente recursos de desenvolvemento e apoio.

Se combinamos este enfoque co escalado horizontal, como extra, temos a posibilidade de enviar solicitudes a un servidor e recibir respostas doutro. Así, o cliente pode escoller o servizo que lle convén, e o sistema interno aínda poderá procesar os eventos correctamente.

Abastecemento de eventos

Como sabedes, unha das principais características dun sistema distribuído é a ausencia dun tempo común, dunha sección crítica común. Para un proceso, podes facer unha sincronización (nos mesmos mutexes), dentro da cal estás seguro de que ninguén máis está a executar este código. Non obstante, isto é perigoso para un sistema distribuído, xa que requirirá unha sobrecarga e tamén matará toda a beleza do escalado: todos os compoñentes aínda esperarán por un.

A partir de aquí obtemos un feito importante: un sistema distribuído rápido non se pode sincronizar, porque entón reduciremos o rendemento. Por outra banda, moitas veces necesitamos unha certa consistencia entre os compoñentes. E para iso podes usar o enfoque con consistencia eventual, onde se garante que se non hai cambios de datos durante algún período de tempo despois da última actualización ("eventualmente"), todas as consultas devolverán o último valor actualizado.

É importante entender que para as bases de datos clásicas úsase con bastante frecuencia consistencia forte, onde cada nodo ten a mesma información (isto conséguese a miúdo no caso de que a transacción se considere establecida só despois de que o segundo servidor responda). Hai algunhas relaxacións aquí debido aos niveis de illamento, pero a idea xeral segue a ser a mesma: podes vivir nun mundo completamente harmonizado.

Non obstante, volvamos á tarefa orixinal. Se parte do sistema se pode construír con consistencia eventual, entón podemos construír o seguinte diagrama.

Patróns arquitectónicos cómodos

Características importantes deste enfoque:

  • Cada solicitude entrante colócase nunha cola.
  • Mentres procesa unha solicitude, o servizo tamén pode colocar tarefas noutras filas.
  • Cada evento entrante ten un identificador (que é necesario para a deduplicación).
  • A cola funciona ideoloxicamente segundo o esquema "só anexar". Non pode eliminar elementos del nin reorganizalos.
  • A cola funciona segundo o esquema FIFO (perdón pola tautoloxía). Se necesitas facer unha execución paralela, nun momento debes mover obxectos a diferentes filas.

Permíteme recordarche que estamos considerando o caso do almacenamento de ficheiros en liña. Neste caso, o sistema terá un aspecto así:

Patróns arquitectónicos cómodos

É importante que os servizos do diagrama non signifiquen necesariamente un servidor separado. Incluso o proceso pode ser o mesmo. Outra cousa é importante: ideoloxicamente, estas cousas están separadas de tal xeito que a escala horizontal se pode aplicar facilmente.

E para dous usuarios o diagrama terá este aspecto (os servizos destinados a diferentes usuarios indícanse en cores diferentes):

Patróns arquitectónicos cómodos

Bonos desta combinación:

  • Os servizos de tratamento da información están separados. As colas tamén están separadas. Se necesitamos aumentar o rendemento do sistema, só necesitamos lanzar máis servizos en máis servidores.
  • Cando recibimos información dun usuario, non temos que esperar ata que os datos estean completamente gardados. Pola contra, só temos que responder "ok" e despois comezar a traballar aos poucos. Ao mesmo tempo, a cola suaviza os picos, xa que a adición dun novo obxecto ocorre rapidamente e o usuario non ten que esperar a que pase por completo todo o ciclo.
  • Como exemplo, engadín un servizo de deduplicación que tenta combinar ficheiros idénticos. Se funciona durante moito tempo nun 1% dos casos, o cliente case non o notará (ver arriba), o que é unha gran vantaxe, xa que xa non se nos obriga a ser XNUMX% rápidos e fiables.

Non obstante, as desvantaxes son inmediatamente visibles:

  • O noso sistema perdeu a súa estrita consistencia. Isto significa que se, por exemplo, te subscribes a diferentes servizos, teoricamente podes obter un estado diferente (xa que un dos servizos pode non ter tempo para recibir unha notificación da cola interna). Como outra consecuencia, o sistema agora non ten tempo común. É dicir, é imposible, por exemplo, ordenar todos os eventos simplemente pola hora de chegada, xa que os reloxos entre servidores poden non ser sincrónicos (ademais, a mesma hora en dous servidores é unha utopía).
  • Agora non se pode retrotraer ningún evento (como se podería facer cunha base de datos). Pola contra, cómpre engadir un novo evento − evento de compensación, que cambiará o último estado ao requirido. Como exemplo dunha área similar: sen reescribir o historial (o que é malo nalgúns casos), non podes revertir un commit en git, pero podes facer un confirmación de retroceso, que esencialmente só devolve o estado antigo. Non obstante, tanto a confirmación errónea como a reversión permanecerán na historia.
  • O esquema de datos pode cambiar de versión en versión, pero os eventos antigos xa non se poderán actualizar ao novo estándar (xa que os eventos non se poden cambiar en principio).

Como podes ver, Event Sourcing funciona ben con CQRS. Ademais, implantar un sistema con colas eficientes e cómodas, pero sen separar os fluxos de datos, xa é difícil en si mesmo, porque haberá que engadir puntos de sincronización que neutralicen todo o efecto positivo das colas. Aplicando ambos enfoques á vez, é necesario axustar lixeiramente o código do programa. No noso caso, ao enviar un ficheiro ao servidor, a resposta vén só "ok", o que só significa que "a operación de engadir o ficheiro foi gardada". Formalmente, isto non significa que os datos xa estean dispoñibles noutros dispositivos (por exemplo, o servizo de deduplicación pode reconstruír o índice). Non obstante, despois dun tempo, o cliente recibirá unha notificación co estilo de "gardause o ficheiro X".

Como resultado:

  • O número de estados de envío de ficheiros está aumentando: en lugar do clásico "ficheiro enviado", obtemos dous: "o ficheiro engadiuse á cola do servidor" e "o ficheiro gardouse no almacenamento". Isto último significa que outros dispositivos xa poden comezar a recibir o ficheiro (axustado ao feito de que as colas funcionan a diferentes velocidades).
  • Debido ao feito de que a información de envío agora chega por diferentes canles, cómpre dar solucións para recibir o estado de tramitación do ficheiro. Como consecuencia diso: a diferenza da clásica solicitude-resposta, o cliente pódese reiniciar mentres se procesa o ficheiro, pero o estado deste procesamento será correcto. Ademais, este elemento funciona, esencialmente, fóra da caixa. Como consecuencia: agora somos máis tolerantes cos fracasos.

Esgalla

Como se describiu anteriormente, os sistemas de aprovisionamento de eventos carecen de coherencia estrita. Isto significa que podemos usar varios almacenamentos sen ningunha sincronización entre eles. Achegándonos ao noso problema, podemos:

  • Separa os ficheiros por tipo. Por exemplo, pódense descodificar imaxes/vídeos e seleccionar un formato máis eficiente.
  • Contas separadas por país. Debido a moitas leis, isto pode ser necesario, pero este esquema de arquitectura ofrece esa oportunidade automaticamente

Patróns arquitectónicos cómodos

Se queres transferir datos dun almacenamento a outro, os medios estándar xa non son suficientes. Desafortunadamente, neste caso, cómpre deter a cola, facer a migración e despois iniciala. No caso xeral, os datos non se poden transferir "sobre a marcha", non obstante, se a cola de eventos se almacena completamente e tes instantáneas dos estados de almacenamento anteriores, poderemos reproducir os eventos do seguinte xeito:

  • En Orixe do evento, cada evento ten o seu propio identificador (idealmente, non decrecente). Isto significa que podemos engadir un campo ao almacenamento: o id do último elemento procesado.
  • Duplicamos a cola para que todos os eventos poidan ser procesados ​​para varios almacenamentos independentes (o primeiro é aquel no que xa están almacenados os datos, e o segundo é novo, pero aínda está baleiro). A segunda cola, por suposto, aínda non se está procesando.
  • Lanzamos a segunda cola (é dicir, comezamos a reproducir eventos).
  • Cando a nova cola estea relativamente baleira (é dicir, a diferenza de tempo media entre engadir un elemento e recuperalo é aceptable), podes comezar a cambiar de lector ao novo almacenamento.

Como podedes ver, non tiñamos, e aínda non temos, unha coherencia estrita no noso sistema. Só existe consistencia eventual, é dicir, unha garantía de que os acontecementos se tramitan na mesma orde (pero posiblemente con diferentes atrasos). E, usando isto, podemos transferir datos con relativa facilidade sen deter o sistema ao outro lado do globo.

Así, seguindo co noso exemplo sobre o almacenamento en liña para ficheiros, unha arquitectura deste tipo xa nos ofrece unha serie de bonos:

  • Podemos achegar obxectos aos usuarios dun xeito dinámico. Deste xeito pode mellorar a calidade do servizo.
  • Podemos almacenar algúns datos dentro das empresas. Por exemplo, os usuarios de empresas adoitan precisar que os seus datos se almacenen en centros de datos controlados (para evitar fugas de datos). A través do sharding podemos apoiar isto facilmente. E a tarefa é aínda máis sinxela se o cliente ten unha nube compatible (por exemplo, Azure autoaloxado).
  • E o máis importante é que non temos que facelo. Despois de todo, para comezar, estaríamos moi satisfeitos cun almacenamento único para todas as contas (para comezar a traballar rapidamente). E a característica fundamental deste sistema é que aínda que é ampliable, na fase inicial é bastante sinxelo. Non tes que escribir inmediatamente código que funcione con un millón de colas independentes, etc. Se é necesario, pódese facer no futuro.

Aloxamento de contido estático

Este punto pode parecer bastante obvio, pero aínda é necesario para unha aplicación cargada máis ou menos estándar. A súa esencia é sinxela: todo o contido estático distribúese non desde o mesmo servidor onde se atopa a aplicación, senón desde outros especiais dedicados especificamente a esta tarefa. Como resultado, estas operacións realízanse máis rápido (nginx condicional serve ficheiros máis rápido e menos custoso que un servidor Java). Plus arquitectura CDN (Rede de entrega de contidos) permítenos localizar os nosos ficheiros máis preto dos usuarios finais, o que ten un efecto positivo na comodidade de traballar co servizo.

O exemplo máis sinxelo e estándar de contido estático é un conxunto de scripts e imaxes para un sitio web. Todo é sinxelo con eles: coñécense de antemán, despois o arquivo súbese aos servidores CDN, desde onde se distribúen aos usuarios finais.

Non obstante, en realidade, para o contido estático, pode usar un enfoque algo similar á arquitectura lambda. Volvamos á nosa tarefa (almacenamento de ficheiros en liña), na que necesitamos distribuír ficheiros aos usuarios. A solución máis sinxela é crear un servizo que, para cada solicitude do usuario, faga todas as comprobacións necesarias (autorización, etc.), e despois descargue o arquivo directamente do noso almacenamento. A principal desvantaxe deste enfoque é que o contido estático (e un ficheiro cunha determinada revisión é, de feito, contido estático) distribúese o mesmo servidor que contén a lóxica empresarial. Pola contra, podes facer o seguinte diagrama:

  • O servidor proporciona un URL de descarga. Pode ser do formato file_id + key, onde key é unha minisinatura dixital que dá dereito a acceder ao recurso durante as próximas XNUMX horas.
  • O ficheiro distribúese por nginx simple coas seguintes opcións:
    • Almacenamento en caché de contido. Dado que este servizo pódese localizar nun servidor separado, deixámonos unha reserva para o futuro coa posibilidade de almacenar todos os últimos ficheiros descargados no disco.
    • Comprobando a clave no momento da creación da conexión
  • Opcional: procesamento de contido en streaming. Por exemplo, se comprimimos todos os ficheiros do servizo, podemos descomprimir directamente neste módulo. Como consecuencia: as operacións de E/S fanse onde corresponden. Un arquivador en Java asignará facilmente moita memoria extra, pero reescribir un servizo con lóxica empresarial en condicionais Rust/C++ tamén pode ser ineficaz. No noso caso, utilízanse diferentes procesos (ou incluso servizos) e, polo tanto, podemos separar de forma bastante eficaz a lóxica empresarial e as operacións de E/S.

Patróns arquitectónicos cómodos

Este esquema non é moi semellante á distribución de contido estático (xa que non cargamos todo o paquete estático nalgún lugar), pero en realidade, este enfoque precísase precisamente de distribuír datos inmutables. Ademais, este esquema pódese xeneralizar a outros casos nos que o contido non é simplemente estático, senón que se pode representar como un conxunto de bloques inmutables e non borrables (aínda que se poden engadir).

Como outro exemplo (para reforzo): se traballaches con Jenkins/TeamCity, sabes que ambas solucións están escritas en Java. Ambos son un proceso Java que se encarga tanto da orquestración da compilación como da xestión de contidos. En particular, ambos teñen tarefas como "transferir un ficheiro/cartafol do servidor". Como exemplo: emisión de artefactos, transferencia de código fonte (cando o axente non descarga o código directamente do repositorio, pero o servidor faino por el), acceso aos rexistros. Todas estas tarefas difiren na súa carga de E/S. É dicir, resulta que o servidor responsable da lóxica empresarial complexa debe ser capaz, ao mesmo tempo, de impulsar de forma eficaz grandes fluxos de datos por si mesmo. E o máis interesante é que tal operación pódese delegar no mesmo nginx segundo exactamente o mesmo esquema (excepto que a clave de datos debe engadirse á solicitude).

Non obstante, se volvemos ao noso sistema, obtemos un diagrama similar:

Patróns arquitectónicos cómodos

Como podes ver, o sistema volveuse radicalmente máis complexo. Agora non é só un mini-proceso que almacena ficheiros localmente. Agora o que se require non é o soporte máis sinxelo, o control de versións da API, etc. Polo tanto, despois de debuxar todos os diagramas, é mellor avaliar en detalle se a extensibilidade paga a pena o custo. Non obstante, se queres poder ampliar o sistema (incluíndo traballar cun número aínda maior de usuarios), terás que buscar solucións similares. Pero, como resultado, o sistema está arquitectónicamente preparado para aumentar a carga (case todos os compoñentes pódense clonar para a escala horizontal). O sistema pódese actualizar sen detelo (simplemente algunhas operacións ralentizaranse lixeiramente).

Como dixen ao principio, agora unha serie de servizos de Internet comezaron a recibir unha maior carga. E algúns deles simplemente comezaron a deixar de funcionar correctamente. De feito, os sistemas fallaron precisamente no momento no que se suponía que o negocio debía gañar cartos. É dicir, en lugar de entrega aprazada, en lugar de suxerir aos clientes "planifique a súa entrega para os próximos meses", o sistema simplemente dixo "vaia aos seus competidores". De feito, este é o prezo da baixa produtividade: as perdas produciranse precisamente cando os beneficios serían máis altos.

Conclusión

Todos estes enfoques eran coñecidos antes. O mesmo VK leva moito tempo usando a idea de Aloxamento de contido estático para mostrar imaxes. Moitos xogos en liña usan o esquema Sharding para dividir os xogadores en rexións ou para separar as localizacións dos xogos (se o mundo é un). O enfoque de abastecemento de eventos úsase activamente no correo electrónico. A maioría das aplicacións comerciais nas que se reciben datos constantemente están construídas nun enfoque CQRS para poder filtrar os datos recibidos. Ben, a escala horizontal utilizouse en moitos servizos durante bastante tempo.

Non obstante, o máis importante é que todos estes patróns volvéronse moi fáciles de aplicar en aplicacións modernas (se son apropiados, por suposto). As nubes ofrecen Sharding e escalado horizontal de inmediato, o que é moito máis sinxelo que pedir vostede mesmo servidores dedicados en diferentes centros de datos. CQRS fíxose moito máis sinxelo, aínda que só sexa polo desenvolvemento de bibliotecas como RX. Hai uns 10 anos, un sitio web raro podía soportar isto. O aprovisionamento de eventos tamén é incriblemente sinxelo de configurar grazas aos contedores preparados con Apache Kafka. Hai 10 anos isto sería unha innovación, agora é algo habitual. O mesmo ocorre co aloxamento de contido estático: debido ás tecnoloxías máis convenientes (incluído o feito de que hai documentación detallada e unha gran base de datos de respostas), este enfoque fíxose aínda máis sinxelo.

Como resultado, a implementación dunha serie de patróns arquitectónicos bastante complexos agora fíxose moito máis sinxela, o que significa que é mellor examinalo con máis detalle con antelación. Se nunha aplicación de dez anos de antigüidade se abandonou unha das solucións anteriores debido ao alto custo de implantación e funcionamento, agora, nunha nova aplicación, ou despois da refactorización, pódese crear un servizo que xa será arquitectónicamente tanto extensible ( en termos de rendemento) e preparado para novas solicitudes dos clientes (por exemplo, para localizar datos persoais).

E o máis importante: non uses estes enfoques se tes unha aplicación sinxela. Si, son bonitos e interesantes, pero para un sitio cun pico de visitas de 100 persoas, moitas veces podes pasar cun monolito clásico (polo menos no exterior, todo o interior pódese dividir en módulos, etc.).

Fonte: www.habr.com

Engadir un comentario