Padrões de migração de monólitos para microsserviços — Parte II
No primeiro artigo dessa série foram abordados padrões que ajudam a migrar a base de código de um monólito para uma arquitetura de microsserviços. A base de código é apenas a ponta do iceberg, um dos maiores desafios está nos dados.
Nesse artigo, falaremos sobre a etapa de migração dos dados que é tão crucial quanto a migração de código e por isso não deve ser desconsiderada para que tenhamos as grandes vantagens prometidas pela arquitetura de microsserviços.
Esse conteúdo foi baseado principalmente nesse livro, que aborda o processo de migração de monólitos para microsserviços.
Banco de dados compartilhado
Algumas empresas possuem bases de dados gigantescas que fazem com que os times afirmem veementemente que é impossível separar os dados em diferentes bases. Por causa disso, a migração para microsserviços acaba sendo feita apenas no código, resultando num anti-padrão chamado de Banco de dados Compartilhado.
Você pode estar se perguntando nesse momento: é tão ruim assim ter apenas um banco? O grande problema dessa estratégia é o acoplamento que ela gera, que para bases de dados maiores é algo bem prejudicial. Os clientes desse banco estão totalmente acoplados as suas definições de esquemas, então mudanças tem um impacto muitas vezes imprevisível, pois não sabemos quem acessa os dados. Imagine se alguém resolve mudar o nome de uma coluna ou tabela do banco, como saber quais clientes precisam se atualizar? Muitas vezes só vamos descobrir quando o problema explodir na cara do cliente.
Podemos minimizar o problema do acoplamento disponibilizando o acesso a esses dados pelos clientes através de interfaces estáveis e bem definidas e assim ganhamos maior liberdade para mudar os esquemas do banco de dados. Essa interface de acesso pode ser uma view comum ou materializada, um serviço na frente do banco (wrapping service) ou um Database-as-a-service dedicado para operações de leitura de dados.
Todos esses esforços visam apenas "estancar o sangue", ainda temos os problemas de acoplamento inerentes ao uso de uma base de dados compartilhada e por isso ainda precisamos pensar em estratégias que nos ajudem a fazer a divisão desses dados.
Quem é o dono do dado?
Quando pensamos em separar os dados, precisamos saber quem vai ser o microsserviço responsável por ele. Lembre-se de que estamos dividindo o banco para que cada microsserviço tenha autonomia sobre o controle do seu estado interno e assim essa autonomia se reflita no desenvolvimento pelos times, implantação, etc. Pensando nisso, podemos adotar alguns padrões de transferência de posse.
Aggregate Exposing Monolith
Esse padrão é bastante útil quando os dados que o microsserviço precisa ainda estão no monólito (lembre-se que o processo de migração é incremental). A ideia é criar um serviço dentro do monólito que vai disponibilizar os dados que ainda não foram extraídos para os microsserviços externos. Todos os clientes desses dados precisarão acessar esse serviço ao invés de acessar diretamente o banco para não aumentarmos o acoplamento com a base de dados monolítica durante a criação dos novos microsserviços. No futuro, esse serviço poderá ser movido para o microsserviço que será o seu dono.
Change Data Ownership
Quando um microsserviço for extraído do monólito, se identificarmos um conjunto de dados que deveriam pertencer a ele devemos movê-los também para uma nova base de dados e fazer o monólito consumir esses dados do microsserviço ao invés de acessar diretamente a base de dados. É claro que extrair os dados do banco pode não ser algo tão simples e intuitivo, por isso vamos precisar utilizar outros padrões que veremos mais a frente para nos auxiliar nessa tarefa.
Como manter as bases de dados íntegras?
Queremos mover incrementalmente funcionalidades e seus dados para novos microsserviços. Nesse período, é possível termos o cenário onde existem duas bases de dados sendo utilizadas, uma no monólito e uma no microsserviço, ambas para a mesma funcionalidade. Por isso, é necessário haver sincronismo de dados, para que a leitura e escrita dos dados esteja íntegra em ambos os sistemas. Isso pode ser alcançado utilizando alguns padrões de sincronização de dados.
Sincronize data in application
A ideia aqui é sincronizar os dados na própria aplicação, fazendo ela escrever em ambas as bases. Para isso é necessário:
- Realizar uma carga inicial dos dados em um novo banco e após ela ativar uma ferramenta de CDC para sincronizar apenas o que mudou durante a execução dessa carga.
- O monólito vai escrever em ambos os bancos mas vai ler os dados apenas do banco original, que nesse momento é a fonte de verdade.
- Depois de garantir a consistência de escrita, agora é possível fazer a leitura da nova base de dados.
Essa estratégia faz mais sentido quando a migração dos esquemas de dados ocorrer antes da migração do código, pois aí teremos apenas uma aplicação escrevendo nas bases. A sincronização com duas aplicações escrevendo ambas as bases é mais complexa, e se esse for o caso, melhor evitar o uso desse padrão devido a complexidade que ele traria.
Tracer Write
Ao contrário do padrão anterior, o sincronismo aqui será garantido por uma ferramenta de CDC. A escrita de dados vai ocorrer apenas na base antiga e a nova será sincronizada por essa ferramenta mantendo assim as duas bases íntegras. No futuro, a nova base será a fonte de verdade para leitura e escrita de dados. O processo de sincronismo entre bases não é trivial, ainda mais quando a consistência eventual não é aceitável, por isso a aquisição de ferramentas de mercado robustas é recomendada para entregar resultados praticamente em tempo real e evitar dores de cabeça.
Divisão do banco de dados
Nós já planejamos a transferência de posse dos dados e a estratégia de sincronismo utilizada durante o processo de migração incremental. Agora vamos falar sobre a divisão do banco de dados de fato. Essa divisão dos dados pode ocorrer antes da divisão de código, depois dela ou ao mesmo tempo. A última alternativa não é recomendada pois estamos adotando um processo incremental, por isso vamos falar apenas das duas primeiras.
Banco de dados primeiro, código depois
A ideia dessa estratégia é identificar logo no começo algum problema de performance ou integridade antes de separar o código, e assim ir ajustando as fronteiras para os microsserviços.
Repository per bounded context
Nesse padrão, iremos modificar a camada de acesso à dados criando repositórios divididos por contexto (ex: compras, vendas, estoque…). Estamos criando uma divisão lógica da camada de acesso a dados e assim teremos mais visibilidade sobre as fronteiras entre os microsserviços quando formos extrair as funcionalidades.
Database per bounded context
Agora os contextos lógicos são divididos em esquemas físicos do banco, o que vai facilitar um processo de migração futura das funcionalidades e dará um entendimento maior sobre quem trabalha com quais dados.
Ambos os padrões citados apresentam a vantagem de entregar essa visibilidade sobre as fronteiras dos microsserviços a nível de dados, mas por demorar a dividir o código do monólito acabam não entregando tantos benefícios da arquitetura de microsserviços no curto prazo. Por isso, é interessante utilizar essa estratégia apenas quando a performance e consistência forem questões mais importantes de serem pensadas logo no início.
Código primeiro, banco de dados depois
A ideia é ter ganhos no curto prazo com a divisão do código, que são relativos ao desacoplamento dos componentes e assim maior independência de implantação e desenvolvimento dos serviços.
Multischema storage
Nesse padrão teremos os esquemas de dados do monólito e do microsserviço coexistindo. Sempre que adicionarmos novas funcionalidades em um microsserviço iremos colocar os novos dados no banco dele para não crescer ainda mais o banco do monólito. Continuaremos lendo os dados antigos da base monolítica enquanto eles não forem migrados.
Essa estratégia é interessante quando ainda não se tem uma ideia clara sobre a posse dos dados, e durante a divisão do código podemos amadurecer essa visão para só então representá-la nos dados. Aqui deve-se usar também os padrões de transferência de posse (citados anteriormente) para dividir o código sem gerar acoplamento com a base de dados do monólito.
E a separação mais baixo nível?
Nós discutimos as estratégias de divisão dos esquemas de dados mas apenas de forma mais alto nível. Precisamos falar agora sobre estratégias de extração dos dados do banco de forma mais concreta. Para dados que não tem relacionamentos a extração é bem mais simples e intuitiva, basta movê-los de uma base para outra. Mas quando temos situações em que os dados são mais acoplados, precisamos utilizar alguns padrões para nos auxiliarem.
Split Table
É bem comum em sistemas grandes encontrar aquelas tabelas gigantes do banco de dados com mais de 200 colunas. Nesses cenários é bem comum que numa única tabela existam dados de diferentes contextos. A ideia desse padrão é dividir essa única tabela em várias, por contexto, e depois migrar os esquemas resultantes para seus respectivos bancos.
Em alguns casos também é comum ter vários programas atualizando uma mesma coluna (ex: coluna de status) e nesse cenário é interessante movê-la para um único serviço que será chamado quando alguém precisar atualizá-la.
Move foreign-key relationship to code
É comum termos chaves estrangeiras no banco que criam uma dependência entre diferentes tabelas e talvez entre diferentes contextos. Para dividirmos esses dados, a ideia desse padrão é remover a chave do banco e trazê-la para o código. O problema é que agora precisamos de alguma forma lidar com a integridade dos dados, que antes era garantida pelo banco e agora precisa ser pensada na lógica de negócio. Além disso, os joins realizados no banco de dados único agora não poderão ser mais feitos, então é necessário realizá-los em código de forma performática.
Esses problemas podem ser tratados de várias formas. Em relação ao join dá pra fazer uma chamada em lote ao novo serviço que possui o dado ou então cachear localmente os dados para evitar a latência de chamadas a um serviço web, sempre prezando pela performance da consulta. Com relação à integridade, utilizar soft deletes pode ser uma alternativa interessante para manter as referências entre os registros.
Devido a tudo que foi exposto, é importante ter certeza de que as tabelas que possuem o relacionamento precisam de fato ser separadas em contextos diferentes para evitar a complexidade inerente a essa separação.
Conclusão
Nesse artigo falamos sobre padrões de migração de monólitos para microsserviços focando na migração da base de dados. Começamos discutindo os problemas relativos ao uso de uma única base de dados compartilhada e terminamos discutindo técnicas para dividir essa base de forma incremental.
No próximo artigo, falaremos sobre padrões para migração focando numa das questões mais delicadas do processo, as transações.
Se você gosta do meu conteúdo, não deixe de conferir o meu canal do Youtube, onde falo sobre desenvolvimento de software. Espero te ver por lá! 😉