Migrações de banco de dados: transformando lagartas em borboletas
Publicados: 2022-03-11Os usuários não se importam com o que está dentro do software que usam; apenas que funciona de forma suave, segura e discreta. Os desenvolvedores se esforçam para que isso aconteça, e um dos problemas que eles tentam resolver é como garantir que o armazenamento de dados esteja em um estado apropriado para a versão atual do produto. O software evolui e seu modelo de dados também pode mudar ao longo do tempo, por exemplo, para corrigir erros de projeto. Para complicar ainda mais o problema, você pode ter vários ambientes de teste ou clientes que migram para versões mais recentes do produto em ritmos diferentes. Você não pode apenas documentar a estrutura da loja e quais manipulações são necessárias para usar a nova versão brilhante de uma única perspectiva.
Certa vez participei de um projeto com alguns bancos de dados com estruturas que eram atualizadas sob demanda, diretamente pelos desenvolvedores. Isso significava que não havia uma maneira óbvia de descobrir quais alterações precisavam ser aplicadas para migrar a estrutura para a versão mais recente e não havia nenhum conceito de versionamento! Isso foi durante a era pré-DevOps e seria considerado uma bagunça total hoje em dia. Decidimos desenvolver uma ferramenta que seria usada para aplicar todas as alterações no banco de dados fornecido. Tinha migrações e documentaria mudanças de esquema. Isso nos deixou confiantes de que não haveria alterações acidentais e que o estado do esquema seria previsível.
Neste artigo, veremos como aplicar migrações de esquema de banco de dados relacional e como superar problemas concomitantes.
Em primeiro lugar, o que são migrações de banco de dados? No contexto deste artigo, uma migração é um conjunto de alterações que devem ser aplicadas a um banco de dados. Criar ou descartar uma tabela, coluna ou índice são exemplos comuns de migrações. A forma do seu esquema pode mudar drasticamente ao longo do tempo, especialmente se o desenvolvimento foi iniciado quando os requisitos ainda eram vagos. Assim, ao longo de vários marcos no caminho para um lançamento, seu modelo de dados terá evoluído e pode ter se tornado completamente diferente do que era no início. As migrações são apenas etapas para o estado de destino.
Para começar, vamos explorar o que temos em nossa caixa de ferramentas para evitar reinventar o que já foi bem feito.
Ferramentas
Em todas as linguagens amplamente utilizadas, existem bibliotecas que ajudam a facilitar as migrações de banco de dados. Por exemplo, no caso de Java, as opções populares são Liquibase e Flyway. Usaremos mais o Liquibase em exemplos, mas os conceitos se aplicam a outras soluções e não estão vinculados ao Liquibase.
Por que se preocupar em usar uma biblioteca de migração de esquema separada se alguns ORMs já fornecem uma opção para atualizar automaticamente um esquema e torná-lo compatível com a estrutura das classes mapeadas? Na prática, essas migrações automáticas apenas fazem alterações simples de esquema, por exemplo, criar tabelas e colunas, e não podem fazer coisas potencialmente destrutivas, como descartar ou renomear objetos de banco de dados. Portanto, soluções não automáticas (mas ainda automatizadas) geralmente são uma escolha melhor porque você mesmo é forçado a descrever a lógica de migração e sabe exatamente o que vai acontecer com seu banco de dados.
Também é uma péssima ideia misturar modificações de esquema automatizadas e manuais porque você pode produzir esquemas únicos e imprevisíveis se as alterações manuais forem aplicadas na ordem errada ou não forem aplicadas, mesmo que sejam necessárias. Depois que a ferramenta for escolhida, use-a para aplicar todas as migrações de esquema.
Migrações típicas de banco de dados
As migrações típicas incluem a criação de sequências, tabelas, colunas, chaves primárias e estrangeiras, índices e outros objetos de banco de dados. Para os tipos mais comuns de alterações, o Liquibase fornece elementos declarativos distintos para descrever o que deve ser feito. Seria muito chato ler sobre todas as mudanças triviais suportadas pelo Liquibase ou outras ferramentas semelhantes. Para ter uma ideia de como os changesets se parecem, considere o exemplo a seguir onde criamos uma tabela (declarações de namespace XML são omitidas por questões de brevidade):
<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog> <changeSet author="demo"> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> </changeSet> </databaseChangeLog> Como você pode ver, o changelog é um conjunto de changesets, e changesets consistem em alterações. Mudanças simples como createTable podem ser combinadas para implementar migrações mais complexas; por exemplo, suponha que você precise atualizar o código do produto para todos os produtos. Isso pode ser facilmente alcançado com a seguinte alteração:
<sql>UPDATE product SET code = 'new_' || code</sql>O desempenho sofrerá se você tiver zilhões de produtos. Para acelerar a migração, podemos reescrevê-la nas seguintes etapas:
- Crie uma nova tabela para produtos com
createTable, como vimos anteriormente. Nesse estágio, é melhor criar o mínimo de restrições possível. Vamos nomear a nova tabelaPRODUCT_TMP. - Preencha
PRODUCT_TMPcom SQL na forma deINSERT INTO ... SELECT ...usandosqlchange. - Crie todas as restrições (
addNotNullConstraint,addUniqueConstraint,addForeignKeyConstraint) e índices (createIndex) necessários. - Renomeie a tabela
PRODUCTpara algo comoPRODUCT_BAK. Liquibase pode fazer isso comrenameTable. - Renomeie
PRODUCT_TMPparaPRODUCT(novamente, usandorenameTable). - Opcionalmente, remova
PRODUCT_BAKcomdropTable.
Claro, é melhor evitar essas migrações, mas é bom saber como implementá-las caso você encontre um daqueles raros casos em que você precisa.
Se você considera XML, JSON ou YAML muito bizarro para a tarefa de descrever alterações, basta usar SQL simples e utilizar todos os recursos específicos do fornecedor do banco de dados. Além disso, você pode implementar qualquer lógica personalizada em Java simples.
A forma como o Liquibase o isenta de escrever SQL específico para banco de dados real pode levar ao excesso de confiança, mas você não deve esquecer as peculiaridades do seu banco de dados de destino; por exemplo, quando você cria uma chave estrangeira, um índice pode ou não ser criado, dependendo do sistema de gerenciamento de banco de dados específico que está sendo usado. Como resultado, você pode se encontrar em uma situação embaraçosa. O Liquibase permite que você especifique que um changeset deve ser executado apenas para um tipo específico de banco de dados, por exemplo, PostgreSQL, Oracle ou MySQL. Isso torna isso possível usando os mesmos conjuntos de alterações independentes de fornecedor para bancos de dados diferentes e para outros conjuntos de alterações, usando sintaxe e recursos específicos do fornecedor. O seguinte changeset será executado apenas se estiver usando um banco de dados Oracle:
<changeSet dbms="oracle" author="..."> ... </changeSet>Além do Oracle, o Liquibase suporta alguns outros bancos de dados prontos para uso.
Nomeando objetos de banco de dados
Cada objeto de banco de dados que você cria precisa ser nomeado. Você não é obrigado a fornecer explicitamente um nome para alguns tipos de objetos, por exemplo, para restrições e índices. Mas isso não significa que esses objetos não terão nomes; seus nomes serão gerados pelo banco de dados de qualquer maneira. O problema surge quando você precisa fazer referência a esse objeto para eliminá-lo ou alterá-lo. Portanto, é melhor dar-lhes nomes explícitos. Mas existem regras sobre quais nomes dar? A resposta é curta: seja consistente; por exemplo, se você decidiu nomear índices como este: IDX_<table>_<columns> , então um índice para a coluna CODE acima mencionada deve ser nomeado IDX_PRODUCT_CODE .
As convenções de nomenclatura são incrivelmente controversas, por isso não pretendemos fornecer instruções abrangentes aqui. Seja consistente, respeite as convenções de sua equipe ou projeto, ou apenas invente-as se não houver nenhuma.
Organizando conjuntos de mudanças
A primeira coisa a decidir é onde armazenar os changesets. Existem basicamente duas abordagens:
- Mantenha changesets com o código do aplicativo. É conveniente fazer isso porque você pode confirmar e revisar conjuntos de alterações e código do aplicativo juntos.
- Mantenha os conjuntos de alterações e o código do aplicativo separados , por exemplo, em repositórios VCS separados. Essa abordagem é adequada quando o modelo de dados é compartilhado entre vários aplicativos e é mais conveniente armazenar todos os conjuntos de alterações em um repositório dedicado e não espalhá-los em vários repositórios onde o código do aplicativo reside.
Onde quer que você armazene os changesets, geralmente é razoável dividi-los nas seguintes categorias:
- Migrações independentes que não afetam o sistema em execução. Geralmente, é seguro criar novas tabelas, sequências, etc., se o aplicativo atualmente implementado ainda não estiver ciente delas.
- Modificações de esquema que alteram a estrutura da loja , por exemplo, adicionar ou remover colunas e índices. Essas alterações não devem ser aplicadas enquanto uma versão mais antiga do aplicativo ainda estiver em uso, pois isso pode levar a bloqueios ou comportamento estranho devido a alterações no esquema.
- Migrações rápidas que inserem ou atualizam pequenas quantidades de dados. Se vários aplicativos estiverem sendo implantados, os conjuntos de alterações dessa categoria podem ser executados simultaneamente sem prejudicar o desempenho do banco de dados.
- Migrações potencialmente lentas que inserem ou atualizam muitos dados. Essas alterações são melhores para serem aplicadas quando nenhuma outra migração semelhante estiver sendo executada.

Esses conjuntos de migrações devem ser executados consecutivamente antes da implantação de uma versão mais recente de um aplicativo. Essa abordagem fica ainda mais prática se um sistema for composto por vários aplicativos separados e alguns deles usarem o mesmo banco de dados. Caso contrário, vale a pena separar apenas os changesets que podem ser aplicados sem afetar os aplicativos em execução, e os changesets restantes podem ser aplicados juntos.
Para aplicativos mais simples, o conjunto completo de migrações necessárias pode ser aplicado na inicialização do aplicativo. Nesse caso, todos os changesets se enquadram em uma única categoria e são executados sempre que o aplicativo é inicializado.
Seja qual for o estágio escolhido para aplicar as migrações, vale ressaltar que usar o mesmo banco de dados para vários aplicativos pode causar bloqueios quando as migrações estão sendo aplicadas. O Liquibase (como muitas outras soluções semelhantes) utiliza duas tabelas especiais para registrar seus metadados: DATABASECHANGELOG e DATABASECHANGELOGLOCK . O primeiro é usado para armazenar informações sobre conjuntos de alterações aplicados e o último para evitar migrações simultâneas no mesmo esquema de banco de dados. Portanto, se vários aplicativos precisarem usar o mesmo esquema de banco de dados por algum motivo, é melhor usar nomes não padrão para tabelas de metadados para evitar bloqueios.
Agora que a estrutura de alto nível está clara, você precisa decidir como organizar os conjuntos de mudanças dentro de cada categoria.
Depende muito dos requisitos específicos da aplicação, mas os seguintes pontos geralmente são razoáveis:
- Mantenha os logs de alterações agrupados por versões do seu produto. Crie um novo diretório para cada versão e coloque os arquivos de registro de alterações correspondentes nele. Tenha um changelog raiz e inclua changelogs que correspondam aos lançamentos. Nos changelogs da versão, inclua outros changelogs que compõem esta versão.
- Tenha uma convenção de nomenclatura para arquivos de changelog e identificadores de changeset—e siga-a, é claro.
- Evite conjuntos de alterações com muitas alterações. Prefira vários conjuntos de alterações a um único conjunto de alterações longo.
- Se você usar procedimentos armazenados e precisar atualizá-los, considere usar o
runOnChange="true"do changeset no qual esse procedimento armazenado é adicionado. Caso contrário, cada vez que for atualizado, você precisará criar um novo conjunto de alterações com uma nova versão do procedimento armazenado. Os requisitos variam, mas geralmente é aceitável não rastrear esse histórico. - Considere esmagar alterações redundantes antes de mesclar ramificações de recursos. Às vezes, acontece que em uma ramificação de recurso (especialmente em uma de longa duração) os conjuntos de alterações posteriores refinam as alterações feitas nos conjuntos de alterações anteriores. Por exemplo, você pode criar uma tabela e decidir adicionar mais colunas a ela. Vale a pena adicionar essas colunas à alteração inicial de
createTablese essa ramificação de recurso ainda não tiver sido mesclada à ramificação principal. - Use os mesmos changelogs para criar um banco de dados de teste. Se você tentar fazer isso, poderá descobrir em breve que nem todo conjunto de alterações é aplicável ao ambiente de teste ou que conjuntos de alterações adicionais são necessários para esse ambiente de teste específico. Com o Liquibase, esse problema é facilmente resolvido usando contextos . Basta adicionar o atributo
context="test"aos changesets que precisam ser executados apenas com testes, e então inicializar o Liquibase com o contexto detesthabilitado.
Revertendo
Como outras soluções semelhantes, o Liquibase suporta a migração de esquemas “para cima” e “para baixo”. Mas esteja avisado: desfazer migrações pode não ser fácil e nem sempre vale a pena o esforço. Se você decidiu dar suporte a desfazer migrações para seu aplicativo, seja consistente e faça isso para cada conjunto de alterações que precisaria ser desfeito. Com o Liquibase, desfazer um changeset é feito adicionando uma tag de rollback que contém as mudanças necessárias para realizar um rollback. Considere o seguinte exemplo:
<changeSet author="..."> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> <rollback> <dropTable tableName="PRODUCT"/> </rollback> </changeSet> A reversão explícita é redundante aqui porque o Liquibase executaria as mesmas ações de reversão. O Liquibase é capaz de reverter automaticamente a maioria de seus tipos de alterações suportados, por exemplo, createTable , addColumn ou createIndex .
Consertando o Passado
Ninguém é perfeito, e todos cometemos erros. Alguns deles podem ser descobertos tarde demais quando as alterações estragadas já foram aplicadas. Vamos explorar o que poderia ser feito para salvar o dia.
Atualizar manualmente o banco de dados
Envolve mexer com DATABASECHANGELOG e seu banco de dados das seguintes maneiras:
- Se você quiser corrigir changesets incorretos e executá-los novamente:
- Remova as linhas de
DATABASECHANGELOGque correspondem aos conjuntos de alterações. - Remova todos os efeitos colaterais que foram introduzidos pelos changesets; por exemplo, restaurar uma tabela se ela foi descartada.
- Corrija os conjuntos de alterações ruins.
- Execute as migrações novamente.
- Remova as linhas de
- Se você deseja corrigir conjuntos de alterações ruins, mas pule aplicá-los novamente:
- Atualize
DATABASECHANGELOGdefinindo o valor do campoMD5SUMcomoNULLpara as linhas que correspondem aos conjuntos de alterações incorretos. - Corrija manualmente o que foi feito de errado no banco de dados. Por exemplo, se houver uma coluna adicionada com o tipo errado, emita uma consulta para modificar seu tipo.
- Corrija os conjuntos de alterações ruins.
- Execute as migrações novamente. O Liquibase irá calcular a nova soma de verificação e salvá-la em
MD5SUM. Os conjuntos de alterações corrigidos não serão executados novamente.
- Atualize
Obviamente, é fácil fazer esses truques durante o desenvolvimento, mas fica muito mais difícil se as alterações forem aplicadas a vários bancos de dados.
Gravar conjuntos de alterações corretivas
Na prática, essa abordagem geralmente é mais apropriada. Você pode se perguntar, por que não apenas editar o conjunto de alterações original? A verdade é que depende do que precisa ser mudado. O Liquibase calcula uma soma de verificação para cada conjunto de alterações e se recusa a aplicar novas alterações se a soma de verificação for nova para pelo menos um dos conjuntos de alterações aplicados anteriormente. Esse comportamento pode ser personalizado por conjunto de alterações especificando o runOnChange="true" . A soma de verificação não é afetada se você modificar pré-condições ou atributos opcionais do conjunto de alterações ( context , runOnChange , etc.).
Agora, você pode estar se perguntando, como você eventualmente corrige changesets com erros?
- Se você quiser que essas alterações ainda sejam aplicadas a novos esquemas, basta adicionar conjuntos de alterações corretivas. Por exemplo, se houver uma coluna adicionada com o tipo errado, modifique seu tipo no novo conjunto de alterações.
- Se você quiser fingir que esses conjuntos de alterações ruins nunca existiram, faça o seguinte:
- Remova os changesets ou adicione o atributo
contextcom um valor que garanta que você nunca tente aplicar migrações com esse contexto novamente, por exemplo,context="graveyard-changesets-never-run". - Adicione novos conjuntos de alterações que irão reverter o que foi feito de errado ou corrigi-lo. Essas alterações devem ser aplicadas somente se forem aplicadas alterações ruins. Isso pode ser alcançado com pré-condições, como
changeSetExecuted. Não se esqueça de adicionar um comentário explicando por que você está fazendo isso. - Adicione novos conjuntos de alterações que modifiquem o esquema da maneira correta.
- Remova os changesets ou adicione o atributo
Como você vê, consertar o passado é possível, embora nem sempre seja simples.
Atenuando as dores do crescimento
À medida que seu aplicativo envelhece, seu registro de alterações também cresce, acumulando todas as alterações de esquema ao longo do caminho. É por design, e não há nada inerentemente errado com isso. Longos logs de mudanças podem ser reduzidos com o esmagamento regular de migrações, por exemplo, após o lançamento de cada versão do produto. Em alguns casos, isso tornaria a inicialização do novo esquema mais rápida.
O esmagamento nem sempre é trivial e pode causar regressões sem trazer muitos benefícios. Outra ótima opção é usar um banco de dados seed para evitar a execução de todos os changesets. Ele é adequado para ambientes de teste se você precisar ter um banco de dados pronto o mais rápido possível, talvez até com alguns dados de teste. Você pode pensar nisso como uma forma de esmagar conjuntos de alterações: em algum ponto (por exemplo, após lançar outra versão), você faz um dump do esquema. Depois de restaurar o dump, você aplica as migrações como de costume. Apenas novas alterações serão aplicadas porque as mais antigas já foram aplicadas antes de fazer o dump; portanto, eles foram restaurados do lixão.
Conclusão
Evitamos intencionalmente mergulhar mais fundo nos recursos da Liquibase para entregar um artigo curto e direto ao ponto, focado na evolução de esquemas em geral. Espero que esteja claro quais benefícios e problemas são trazidos pela aplicação automatizada de migrações de esquema de banco de dados e quão bem tudo isso se encaixa na cultura DevOps. É importante não transformar nem as boas ideias em dogmas. Os requisitos variam e, como engenheiros de banco de dados, nossas decisões devem promover o avanço de um produto e não apenas aderir às recomendações de alguém na Internet.
