Às 14h07 de uma terça, a API de um CRM multi-tenant começou a devolver timeout em todas as rotas, inclusive nas que não tocavam a tabela alterada. O pool de conexões chegou a 100% em menos de um minuto. A causa: um ALTER TABLE de uma linha, aprovado em code review, rodando como parte do deploy. Este post-mortem é hipotético, mas o mecanismo é real e reproduzível em qualquer PostgreSQL.
Linha do tempo
A migração adicionava uma coluna com restrição NOT NULL a uma tabela de contatos com algumas dezenas de milhões de linhas. O time havia testado em staging, onde a tabela tinha poucos milhares de registros e a migração terminou em milissegundos. Em produção, o fluxo foi este:
Um relatório analítico, disparado por um cliente grande, mantinha uma transação aberta havia vários minutos, com leitura sobre a tabela de contatos. O deploy iniciou a migração. O ALTER TABLE pede um bloqueio de acesso exclusivo, que conflita com qualquer outro, inclusive com o bloqueio de leitura que o relatório segura. Ele entrou na fila de espera. Até aí, só a migração esperava.
O problema veio a seguir. No PostgreSQL, a fila de bloqueios respeita a ordem de chegada: toda nova consulta sobre a tabela, até um simples SELECT, passa a esperar atrás do ALTER TABLE pendente. Em segundos, todas as requisições que tocavam contatos ficaram penduradas. Cada uma segurava uma conexão do pool. Com o pool esgotado, rotas sem relação com a tabela também pararam, porque não havia conexão livre para atendê-las.
Causa raiz
Três fatores se combinaram, e nenhum sozinho derrubaria o sistema.
O primeiro foi a ausência de limite de espera por bloqueio na sessão da migração. Sem lock_timeout, o ALTER TABLE espera indefinidamente, e enquanto espera, bloqueia todo mundo que chega depois. O segundo foi a transação longa do relatório, que ocupava a tabela sem que ninguém monitorasse. O terceiro foi o pool dimensionado para o caso normal: cem conexões servem bem quando as consultas duram milissegundos e colapsam quando todas passam a esperar.
A suposição errada do time foi tratar a migração como cara apenas se rodasse devagar. Aqui ela rodaria em instantes, caso obtivesse o bloqueio. O custo estava na espera por ele, e não na execução.
O diagnóstico levou mais tempo do que a correção, e o motivo merece registro. Os dashboards mostravam CPU do banco baixa, que é o sinal enganoso típico de bloqueio: ninguém trabalha, todo mundo espera. A pista veio de pg_stat_activity, filtrando sessões com wait_event_type igual a Lock, e da função pg_blocking_pids, que aponta quem bloqueia quem. A cadeia era curta e legível: o relatório bloqueava a migração, e a migração bloqueava todas as demais. Quem soube procurar encontrou em minutos; quem olhou só métricas de recurso perdeu cerca de um quarto do tempo total do incidente investigando rede e balanceador.
Houve ainda um efeito colateral na replicação. Enquanto o ALTER TABLE esperava, a réplica de leitura continuou saudável, mas assim que a migração concluiu, o comando foi replicado e exigiu o mesmo bloqueio exclusivo na réplica, onde outras consultas longas estavam rodando. O atraso de replicação subiu por alguns minutos e as telas servidas pela réplica exibiram dados defasados. Migrações bloqueantes cobram duas vezes, uma no primário e outra em cada réplica.
Por que o staging não mostrou nada
Staging validou a sintaxe e a lógica, mas não reproduzia concorrência. Sem transações longas, o bloqueio era obtido na hora e liberado em seguida. O teste de migração mais valioso é o que roda com tráfego sintético e pelo menos uma transação longa aberta de propósito. Quem nunca viu uma migração travar na fila não escreve migração defensiva por instinto.
Outra lacuna: a revisão de código avaliava o SQL, não o plano de execução da migração. Perguntas como “qual bloqueio isso pede e por quanto tempo” não faziam parte do checklist de revisão.
Um terceiro ponto de atenção é o comportamento do ORM e do driver durante o incidente. Várias requisições tinham timeout de cliente mais curto do que o de consulta no banco, então o cliente desistia, tentava de novo e a consulta original continuava na fila. O resultado foi uma avalanche de tentativas que multiplicou o número de sessões esperando e atrasou a normalização mesmo depois de o bloqueio ser liberado. Definir statement_timeout no servidor, alinhado ao timeout do cliente, impede que consultas abandonadas continuem ocupando conexão, e retries com backoff exponencial e jitter evitam a tempestade de reenvios.
Correção aplicada na hora
A mitigação foi encerrar a sessão do relatório com pg_terminate_backend, o que liberou o bloqueio, deixou a migração prosseguir e esvaziou a fila em segundos. Funcionou, mas à custa de matar a consulta de um cliente. O ideal seria ter cancelado a migração, que é a parte barata de refazer.
Um detalhe sobre a própria operação: adicionar uma coluna com valor padrão constante é, desde o PostgreSQL 11, uma alteração apenas de catálogo, sem reescrever a tabela. Já uma coluna NOT NULL sem padrão exige preenchimento prévio, e dependendo do caminho escolhido pode implicar varredura completa da tabela para validar a restrição. Confira a documentação da versão em uso antes de assumir qual dos casos se aplica.
Ações corretivas
Depois do incidente, o time adotou quatro mudanças, em ordem de impacto.
Limite de espera obrigatório. Toda migração de esquema roda com lock_timeout curto, na faixa de poucos segundos, e statement_timeout adequado ao tamanho da tabela. Se o bloqueio não é obtido, a migração falha rápido e é repetida depois, em vez de travar a produção. O deploy passa a tratar essa falha como esperada e reexecutável, com algumas tentativas espaçadas.
Migração em etapas. Coluna nova entra anulável e sem restrição. A aplicação passa a escrever nela. Um backfill em lotes pequenos preenche as linhas antigas, com pausa entre lotes para não saturar replicação. A restrição entra por último; no PostgreSQL, adicionar uma CHECK como NOT VALID e validar depois reduz o tempo sob bloqueio forte, porque a validação roda com bloqueio mais fraco. Cada etapa é um deploy separado e reversível.
Alarme para transações longas. Uma consulta sobre pg_stat_activity filtrando transações abertas há mais de alguns minutos alimenta um alerta. Relatórios pesados foram redirecionados para uma réplica de leitura, o que remove a causa na origem. Atenção: consultas longas em réplica têm seu próprio conflito com a recuperação, configurável, e precisam de ajuste.
Pool com teto por rota. Foi criado um limite de conexões por grupo de rotas, de modo que uma tabela travada não consiga sequestrar o pool inteiro. O efeito é uma degradação parcial em vez de queda total, que é o que se quer de qualquer sistema com dependência compartilhada.
O que mudou no processo
Vale registrar o que o time decidiu não fazer. Não adotou ferramenta de migração online de terceiros, porque o conjunto de etapas manuais cobria os casos reais e a ferramenta adicionaria mais um componente para operar. Também não proibiu transações longas em geral, só passou a vigiá-las e a deslocar as analíticas para fora do primário. A proibição bateria de frente com cargas legítimas, e o alarme resolve o risco com muito menos atrito.
O checklist de revisão de migrações ganhou três perguntas: qual bloqueio a instrução adquire, o que acontece se ele demorar a ser concedido e se a operação é reversível sem perda. Migrações que alteram tabelas acima de um certo tamanho passam a exigir plano escrito em etapas, e o deploy deixou de rodar migração de esquema no mesmo passo da troca de versão da aplicação, o que permite parar uma sem a outra.
O incidente durou alguns minutos de indisponibilidade total e mais algum tempo de recuperação de filas acumuladas. O aprendizado técnico cabe numa linha: em PostgreSQL, a migração que espera é mais perigosa do que a migração que roda.
Deixe um comentário