Migrations sem downtime na prática
A mudança de schema nunca é a parte difícil. O lock, o backfill e o código velho ainda rodando são.
Todo time diz que faz migration sem downtime. O que a maioria quer dizer é que a ferramenta de migration roda antes do deploy e geralmente nada quebra. A diferença entre geralmente e sempre é um conjunto de regras sobre locks, ordem e backfills que não são complicadas mas precisam ser aplicadas toda vez. Este é o jeito que eu faço de verdade no Postgres, que é onde mora a maior parte do meu trabalho.
Regra um: saiba quais statements travam
Adicionar coluna nullable sem default é instantâneo. Adicionar coluna com default foi reescrita de tabela inteira por muitos anos; nas versões atuais é instantâneo para default constante, mas não para volátil, e o time precisa saber qual versão roda. Criar índice sem a opção concurrent trava escritas na tabela durante toda a construção. Numa tabela grande isso é minutos de downtime disfarçados de migration.
Adicionar foreign key valida cada linha existente sob lock. Adicionar constraint not-null varre a tabela inteira. Mudar o tipo de uma coluna reescreve ela. Renomear coluna é instantâneo mas quebra toda instância do código velho que estiver rodando, que é o assunto da regra três.
O hábito prático: antes de qualquer migration numa tabela acima de alguns milhões de linhas, leia a documentação daquele statement exato naquela versão exata, e defina um lock timeout de poucos segundos para que uma migration que bloquearia espere pouco e falhe em vez de segurar a produção atrás dela.
Regra dois: índices e constraints em dois passos
Índices são criados com concurrently. É mais lento, pode falhar e deixar um índice inválido que precisa ser removido e refeito, e não bloqueia escritas. Essas são as trocas certas para produção. A ferramenta de migration precisa rodar isso fora de transação, o que a maioria suporta com uma flag, e quem escreve a migration precisa saber que tem que ligar essa flag.
Constraints são adicionadas como not valid primeiro, o que é instantâneo e vale só para linhas novas, e depois validadas num statement separado que varre a tabela sem bloquear escritas. Not-null numa coluna existente é feito adicionando uma check constraint como not valid, validando, e depois, em versões recentes, definindo not null usando essa constraint como prova. Cada uma dessas é duas migrations, não uma, e a segunda pode rodar horas depois.
Regra três: expandir, migrar, contrair
Toda mudança que o código velho não entende é dividida em fases em deploys separados. Expandir: adiciona a coluna, tabela ou campo novo, nullable, sem uso. Publica código que escreve nos dois, velho e novo. Faz backfill do novo a partir do velho em lotes, alguns milhares de linhas por vez, com pausa entre lotes para replicação e vacuum acompanharem. Publica código que lê do novo e ainda escreve nos dois. Publica código que escreve só no novo. Contrair: remove a coluna velha, em migration própria, depois de um ciclo de release ter passado e rollback para o código anterior não ser mais plausível.
É lento. Um rename que seria uma linha vira quatro releases. A alternativa é um rename que funciona em staging, onde nenhuma instância velha está rodando, e falha em produção durante os trinta segundos em que as duas versões coexistem.
Regra quatro: backfill é job, não migration
Um backfill que toca milhões de linhas não pertence a um arquivo de migration. Pertence a um job idempotente, retomável, em lotes, com limite de taxa e observável. Ele registra o progresso para poder ser parado e reiniciado. É rodado manualmente na primeira vez, observado, e só então agendado. Quando termina, uma verificação separada confirma que velho e novo concordam antes de qualquer código começar a confiar no novo.
O checklist antes de toda migration
- Quais statements dessa migration pegam lock, e por quanto tempo no tamanho da tabela em produção?
- Todo índice é concurrent, e a ferramenta roda ele fora de transação?
- O código atualmente publicado consegue rodar contra o schema depois dessa migration?
- A release anterior consegue rodar contra ele, se fizermos rollback?
- Existe backfill, e ele é um job com rastreio de progresso em vez de um statement?
Cinco perguntas, feitas na revisão, toda vez. Essa é a prática inteira. Não é esperta. Ela só se recusa a pular a parte chata, e a parte chata é onde mora o downtime.