System Design: separando bancos de dados

Adriano Croco
7 min readApr 25, 2023

Olá!

Estou iniciando essa série de posts para tentar resolver alguns desafios de arquitetura/system design que encontrei nesse post aqui.

Vamos ao primeiro:

Desafio 0: Separar um Banco de Dados em Dois

Como você separaria um banco de dados relacional legado em dois de forma que mantivesse apenas registros de até 6 meses num banco e registros mais antigos em outro?

Considere coisas como paginação, busca, usabilidade, etc.

Em uma linguagem mais voltada para features, após um refinamento hipotético, teríamos as seguinte tarefas:

- Carga inicial no banco novo
- Processo de escrita funcionando no banco novo
- Permitir leitura em ambos os bancos, de forma amigável para quem usa (com paginação e similares)

Antes de discutir soluções, um disclaimer: Como o intuito desse artigo é ser educativo, vou focar mais nas opções possíveis do que uma única solução correta (se é que existe uma).

Para deixar um pouco mais visual, imagine que no problema temos o cenário abaixo. Uma ou mais aplicações que escrevem em uma base de dados legada através de uma camada de API. O que fica algo parecido com isso:

Escopo básico do problema

Em um cenário hipotético, caso a escrita no banco legado seja feita sem essa API (no caso de múltiplos lugares acessando o banco diretamente, por exemplo), a prioridade na resolução desse problema deveria ser centralizar o acesso ao banco por um único componente sistêmico antes de discutir qualquer outra coisa, ok?

Dado que a descrição do problema não deixa claro mais restrições, vou tentar deixar mais realista. Vamos transformar os nomes genéricos dos componentes em algo parecido com um domínio de verdade no qual esse tipo de problema possa acontecer. Como uma tabela de pedidos, por exemplo.

Um cenário comum é: A tabela de pedidos cresce ao longo do tempo e acaba se tornando um problema em termos de tamanho, gerando a necessidade de ações de migração de dados como esse desafio em questão. Aqui tem um pequeno detalhe: Migrar tabelas atômicas que não possuem relacionamentos é muito mais fácil que tabelas que possuem relacionamentos complexos. Ou seja, de quanto menos joins uma tabela depende, mais fácil a vida do profissional de tecnologia na hora de mexer na tabela.

Vamos ao primeiro ponto: A regra por data. Dado que temos um critério explícito de filtro, simplesmente podemos adaptar a escrita de todos os registros novos para o banco novo e deixar o banco legado como somente consulta. Vou deixar duas formas possíveis. A primeira solução é a da imagem abaixo, usando uma técnica chamada de Change Data Capture (CDC).

Solução de migração usando CDC

Uma das formas de replicar a escrita entre dois bancos é usar ferramentas de CDC como o Debezium ou Airbyte. Essencialmente, esse tipo de ferramenta fica escutando as escritas que ocorrem no banco lendo o log de transações conforme elas ocorrem. Eu já escrevi sobre esses mecanismos de logs aqui. Ou seja, com essa técnica não há execução de queries e sim, a leitura de um arquivo que já é append-only por definição. Em teoria, não há impacto na performance do banco. Porém, já vi times que lidavam com volumes muito grandes de dados abandonarem o CDC devido a impactos na performance. Para a grande maioria dos casos — ou seja, em sistemas com escalas de milhares ou centenas de milhares de usuários — você muito provavelmente não precisará se preocupar com isso.

Uma das alternativas a esse mecanismo é replicar os dados manualmente, usando código mesmo. O que transformaria o diagrama em algo parecido com a imagem abaixo:

Replicação manual

Apesar de parecer ingênua, dependendo do esforço de manutenção de uma ferramenta de CDC, esse caminho pode ser melhor e mais rápido. A única dica que eu dou é: Escreva o código que insere os registros de uma forma que você tenha certeza que os dados foram replicados corretamente, para evitar mais problemas de sincronização durante o período de convivência entre os dois bancos.

Com essas técnicas aplicadas, nosso fluxo está escrevendo em dois lugares. Caso seja necessário uma carga inicial dos últimos 6 meses de histórico de dados para o banco novo, isso pode ser feito uma única vez via um script de carga que irá rodar em algum momento de baixo uso do banco legado. Caso não seja possível (em uma operação 24/7 que precisa operar sem downtime, por exemplo), é possível processar a carga inicial em lotes usando queries e um pouco de paciência. Apesar de demorar um pouco mais, é possível concluir esse migração com uma relativa segurança e estabilidade ao se utilizar essa abordagem.

Já temos no banco novo a fonte da verdade dos dados dos últimos 6 meses. Porém, surge outro problema agora: O que acontecerá daqui a uma outra meia dúzia de meses? O banco novo ficará aumentando de tamanho até se tornar igual ao legado?

Novamente, há várias formas de se resolver esse problema. Uma delas é manter uma espécie de limite na quantidade de dados que o banco novo irá suportar. É possível fazer isso usando um mecanismo (via código mesmo) de a cada nova escrita no banco novo, uma verificação na mesma query de inserção move dados mais antigos que 6 meses para o banco antigo simultaneamente. Essa seria uma espécie de processo 100% automatizado de manter o banco novo com os últimos 6 meses de dados, em troca de um overhead maior na escrita de cada registro.

O grande problema dessa solução é a fragilidade dela. Apesar de bom em teoria, esse caminho só seria aceitável caso o banco novo realmente precisasse manter somente os últimos 6 meses de forma explícita e rígida. Como não é o caso na maioria dos cenários, uma solução com uma qualidade de vida mais amigável para o mantenedor poderia ser um processo assíncrono que rodaria em momentos de baixo uso do banco novo que moveria os dados por blocos de tempo de acordo com o que fizesse sentido para o negócio — podendo mover os dados por qualquer medida de tempo possível (seja minutos, horas ou dias).

Como não tenho certeza se isso ficou claro para todos que leram, resolvi fazer um diagrama com uma linha do tempo:

Linha do tempo de como que são organizados os dados

Um exemplo de solução nesse caso seria: Um job que roda de madrugada movendo todos os dados mais antigos que 6 meses para o banco antigo. Além de ser mais fácil de entender, essa abordagem é mais fácil de gerenciar também (o time de engenharia de dados agradece). Lembrando que se o volume de registros for muito grande para uma determinada janela de tempo usando esse critério, uma saída para isso seria aumentar a frequência de execução do job e reduzir o tamanho do lote de registros a serem movidos, alterando o critério de data para ao invés de mover todos os registros menor que 6 meses, usar um critério mais restrito, como horas (ou seja, mover todos os registros das 4 horas anteriores a data de corte ou algo similar).

Para esse último ponto, a representação visual ficaria:

Representação visual da migração em lotes

Nesse momento, temos uma solução que escreve no lugar certo e com um delay de um dia, move os dados excedentes de um banco para o outro. A única coisa que falta agora é uma leitura amigável. Suponhamos que o usuário queira os dados dos últimos 7 meses, por exemplo. Como poderíamos resolver isso?

Caso o volume de dados não seja absurdamente grande, é possível via camada de API aplicar uma regra em código que verifica a data da query e busca 6 meses de dados no banco novo e busca dados do sétimo mês no banco antigo, junta ambos os dados e retorna para o usuário. É possível usar ferramentas como GraphQL para fazer esse processamento (até porque essa ferramenta foi feita para cenários dessa natureza). Porém, para essa solução funcionar, seria necessário adaptar os componentes para algo parecido com a seguinte estrutura:

Seria necessário uma camada de API para cada banco para funcionar melhor com GraphQL

Esse cenário de buscar dados de dois bancos simultaneamnete provavelmente não seria muito viável no mundo real. Aqui vale comentar uma classificação necessária antes de prosseguirmos: Dados Transacionais vs Dados Gerenciais. Dados que precisam ser lidos a quente (ou seja, por sistemas que os usam os dados ao vivo para operar), podem ser acessados diretamente via API e similares sem maiores problemas (desde que se respeite as limitações e gargalos dos sistemas envolvidos). Agora, caso o uso seja mais exploratório e sem uso definido — que é a forma que geralmente usuários de negócios usam dados históricos — , o recomendado para esse cenário é usar arquiteturas de dados mais robustas (como um Data Lake que serve de fonte da verdade para uma ferramenta de Data Visualization). Uma stack comum é usar Looker/PowerBI como ferramenta de análise e visualização e Snowflake/DataBricks como Data Lake, por exemplo.

Com isso, a solução final se torna a abaixo (lembrando que o que vai em cada caixinha depende e foi discutida ao longo do texto).

Diagrama do fluxo completo

E você, o que achou?

Me mande uma mensagem caso você encontre algo faltando ou algum erro conceitual que eu corrijo o texto.

Até!

Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.

--

--