Um ALTER TABLE para adicionar uma coluna com NOT NULL DEFAULT em uma tabela de pedidos com dezenas de milhões de linhas, executado às 14h de uma terça, com o checkout recebendo tráfego. O resultado: 38 minutos de erro 500 em finalização de compra. O caso abaixo é uma reconstrução hipotética, montada a partir de padrões de falha que se repetem em times que rodam migração junto com o deploy da aplicação. Serve para separar o que é limitação do banco do que é falha de processo.
A linha do tempo do incidente
O deploy subiu às 14h02. A migração era considerada trivial: uma coluna nova em pedidos, com valor padrão, para marcar o estado do pagamento. Em ambiente de homologação, com uma base de poucos milhares de registros, a migração rodou em menos de um segundo.
Em produção, a sequência foi outra:
- 14h02 — o runner de migração abre transação e emite o ALTER TABLE.
- 14h02 — o comando adquire um lock exclusivo (ACCESS EXCLUSIVE, no vocabulário do PostgreSQL) sobre a tabela.
- 14h03 — toda consulta nova em
pedidos, inclusive SELECT, entra na fila atrás do lock. - 14h05 — o pool de conexões da aplicação satura. Requisições que nem tocam pedidos começam a falhar por indisponibilidade de conexão.
- 14h11 — o alerta de taxa de erro dispara. O time ainda procura o problema no serviço de pagamento.
- 14h33 — alguém consulta
pg_stat_activitye encontra a migração ainda em execução, com uma fila de centenas de sessões em lock wait. - 14h40 — a migração é cancelada e sofre rollback. O sistema normaliza em dois minutos.
Duas coisas importam nessa linha do tempo. A primeira é que o incidente durou 38 minutos, mas o diagnóstico consumiu 30 deles. A segunda é que o efeito extrapolou a tabela afetada: a saturação do pool transformou um problema localizado em queda geral.
Por que o ALTER TABLE trava a tabela inteira
A causa raiz não é o ALTER TABLE em si. Versões recentes de PostgreSQL adicionam coluna com valor padrão sem reescrever a tabela — o default fica registrado no catálogo e é materializado só na próxima escrita da linha. A operação é rápida.
O problema é o que acompanha o comando. Três armadilhas explicam a maioria dos casos:
O lock é exclusivo mesmo quando a operação é rápida. Para pegar o lock, o comando precisa esperar que todas as transações abertas sobre a tabela terminem. Basta uma consulta analítica de dois minutos, ou uma transação esquecida em estado idle in transaction, para o ALTER ficar bloqueado. E, uma vez na fila, ele bloqueia todo mundo que chega depois — inclusive leituras. Uma operação de 50 milissegundos vira uma parada de meia hora por causa de uma transação que ninguém fechou.
Nem toda variação evita a reescrita. Adicionar coluna com default constante é barato. Adicionar com default calculado por função volátil, mudar tipo de coluna, ou adicionar uma constraint CHECK sem a cláusula NOT VALID força varredura ou reescrita completa. A diferença entre o caso barato e o caro está em detalhes de sintaxe que passam despercebidos em revisão de código.
Migração e deploy compartilham o mesmo timeout. Se o runner de migração roda sem lock_timeout, ele espera indefinidamente. O comportamento desejado é o oposto: falhar rápido e tentar de novo.
SET lock_timeout = '3s'; SET statement_timeout = '30s'; antes do DDL. A migração passa a falhar em três segundos em vez de bloquear a tabela por meia hora — e a falha some do caminho crítico assim que o runner tenta de novo.Expand e contract: a migração em quatro deploys
A correção estrutural é parar de tratar mudança de schema como evento único. O padrão expand–contract divide a alteração em etapas nas quais schema antigo e novo coexistem, de modo que nenhum deploy exija que banco e aplicação mudem no mesmo instante.
Para a coluna do incidente, a sequência fica assim:
Deploy 1 — expandir. Adicionar a coluna como nullable, sem default e sem constraint. É a operação mais barata que existe: só catálogo, sem reescrita. A aplicação ainda não conhece a coluna.
ALTER TABLE pedidos ADD COLUMN status_pagamento varchar(32);
Deploy 2 — escrever nos dois lugares. A aplicação passa a preencher a coluna nova em toda escrita, mas continua lendo do campo antigo. Nenhuma leitura depende ainda do dado novo, então um bug aqui não quebra o fluxo do usuário.
Deploy 3 — backfill e virada da leitura. Preencher as linhas antigas em lotes (detalhado na seção seguinte) e, só depois de o backfill terminar e ser verificado, mudar a leitura para a coluna nova.
Deploy 4 — contrair. Remover a escrita dupla, aplicar a constraint NOT NULL e derrubar o campo antigo. A constraint entra em duas etapas para não varrer a tabela sob lock exclusivo:
ALTER TABLE pedidos
ADD CONSTRAINT pedidos_status_pagamento_nn
CHECK (status_pagamento IS NOT NULL) NOT VALID;
ALTER TABLE pedidos
VALIDATE CONSTRAINT pedidos_status_pagamento_nn;
A primeira instrução registra a constraint sem checar as linhas existentes — rápida, lock curto. A segunda varre a tabela, mas com um lock que não bloqueia leitura nem escrita. O custo é ter uma constraint CHECK onde se queria um NOT NULL de coluna; em compensação, o processo nunca para o sistema.
O custo real do expand–contract é organizacional: quatro deploys em vez de um, e uma janela de dias em que o código carrega escrita dupla. Vale a pena para qualquer tabela grande com tráfego. Não vale para tabela de configuração com centenas de linhas — nesse caso o ALTER direto, com lock_timeout curto, resolve.
Backfill: o detalhe que quase repetiu o incidente
Depois do incidente, o time escreveu o backfill como um único UPDATE. Isso reproduz o problema em outra forma: um UPDATE que toca dezenas de milhões de linhas em uma transação gera uma transação longuíssima, infla o WAL, atrasa a replicação e impede o autovacuum de limpar versões mortas de linha. O sistema não trava, mas degrada por horas.
Backfill correto é feito em lotes, com pausa entre eles e commit por lote:
UPDATE pedidos
SET status_pagamento = CASE
WHEN pago_em IS NOT NULL THEN 'pago'
ELSE 'pendente'
END
WHERE id IN (
SELECT id FROM pedidos
WHERE status_pagamento IS NULL
ORDER BY id
LIMIT 5000
);
O laço roda até nenhuma linha ser afetada. Três parâmetros governam o comportamento: tamanho do lote, pausa entre lotes e critério de parada. Lotes na casa de alguns milhares de linhas costumam ser o ponto de equilíbrio — grandes o bastante para terminar em tempo razoável, pequenos o bastante para que cada transação dure milissegundos. A pausa entre lotes existe para dar espaço ao autovacuum e à replicação; começar com algo em torno de cem milissegundos e ajustar observando o atraso de réplica é mais confiável do que fixar um número no código.
O critério de parada merece atenção especial. Um backfill que roda por horas precisa poder ser interrompido e retomado sem perder progresso — daí filtrar por status_pagamento IS NULL em vez de paginar por offset. Se o processo morrer no meio, basta rodar de novo.
Comparativo: quando usar cada estratégia
| Estratégia | Cabe quando | Custo | Risco de indisponibilidade |
|---|---|---|---|
| ALTER direto no deploy | Tabela pequena, sem tráfego contínuo | Um deploy | Alto se a tabela crescer sem ninguém revisar |
| ALTER com lock_timeout e retry | Tabela média, operação que só mexe no catálogo | Um deploy mais lógica de retry | Baixo — falha rápido em vez de bloquear |
| Expand–contract com backfill em lotes | Tabela grande com tráfego, mudança que altera dados | Três a quatro deploys ao longo de dias | Muito baixo |
| Ferramenta de migração online | MySQL, ou mudança de tipo em tabela muito grande | Ferramenta externa no pipeline, espaço em disco extra | Baixo, com complexidade operacional maior |
Entre as quatro, a que mais se ganha adotando por padrão é a segunda. Colocar lock_timeout em toda migração é uma linha de configuração no runner e elimina a classe inteira de incidentes em que uma operação barata fica presa atrás de uma transação esquecida. O expand–contract é mais trabalhoso e deve ser reservado a mudanças que realmente mexem em dados de tabela grande.
O que entra no runbook
Três mudanças de processo evitam a repetição, e nenhuma delas depende de o desenvolvedor lembrar da regra na hora certa:
- Migração fora do deploy da aplicação. Enquanto os dois compartilham o mesmo passo do pipeline, uma migração lenta segura o rollback do código. Separar os dois permite reverter a aplicação sem tocar no banco.
- Limite de tamanho na revisão. Um teste no CI que consulta o número estimado de linhas da tabela alvo e exige aprovação explícita acima de um limiar — algo na casa do milhão de linhas — transforma a regra em barreira automática.
- Monitor de transação longa. Um alerta para sessões em idle in transaction acima de alguns minutos. Foi uma dessas que segurou o lock no incidente, e ela já estava aberta antes do deploy começar.
O próximo passo prático é levantar as três maiores tabelas do sistema e verificar quais migrações do último trimestre teriam sido bloqueantes nelas. O padrão costuma aparecer rápido: quase sempre são duas ou três tabelas que concentram o risco, e é nelas que o expand–contract precisa ser obrigatório.
Deixe um comentário