Padrões de dados nativos de nuvem
Dica
Esse conteúdo é um trecho do livro eletrônico, para Projetar os Aplicativos .NET nativos de nuvem para o Azure, disponível no .NET Docs ou como um PDF para download gratuito que pode ser lido offline.
Como vimos ao longo deste livro, uma abordagem nativa de nuvem muda a maneira como você projeta, implanta e gerencia aplicativos. Também altera a maneira como você gerencia e armazena dados.
A Figura 5-1 contrasta as diferenças.
Figura 5-1. Gerenciamento de dados em aplicativos nativos de nuvem
Desenvolvedores experientes reconhecerão facilmente a arquitetura no lado esquerdo da figura 5-1. Neste aplicativo monolítico, os componentes de serviços comerciais são colocados juntos em uma camada de serviços compartilhados, compartilhando os dados de um banco de dados relacional individual.
De muitas maneiras, um banco de dados individual mantém o gerenciamento de dados simples. Consultar dados em várias tabelas é simples. As alterações nos dados são atualizadas em conjunto ou todas são revertidas. As transações ACID garantem consistência forte e imediata.
Projetando para nativo de nuvem, adotaremos uma abordagem diferente. No lado direito da Figura 5-1, observe como a funcionalidade empresarial se segrega em microsserviços pequenos e independentes. Cada microsserviço encapsula um recurso de negócios específico e seus próprios dados. O banco de dados monolítico se decompõe em um modelo de dados distribuído com muitos bancos de dados menores, cada um alinhado a um microsserviço. Quando a fumaça é limpa, surge um design que expõe um banco de dados por microsserviço.
Banco de dados por microsserviço, por quê?
Esse banco de dados por microsserviço oferece muitos benefícios, especialmente para sistemas que devem evoluir rapidamente e dar suporte para escala massiva. Com este modelo...
- Os dados de domínio são encapsulados dentro do serviço
- O esquema de dados pode evoluir sem afetar diretamente outros serviços
- Cada armazenamento de dados pode ser dimensionado de forma independente
- Uma falha no armazenamento de dados em um serviço não afetará diretamente outros serviços
A segregação de dados também permite que cada microsserviço implemente o tipo de armazenamento de dados mais otimizado para a carga de trabalho, necessidades de armazenamento e padrões de leitura/gravação. As opções incluem armazenamentos de dados relacionais, documentais, de valor-chave e baseados em gráficos.
A Figura 5-2 apresenta o princípio da persistência poliglota em um sistema nativo de nuvem.
Figura 5-2. Persistência poliglota de dados
Observe na figura anterior como cada microsserviço dá suporte a um tipo diferente de armazenamento de dados.
- O microsserviço de catálogo de produtos consome um banco de dados relacional para acomodar a avançada estrutura relacional dos dados subjacentes.
- O microsserviço de carrinho de compras consome um cache distribuído que dá suporte a armazenamento de dados de chave-valor simples.
- O microsserviço de ordenação consome um banco de dados de documentos NoSql para operações de gravação e um armazenamento de chave-valor altamente desnormalizado para acomodar grandes volumes de operações de leitura.
Embora os bancos de dados relacionais permaneçam relevantes para microsserviços com dados complexos, os bancos de dados NoSQL ganharam popularidade considerável. Eles fornecem escala massiva e alta disponibilidade. Sua natureza sem esquema permite que os desenvolvedores se afastem de uma arquitetura de classes de dados tipadas e ORMs que tornam as alterações caras e demoradas. Abordaremos o bancos de dados NoSQL mais adiante neste capítulo.
Embora o encapsulamento de dados em microsserviços separados possa aumentar a agilidade, o desempenho e a escalabilidade, isso também apresenta muitos desafios. Na próxima seção, abordaremos esses desafios juntamente com os padrões e as práticas para ajudar a superá-los.
Consultas entre serviços
Embora os microsserviços sejam independentes e se concentrem em recursos funcionais específicos como estoque, envio ou ordenação, eles frequentemente exigem a integração com outros microsserviços. Geralmente, a integração envolve um microsserviço consultando outro para obter os dados. A Figura 5-3 mostra o cenário.
Figura 5-3. Consultas entre microsserviços
Na figura anterior, observamos um microsserviço da cesta de compras que adiciona um item à cesta de compras de um usuário. Embora o armazenamento de dados desse microsserviço contenha os dados da cesta e item de linha, ele não mantém dados de produtos ou preços. Em vez disso, esses itens de dados são de propriedade do catálogo e dos microsserviços de preços. Esse aspecto apresenta um problema. Como o microsserviço da cesta de compras poderá adicionar um produto à cesta de compras do usuário quando não possuir dados de produtos ou preços no banco de dados?
Uma opção discutida no Capítulo 4 é uma chamada de HTTP direta da cesta de compras para o catálogo e microsserviços de preços. No entanto, no capítulo 4, dissemos que o HTTP síncrono chama os microsserviços acoplados, reduzindo a autonomia e diminuindo os benefícios arquitetônicos.
Também poderíamos implementar um padrão de solicitação-resposta com filas de entrada e saída separadas para cada serviço. No entanto, esse padrão é complicado e requer conexão para correlacionar as mensagens de solicitação e resposta. Embora desvincule as chamadas de microsserviço de back-end, o serviço de chamada ainda deverá aguardar de forma síncrona a conclusão da chamada. Congestionamento de rede, falhas transitórias ou um microsserviço sobrecarregado poderá resultar em operações de longa duração e até mesmo com falha.
Em vez disso, um padrão amplamente aceito para remover as dependências entre serviços é o Padrão de Exibição Materializada, mostrado na Figura 5-4.
Figura 5-4. Padrão de Exibição Materializada
Com esse padrão, você coloca uma tabela de dados local (conhecida como modelo de leitura) no serviço da cesta de compras. Essa tabela contém uma cópia desnormalizada dos dados necessários dos microsserviços de produtos e preços. Copiar os dados diretamente no microsserviço da cesta de compras eliminará a necessidade de chamadas entre serviços. Com os dados locais para o serviço, você melhorará o tempo de resposta e a confiabilidade do serviço. Além disso, ter sua própria cópia dos dados tornará o serviço da cesta de compras mais resiliente. Se o serviço de catálogo ficar indisponível, isso não afetará diretamente o serviço da cesta de compras. A cesta de compras poderá continuar operando com os dados da própria loja.
O problema com essa abordagem é que os dados ficarão duplicados no sistema. No entanto, duplicar dados estrategicamente em sistemas nativos de nuvem é uma prática estabelecida e não considerada um antipadrão ou uma prática ruim. Tenha em mente que um e apenas um serviço poderá conter um conjunto de dados e ter autoridade sobre ele. Será necessário sincronizar os modelos lidos quando o sistema de registro for atualizado. A sincronização normalmente é implementada por meio de mensagens assíncronas com um padrão de publicação/assinatura, conforme mostrado na Figura 5.4.
Transações distribuídas
Embora a consulta de dados em microsserviços seja difícil, a implementação de uma transação em vários microsserviços é ainda mais complexa. O desafio inerente de manter a consistência de dados em fontes de dados independentes em diferentes microsserviços não poderá ser subestimado. A falta de transações distribuídas em aplicativos nativos de nuvem significa que será necessário gerenciar as transações distribuídas programaticamente. Você passa de um cenário de consistência imediata para o de consistência eventual.
A Figura 5-5 mostra o problema.
Figura 5-5. Implementando uma transação entre microsserviços
Na figura anterior, cinco microsserviços independentes participam de uma transação distribuída que cria um pedido. Cada microsserviço mantém o próprio armazenamento de dados e implementa uma transação local para o armazenamento. Para criar o pedido, a transação local para cada microsserviço individual deverá obter êxito ou todos deverão anular e reverter a operação. Embora o suporte transacional integrado esteja disponível em cada um dos microsserviços, não há suporte para uma transação distribuída que abranja todos os cinco serviços para manter a consistência dos dados.
Em vez disso, será necessário construir essa transação distribuída programaticamente.
Um padrão popular para adicionar suporte transacional distribuído é o Padrão de saga. Ele é implementado agrupando as transações locais de forma programática e sequencialmente invocando cada uma delas. Se alguma das transações locais falhar, o Saga anulará a operação e invocará um conjunto de transações compensatórias. As transações de compensação desfarão as alterações feitas pelas transações locais anteriores e restaurarão a consistência dos dados. A Figura 5-6 mostra uma transação com falha com o padrão Saga.
Figura 5-6. Revertendo uma transação
Na figura anterior, a operação Atualizar Estoque falhou no microsserviço de estoque. O Saga invoca um conjunto de transações de compensação (em vermelho) para ajustar as contagens de estoque, cancelar o pagamento e o pedido e retornar os dados de cada microsserviço de volta a um estado consistente.
Os padrões Saga são normalmente coreografados como uma série de eventos relacionados ou orquestrados como um conjunto de comandos relacionados. No Capítulo 4, foi abordado o padrão de agregador de serviços que seria a base para uma implementação de Saga orquestrada. Também discutimos o evento junto com os tópicos do Barramento de Serviço do Azure e da Grade de Eventos do Azure que seriam uma base para uma implementação coreografada de saga.
Dados de alto volume
Aplicativos nativos de nuvem grandes geralmente dão suporte a requisitos de dados de alto volume. Nesses cenários, as técnicas tradicionais de armazenamento de dados podem causar gargalos. Para sistemas complexos que são implantados em grande escala, a CQRS (Separação das Operações de Comando e de Consulta) e o Fornecimento de Eventos poderão melhorar o desempenho do aplicativo.
CQRS
A CQRS é um padrão de arquitetura que pode ajudar a maximizar o desempenho, a escalabilidade e a segurança. O padrão separa as operações que leem os dados das operações que gravam os dados.
Para cenários normais, o mesmo modelo de entidade e o objeto de repositório de dados são usados para ambas as operações de leitura e gravação.
No entanto, um cenário de dados de alto volume pode se beneficiar de modelos e tabelas de dados separados para leituras e gravações. Para melhorar o desempenho, a operação de leitura poderá consultar uma representação altamente desnormalizada dos dados para evitar junções de tabelas repetitivas e bloqueios de tabela dispendiosos. A operação de gravação, conhecida como comando, atualizará em relação a uma representação totalmente normalizada dos dados que garantirá a consistência. Em seguida, você precisa implementar um mecanismo para manter ambas as representações sincronizadas. Normalmente, sempre que a tabela de gravação é modificada, ela publica um evento que replica a modificação na tabela de leitura.
A Figura 5-7 mostra uma implementação do padrão de CQRS.
Figura 5-7. Implementação de CQRS
Na figura anterior, são implementados os modelos separados de comando e de consulta. Cada operação de gravação de dados é salva no armazenamento de gravação e, em seguida, propagada para o armazenamento de leitura. Atente-se em como o processo de propagação de dados opera com base no princípio da consistência eventual. O modelo de leitura eventualmente é sincronizado com o modelo de gravação, mas poderá haver algum atraso no processo. Discutiremos a consistência eventual na próxima seção.
Essa separação permite que leituras e gravações sejam dimensionadas independentemente. As operações de leitura usam um esquema otimizado para consultas, enquanto as gravações usam um esquema otimizado para atualizações. As consultas de leitura ocorrem em contraste com os dados desnormalizados, enquanto a lógica de negócios complexa pode ser aplicada ao modelo de gravação. Além disso, é possível impor uma segurança mais rígida nas operações de gravação do que aquelas que expõem leituras.
A implementação do CQRS poderá melhorar o desempenho do aplicativo para serviços nativos de nuvem. No entanto, isso resultará em um design mais complexo. Aplique esse princípio com cuidado e estrategicamente às seções do aplicativo nativo de nuvem que se beneficiarão dele. Para obter mais informações sobre a CQRS, consulte o livro da Microsoft Microsserviços .NET: arquitetura para aplicativos .NET conteinerizados.
Fornecimento de eventos
Outra abordagem para otimizar os cenários de dados de alto volume envolve o Fornecimento de Eventos.
Normalmente, um sistema armazena o estado atual de uma entidade de dados. Se um usuário alterar seu número de telefone, por exemplo, o registro do cliente será atualizado com o novo número. Sempre conheceremos o estado atual de uma entidade de dados, mas cada atualização substituirá o estado anterior.
Na maioria dos casos, esse modelo funciona bem. Em sistemas de alto volume, no entanto, a sobrecarga de bloqueio transacional e as operações frequentes de atualização poderão afetar o desempenho do banco de dados, a capacidade de resposta e limitar a escalabilidade.
O Fornecimento de Eventos adota uma abordagem diferente para capturar dados. Cada operação que afeta os dados é mantida em um repositório de eventos. Em vez de atualizar o estado de um registro de dados, anexamos cada alteração a uma lista sequencial de eventos passados, semelhante ao razão de um contador. O Repositório de Eventos se tornará o sistema de registro dos dados. Ele será usado para propagar as várias exibições materializadas dentro do contexto limitado de um microsserviço. A Figura 5.8 mostra o padrão.
Figura 5-8. Fornecimento do evento
Na figura anterior, observe como cada entrada (em azul) do carrinho de compras de um usuário é anexada a um repositório de eventos subjacente. Na exibição materializada adjacente, o sistema projeta o estado atual reproduzindo todos os eventos associados a cada carrinho de compras. Em seguida, essa exibição, ou modelo de leitura, é exposta de volta à interface do usuário. Os eventos também poderão ser integrados a sistemas e aplicativos externos ou consultados para determinar o estado atual de uma entidade. Com essa abordagem, o histórico será mantido. Será possível saber não apenas o estado atual de uma entidade, mas também como atingiu esse estado.
Mecanicamente falando, o fornecimento de eventos simplifica o modelo de gravação. Não há atualizações ou exclusões. Anexar cada entrada de dados como um evento imutável minimizará os conflitos de contenção, bloqueio e simultaneidade associados a bancos de dados relacionais. Compilar os modelos de leitura com o padrão de exibição materializada permitirá desacoplar a exibição do modelo de gravação e escolher o melhor armazenamento de dados para otimizar as necessidades da interface do usuário do aplicativo.
Para esse padrão, escolha um repositório de dados que dê suporte diretamente ao fornecimento de eventos. Azure Cosmos DB, MongoDB, Cassandra, CouchDB e RavenDB são bons candidatos.
Assim como acontece com todos os padrões e tecnologias, implemente estrategicamente e quando necessário. Embora o fornecimento de eventos possa fornecer maior desempenho e escalabilidade, isso é obtido em detrimento da complexidade e de uma curva de aprendizado.