Padrões arquitetônicos convenientes

Oi, Habr!

À luz dos acontecimentos atuais devido ao coronavírus, vários serviços de Internet começaram a receber maior carga. Por exemplo, Uma das cadeias retalhistas do Reino Unido simplesmente encerrou o seu site de encomendas online., porque não havia capacidade suficiente. E nem sempre é possível acelerar um servidor simplesmente adicionando equipamentos mais potentes, mas as solicitações dos clientes devem ser processadas (ou irão para os concorrentes).

Neste artigo falarei brevemente sobre práticas populares que permitirão criar um serviço rápido e tolerante a falhas. Contudo, dos possíveis esquemas de desenvolvimento, selecionei apenas aqueles que estão atualmente fácil de usar. Para cada item, você tem bibliotecas prontas ou tem a oportunidade de resolver o problema usando uma plataforma em nuvem.

Escala horizontal

O ponto mais simples e conhecido. Convencionalmente, os dois esquemas de distribuição de carga mais comuns são o escalonamento horizontal e o vertical. No primeiro caso você permite que os serviços sejam executados em paralelo, distribuindo assim a carga entre eles. No segundo você solicita servidores mais poderosos ou otimiza o código.

Por exemplo, usarei o armazenamento abstrato de arquivos em nuvem, ou seja, algum análogo de OwnCloud, OneDrive e assim por diante.

Uma imagem padrão desse circuito está abaixo, mas apenas demonstra a complexidade do sistema. Afinal, precisamos sincronizar de alguma forma os serviços. O que acontece se o usuário salvar um arquivo no tablet e depois quiser visualizá-lo no telefone?

Padrões arquitetônicos convenientes
A diferença entre as abordagens: no escalonamento vertical, estamos prontos para aumentar a potência dos nós, e no escalonamento horizontal, estamos prontos para adicionar novos nós para distribuir a carga.

CQRS

Segregação de responsabilidade de consulta de comando Um padrão bastante importante, pois permite que diferentes clientes não apenas se conectem a diferentes serviços, mas também recebam os mesmos fluxos de eventos. Seus benefícios não são tão óbvios para uma aplicação simples, mas são extremamente importantes (e simples) para um serviço movimentado. Sua essência: os fluxos de dados de entrada e saída não devem se cruzar. Ou seja, você não pode enviar uma solicitação e esperar uma resposta; em vez disso, você envia uma solicitação ao serviço A, mas recebe uma resposta do serviço B.

O primeiro bônus dessa abordagem é a capacidade de interromper a conexão (no sentido amplo da palavra) durante a execução de uma solicitação longa. Por exemplo, vamos pegar uma sequência mais ou menos padrão:

  1. O cliente enviou uma solicitação ao servidor.
  2. O servidor iniciou um longo tempo de processamento.
  3. O servidor respondeu ao cliente com o resultado.

Vamos imaginar que no ponto 2 a conexão foi interrompida (ou a rede se reconectou, ou o usuário foi para outra página, quebrando a conexão). Nesse caso, será difícil para o servidor enviar uma resposta ao usuário com informações sobre o que exatamente foi processado. Usando CQRS, a sequência será um pouco diferente:

  1. O cliente se inscreveu para receber atualizações.
  2. O cliente enviou uma solicitação ao servidor.
  3. O servidor respondeu “solicitação aceita”.
  4. O servidor respondeu com o resultado através do canal do ponto “1”.

Padrões arquitetônicos convenientes

Como você pode ver, o esquema é um pouco mais complicado. Além disso, falta aqui a abordagem intuitiva de solicitação-resposta. No entanto, como você pode ver, uma interrupção na conexão durante o processamento de uma solicitação não resultará em erro. Além disso, se de facto o utilizador estiver ligado ao serviço a partir de vários dispositivos (por exemplo, de um telemóvel e de um tablet), pode ter a certeza de que a resposta chega a ambos os dispositivos.

Curiosamente, o código para processamento de mensagens recebidas passa a ser o mesmo (não 100%) tanto para eventos que foram influenciados pelo próprio cliente, quanto para outros eventos, inclusive de outros clientes.

No entanto, na realidade obtemos um bônus adicional devido ao fato de que o fluxo unidirecional pode ser tratado de forma funcional (usando RX e similares). E isso já é uma grande vantagem, pois em essência a aplicação pode ser totalmente reativa, e também por meio de uma abordagem funcional. Para programas gordos, isso pode economizar significativamente recursos de desenvolvimento e suporte.

Se combinarmos essa abordagem com o escalonamento horizontal, como bônus teremos a capacidade de enviar solicitações a um servidor e receber respostas de outro. Assim, o cliente poderá escolher o serviço que mais lhe convém, e o sistema interno ainda poderá processar os eventos corretamente.

Sourcing de eventos

Como você sabe, uma das principais características de um sistema distribuído é a ausência de um tempo comum, de uma seção crítica comum. Para um processo, você pode fazer uma sincronização (nos mesmos mutexes), dentro da qual você tem certeza de que ninguém mais está executando esse código. No entanto, isso é perigoso para um sistema distribuído, pois exigirá sobrecarga e também eliminará toda a beleza do dimensionamento - todos os componentes ainda aguardarão por um.

A partir daqui obtemos um fato importante - um sistema distribuído rápido não pode ser sincronizado, porque então reduziremos o desempenho. Por outro lado, muitas vezes precisamos de uma certa consistência entre os componentes. E para isso você pode usar a abordagem com consistência eventual, onde é garantido que se não houver alterações nos dados por algum período de tempo após a última atualização (“eventualmente”), todas as consultas retornarão o último valor atualizado.

É importante entender que para bancos de dados clássicos é frequentemente usado consistência forte, onde cada nó possui as mesmas informações (isso geralmente é conseguido no caso em que a transação é considerada estabelecida somente após a resposta do segundo servidor). Existem alguns relaxamentos aqui devido aos níveis de isolamento, mas a ideia geral permanece a mesma – você pode viver em um mundo completamente harmonizado.

No entanto, voltemos à tarefa original. Se parte do sistema puder ser construída com consistência eventual, então podemos construir o seguinte diagrama.

Padrões arquitetônicos convenientes

Características importantes desta abordagem:

  • Cada solicitação recebida é colocada em uma fila.
  • Ao processar uma solicitação, o serviço também pode colocar tarefas em outras filas.
  • Cada evento recebido possui um identificador (que é necessário para a desduplicação).
  • A fila funciona ideologicamente de acordo com o esquema “apenas anexar”. Você não pode remover elementos dele ou reorganizá-los.
  • A fila funciona de acordo com o esquema FIFO (desculpe pela tautologia). Se você precisar fazer execução paralela, em um estágio você deverá mover objetos para filas diferentes.

Deixe-me lembrá-lo de que estamos considerando o caso do armazenamento de arquivos online. Neste caso, o sistema ficará mais ou menos assim:

Padrões arquitetônicos convenientes

É importante que os serviços no diagrama não signifiquem necessariamente um servidor separado. Até o processo pode ser o mesmo. Outra coisa é importante: ideologicamente, essas coisas estão separadas de tal forma que a escala horizontal pode ser facilmente aplicada.

E para dois usuários o diagrama ficará assim (os serviços destinados a usuários diferentes são indicados em cores diferentes):

Padrões arquitetônicos convenientes

Bônus desta combinação:

  • Os serviços de processamento de informações são separados. As filas também são separadas. Se precisarmos aumentar o rendimento do sistema, só precisaremos lançar mais serviços em mais servidores.
  • Quando recebemos informações de um usuário, não precisamos esperar até que os dados sejam totalmente salvos. Pelo contrário, basta responder “ok” e depois começar a trabalhar aos poucos. Ao mesmo tempo, a fila suaviza os picos, pois a adição de um novo objeto ocorre rapidamente e o usuário não precisa esperar a passagem completa de todo o ciclo.
  • Por exemplo, adicionei um serviço de desduplicação que tenta mesclar arquivos idênticos. Se funcionar por muito tempo em 1% dos casos, o cliente dificilmente notará (veja acima), o que é uma grande vantagem, já que não precisamos mais ser XNUMX% rápidos e confiáveis.

No entanto, as desvantagens são imediatamente visíveis:

  • Nosso sistema perdeu sua consistência estrita. Isso significa que se, por exemplo, você assinar serviços diferentes, teoricamente poderá obter um estado diferente (já que um dos serviços pode não ter tempo para receber uma notificação da fila interna). Como outra consequência, o sistema agora não tem tempo comum. Ou seja, é impossível, por exemplo, ordenar todos os eventos simplesmente pelo horário de chegada, pois os relógios entre servidores podem não ser síncronos (além disso, o mesmo horário em dois servidores é uma utopia).
  • Nenhum evento agora pode ser simplesmente revertido (como poderia ser feito com um banco de dados). Em vez disso, você precisa adicionar um novo evento - evento de compensação, que mudará o último estado para o necessário. Como exemplo de uma área semelhante: sem reescrever o histórico (o que é ruim em alguns casos), você não pode reverter um commit no git, mas pode fazer um especial confirmação de reversão, que essencialmente retorna apenas o estado antigo. No entanto, tanto o commit incorreto quanto a reversão permanecerão no histórico.
  • O esquema de dados pode mudar de versão para versão, mas eventos antigos não poderão mais ser atualizados para o novo padrão (uma vez que os eventos não podem ser alterados em princípio).

Como você pode ver, Event Sourcing funciona bem com CQRS. Além disso, implementar um sistema com filas eficientes e convenientes, mas sem separar os fluxos de dados, já é difícil por si só, pois será necessário adicionar pontos de sincronização que irão neutralizar todo o efeito positivo das filas. Aplicando as duas abordagens ao mesmo tempo, é necessário ajustar ligeiramente o código do programa. No nosso caso, ao enviar um arquivo para o servidor, a resposta vem apenas “ok”, o que significa apenas que “a operação de adição do arquivo foi salva”. Formalmente, isso não significa que os dados já estejam disponíveis em outros dispositivos (por exemplo, o serviço de desduplicação pode reconstruir o índice). Porém, depois de algum tempo, o cliente receberá uma notificação no estilo “o arquivo X foi salvo”.

Como um resultado:

  • O número de status de envio de arquivos está aumentando: em vez do clássico “arquivo enviado”, obtemos dois: “o arquivo foi adicionado à fila do servidor” e “o arquivo foi salvo no armazenamento”. Este último significa que outros dispositivos já podem começar a receber o arquivo (ajustado pelo fato das filas operarem em velocidades diferentes).
  • Devido ao fato de as informações de envio agora passarem por diferentes canais, precisamos encontrar soluções para receber o status de processamento do arquivo. Como consequência: ao contrário da clássica solicitação-resposta, o cliente pode ser reiniciado durante o processamento do arquivo, mas o próprio status desse processamento estará correto. Além disso, este item funciona, essencialmente, fora da caixa. Como consequência: agora somos mais tolerantes com as falhas.

Sharding

Conforme descrito acima, os sistemas de fornecimento de eventos carecem de consistência estrita. Isso significa que podemos usar vários armazenamentos sem qualquer sincronização entre eles. Aproximando-nos do nosso problema, podemos:

  • Separe os arquivos por tipo. Por exemplo, imagens/vídeos podem ser decodificados e um formato mais eficiente pode ser selecionado.
  • Contas separadas por país. Devido a muitas leis, isso pode ser necessário, mas este esquema de arquitetura oferece essa oportunidade automaticamente

Padrões arquitetônicos convenientes

Se você deseja transferir dados de um armazenamento para outro, os meios padrão não são mais suficientes. Infelizmente, neste caso, você precisa parar a fila, fazer a migração e depois iniciá-la. No caso geral, os dados não podem ser transferidos “on the fly”, no entanto, se a fila de eventos estiver completamente armazenada e você tiver instantâneos dos estados de armazenamento anteriores, podemos reproduzir os eventos da seguinte maneira:

  • Na Fonte de Eventos, cada evento possui seu próprio identificador (idealmente, não decrescente). Isso significa que podemos adicionar um campo ao armazenamento - o id do último elemento processado.
  • Duplicamos a fila para que todos os eventos possam ser processados ​​para vários armazenamentos independentes (o primeiro é aquele em que os dados já estão armazenados e o segundo é novo, mas ainda vazio). A segunda fila, claro, ainda não está sendo processada.
  • Lançamos a segunda fila (ou seja, começamos a reproduzir eventos).
  • Quando a nova fila estiver relativamente vazia (ou seja, a diferença média de tempo entre adicionar um elemento e recuperá-lo for aceitável), você poderá começar a mudar os leitores para o novo armazenamento.

Como você pode ver, não tínhamos, e ainda não temos, consistência estrita em nosso sistema. Existe apenas consistência eventual, ou seja, uma garantia de que os eventos são processados ​​na mesma ordem (mas possivelmente com atrasos diferentes). E, usando isso, podemos transferir dados com relativa facilidade, sem parar o sistema, para o outro lado do globo.

Assim, continuando nosso exemplo sobre armazenamento online de arquivos, tal arquitetura já nos dá uma série de bônus:

  • Podemos mover objetos para mais perto dos usuários de forma dinâmica. Desta forma você pode melhorar a qualidade do serviço.
  • Poderemos armazenar alguns dados dentro de empresas. Por exemplo, os usuários corporativos geralmente exigem que seus dados sejam armazenados em data centers controlados (para evitar vazamentos de dados). Através da fragmentação, podemos facilmente suportar isso. E a tarefa fica ainda mais fácil se o cliente tiver uma nuvem compatível (por exemplo, Azure auto-hospedado).
  • E o mais importante é que não precisamos fazer isso. Afinal, para começar, ficaríamos muito felizes com um armazenamento para todas as contas (para começar a trabalhar rapidamente). E a principal característica deste sistema é que embora seja expansível, na fase inicial é bastante simples. Você simplesmente não precisa escrever imediatamente um código que funcione com um milhão de filas independentes separadas, etc. Se necessário, isso poderá ser feito no futuro.

Hospedagem de conteúdo estático

Este ponto pode parecer bastante óbvio, mas ainda é necessário para um aplicativo carregado mais ou menos padrão. Sua essência é simples: todo o conteúdo estático é distribuído não do mesmo servidor onde a aplicação está localizada, mas de servidores especiais dedicados especificamente a esta tarefa. Como resultado, essas operações são executadas mais rapidamente (o nginx condicional fornece arquivos de forma mais rápida e menos dispendiosa do que um servidor Java). Além da arquitetura CDN (Content Delivery Network) nos permite localizar nossos arquivos mais próximos dos usuários finais, o que tem um efeito positivo na comodidade de trabalhar com o serviço.

O exemplo mais simples e padrão de conteúdo estático é um conjunto de scripts e imagens para um site. Com eles tudo é simples - são conhecidos com antecedência e depois o arquivo é carregado nos servidores CDN, de onde são distribuídos aos usuários finais.

No entanto, na realidade, para conteúdo estático, você pode usar uma abordagem semelhante à arquitetura lambda. Voltemos à nossa tarefa (armazenamento de arquivos online), na qual precisamos distribuir arquivos aos usuários. A solução mais simples é criar um serviço que, para cada solicitação do usuário, faça todas as verificações necessárias (autorização, etc.), e depois baixe o arquivo diretamente do nosso armazenamento. A principal desvantagem dessa abordagem é que o conteúdo estático (e um arquivo com uma determinada revisão é, na verdade, conteúdo estático) é distribuído pelo mesmo servidor que contém a lógica de negócios. Em vez disso, você pode fazer o seguinte diagrama:

  • O servidor fornece um URL de download. Pode ter o formato file_id + key, onde key é uma miniassinatura digital que dá direito de acesso ao recurso pelas próximas XNUMX horas.
  • O arquivo é distribuído pelo nginx simples com as seguintes opções:
    • Cache de conteúdo. Como este serviço pode estar localizado em um servidor separado, deixamos uma reserva para o futuro com a capacidade de armazenar em disco todos os arquivos baixados mais recentes.
    • Verificando a chave no momento da criação da conexão
  • Opcional: processamento de conteúdo de streaming. Por exemplo, se compactarmos todos os arquivos do serviço, podemos descompactá-los diretamente neste módulo. Como consequência: as operações de IO são realizadas onde elas pertencem. Um arquivador em Java alocará facilmente muita memória extra, mas reescrever um serviço com lógica de negócios em condicionais Rust/C++ também pode ser ineficaz. No nosso caso, diferentes processos (ou mesmo serviços) são usados ​​e, portanto, podemos separar de forma bastante eficaz a lógica de negócios e as operações de IO.

Padrões arquitetônicos convenientes

Este esquema não é muito semelhante à distribuição de conteúdo estático (já que não carregamos todo o pacote estático em algum lugar), mas na realidade, esta abordagem está precisamente preocupada com a distribuição de dados imutáveis. Além disso, este esquema pode ser generalizado para outros casos onde o conteúdo não é simplesmente estático, mas pode ser representado como um conjunto de blocos imutáveis ​​e não deletáveis ​​(embora possam ser adicionados).

Como outro exemplo (para reforço): se você trabalhou com Jenkins/TeamCity, então sabe que ambas as soluções são escritas em Java. Ambos são processos Java que lidam com orquestração de construção e gerenciamento de conteúdo. Em particular, ambos têm tarefas como “transferir um arquivo/pasta do servidor”. Como exemplo: emissão de artefatos, transferência de código fonte (quando o agente não baixa o código diretamente do repositório, mas o servidor faz isso por ele), acesso a logs. Todas essas tarefas diferem em sua carga de E/S. Ou seja, acontece que o servidor responsável pela lógica de negócios complexa deve, ao mesmo tempo, ser capaz de enviar grandes fluxos de dados através de si mesmo. E o mais interessante é que tal operação pode ser delegada ao mesmo nginx exatamente de acordo com o mesmo esquema (exceto que a chave de dados deve ser adicionada à solicitação).

No entanto, se voltarmos ao nosso sistema, obteremos um diagrama semelhante:

Padrões arquitetônicos convenientes

Como você pode ver, o sistema tornou-se radicalmente mais complexo. Agora não é apenas um miniprocesso que armazena arquivos localmente. Agora o que é necessário não é o suporte mais simples, controle de versão de API, etc. Portanto, depois de todos os diagramas terem sido desenhados, é melhor avaliar detalhadamente se a extensibilidade vale o custo. Porém, se você quiser expandir o sistema (inclusive para trabalhar com um número ainda maior de usuários), terá que optar por soluções semelhantes. Mas, como resultado, o sistema está arquitetonicamente pronto para maior carga (quase todos os componentes podem ser clonados para escalabilidade horizontal). O sistema pode ser atualizado sem interrompê-lo (simplesmente algumas operações ficarão um pouco mais lentas).

Como eu disse no início, agora vários serviços de Internet começaram a receber carga aumentada. E alguns deles simplesmente começaram a parar de funcionar corretamente. Na verdade, os sistemas falharam precisamente no momento em que a empresa deveria ganhar dinheiro. Ou seja, em vez de adiar a entrega, em vez de sugerir aos clientes “planejem sua entrega para os próximos meses”, o sistema simplesmente dizia “vá até seus concorrentes”. Na verdade, este é o preço da baixa produtividade: as perdas ocorrerão precisamente quando os lucros seriam mais elevados.

Conclusão

Todas essas abordagens eram conhecidas antes. O mesmo VK há muito usa a ideia de hospedagem de conteúdo estático para exibir imagens. Muitos jogos online usam o esquema Sharding para dividir os jogadores em regiões ou para separar locais de jogo (se o mundo em si for um). A abordagem de Event Sourcing é usada ativamente em e-mail. A maioria dos aplicativos de negociação onde os dados são recebidos constantemente são, na verdade, construídos com base em uma abordagem CQRS para poder filtrar os dados recebidos. Bem, o escalonamento horizontal tem sido usado em muitos serviços há bastante tempo.

Contudo, o mais importante é que todos esses padrões se tornaram muito fáceis de aplicar em aplicações modernas (se forem apropriados, é claro). As nuvens oferecem fragmentação e escalonamento horizontal imediatamente, o que é muito mais fácil do que solicitar você mesmo diferentes servidores dedicados em diferentes data centers. O CQRS se tornou muito mais fácil, mesmo que apenas por causa do desenvolvimento de bibliotecas como o RX. Cerca de 10 anos atrás, um site raro poderia apoiar isso. O Event Sourcing também é incrivelmente fácil de configurar graças aos contêineres prontos com Apache Kafka. Há 10 anos isso teria sido uma inovação, agora é comum. O mesmo acontece com a hospedagem de conteúdo estático: devido às tecnologias mais convenientes (incluindo o fato de haver documentação detalhada e um grande banco de dados de respostas), essa abordagem se tornou ainda mais simples.

Como resultado, a implementação de uma série de padrões arquitetônicos bastante complexos tornou-se agora muito mais simples, o que significa que é melhor examiná-los mais de perto com antecedência. Se em uma aplicação com dez anos uma das soluções acima foi abandonada devido ao alto custo de implementação e operação, agora, em uma nova aplicação, ou após refatoração, você pode criar um serviço que já será arquiteturalmente extensível ( em termos de desempenho) e prontos para novas solicitações de clientes (por exemplo, para localização de dados pessoais).

E o mais importante: por favor, não use essas abordagens se você tiver um aplicativo simples. Sim, eles são lindos e interessantes, mas para um local com pico de visitação de 100 pessoas, muitas vezes você pode conviver com um monólito clássico (pelo menos por fora, tudo dentro pode ser dividido em módulos, etc.).

Fonte: habr.com

Adicionar um comentário