en
· 3 min de leitura

Evolução de schema sem downtime

Expand and contract, backfill em lotes, migrations conscientes de lock e cutover por flag: como mudar o schema com tudo rodando.

system-designdata

A linha mais perigosa de um deploy não está no código da aplicação. Está no ALTER TABLE. Mudanças de schema mexem no único componente que não pode ser reiniciado de qualquer jeito, não pode voltar atrás com um redeploy e guarda os dados de todos os clientes ao mesmo tempo.

Mesmo assim, a maioria dos times trata migrations como formalidade: escreve a migration, roda no pipeline, sobe. Funciona até o dia em que a tabela tem 80 milhões de linhas e a migration pega um lock às 14h de uma terça-feira.

Expandir, depois contrair

A disciplina central é expand and contract. Nunca faça uma mudança que a aplicação em execução não consiga sobreviver. Em vez disso, divida cada mudança em uma fase que só adiciona e uma fase que só remove, com um período no meio em que as formas antiga e nova coexistem.

Pegue o caso clássico: renomear uma coluna de "name" para "full_name". O rename em um passo quebra toda instância em execução no momento em que roda. A versão expand and contract tem cinco passos. Adicione full_name como nullable. Faça o backfill em lotes de alguns milhares de linhas, com uma pausa entre lotes. Mude a aplicação para dual-write, ou seja, toda escrita atualiza as duas colunas. Mude as leituras para full_name quando o backfill estiver completo e verificado. Só então, depois de um ou dois deploys de confiança, remova name.

Dá mais trabalho. Também é a única versão que nunca tem um momento em que o banco e o código discordam sobre o mundo.

Versões sobrepostas são o caso normal

O expand and contract existe porque deploys não são atômicos. Durante um rolling deploy, a versão N e a versão N+1 rodam lado a lado por minutos, às vezes mais. Se o schema só funciona com uma delas, você tem downtime em todo deploy e, pior, o rollback do código vira impossível porque o código antigo não entende mais o schema.

Toda migration deveria ser testada também contra a versão anterior da aplicação. Se o código antigo não roda com o schema novo, você não terminou a fase de expansão.

Locks e operações longas

Algumas operações que parecem inocentes não são. Adicionar uma coluna NOT NULL com default reescrevia a tabela inteira em versões antigas do Postgres, segurando um lock exclusivo o tempo todo. Versões modernas resolvem isso barato, mas adicionar uma constraint NOT NULL em uma coluna existente ainda exige um scan completo. Adicionar uma foreign key valida todas as linhas. Criar um índice bloqueia escritas, a menos que você crie concurrently, o que não pode rodar dentro de uma transação.

A regra que sigo: toda migration em tabela grande é lida como uma pergunta. Que lock isso pega, e por quanto tempo? Se a resposta é "não tenho certeza", não sobe até alguém ter, de preferência rodando contra uma cópia do tamanho de produção e cronometrando.

Eventos também têm schema

Tudo acima vale para contratos de mensagem. Um evento na fila é um schema do qual consumidores dependem, e consumidores não fazem deploy em sincronia com produtores. Adicione campos, nunca remova ou renomeie em um passo. Tolere campos desconhecidos do lado do consumidor. Versione o evento quando a forma mudar de verdade, e continue consumindo a versão antiga até todos os produtores terem migrado.

Flags e planos de rollback

Para os passos de cutover, trocar leituras e trocar escritas, use uma feature flag em vez de um deploy. Uma flag volta em segundos. Um deploy leva minutos e um engenheiro nervoso.

E antes de qualquer fase de contração, escreva o plano de rollback literalmente como um runbook: se removermos esta coluna e algo quebrar, o que fazemos? Se a resposta honesta é "restaurar do backup", espere mais antes de remover. Manter uma coluna sem uso por mais um mês não custa nada. Perder dados custa tudo.

Evoluir schema sem downtime não é esperteza. É lento, chato e dividido em passos pequenos. É exatamente por isso que funciona.