Post-mortem: a migração de coluna que derrubou o checkout por 38 minutos

Escrito por

em

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_activity e 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.

Rodar toda migração com limites explícitos muda o modo de falha. Em PostgreSQL: 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.

Comentários

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *