01
O job que rodava em três minutos e passou a levar cinquenta e três
A primeira investigação quase sempre vai para o lugar errado. O time abre o comando do expurgo, roda o EXPLAIN, vê uma busca por índice em pedidos filtrando por status e data, com custo baixo e estimativa de duas mil linhas, e conclui que o problema não está ali. O plano está certo. O que o plano do DELETE não mostra é o trabalho que acontece depois de cada linha removida, fora do comando que você escreveu, nos gatilhos internos que o banco usa para garantir a integridade referencial.
No PostgreSQL, cada chave estrangeira é implementada por gatilhos de sistema. Quando uma linha de pedidos é excluída, o gatilho da restrição executa, para aquela linha, uma consulta na tabela filha procurando registros que apontam para o pedido removido: um DELETE se a restrição for ON DELETE CASCADE, um UPDATE se for SET NULL, ou uma verificação de existência se for NO ACTION ou RESTRICT. Se a coluna da filha tem índice, essa consulta é uma busca de microssegundos. Se não tem, é uma varredura sequencial da filha inteira. Por linha excluída no pai.
É aí que está o detalhe que engana: o banco cria índice automaticamente para a chave primária e para restrições UNIQUE, que é o lado referenciado, mas não cria nada na coluna que referencia. Declarar a chave estrangeira garante a integridade, não o desempenho de mantê-la. No caso do incidente, a tabela historico_status tinha índice por data para relatórios e nenhum por pedido_id, porque nenhuma tela buscava histórico por pedido no caminho quente. A restrição existia há três anos, com ON DELETE CASCADE, e cada exclusão de pedido pagava uma leitura completa de uma tabela que crescia mais rápido que qualquer outra do sistema.
Custo do expurgo diario (2.000 pedidos cancelados por dia):
ano 1: historico_status com 2 milhoes de linhas
2.000 exclusoes x 1 varredura de 2 mi de linhas (~80 ms) = ~3 min
ano 3: historico_status com 40 milhoes de linhas
2.000 exclusoes x 1 varredura de 40 mi de linhas (~1,6 s) = ~53 min
com indice em historico_status (pedido_id), em qualquer ano:
2.000 exclusoes x 1 busca no indice (~0,05 ms) = ~0,1 s
Custo = (linhas excluidas no pai) x (tamanho da tabela filha)
Os dois fatores crescem com o negocio: o tempo cresce com o produto.A multiplicação explica por que o problema nunca aparece cedo. Em homologação, a filha tem dez mil linhas, a varredura cabe em memória e custa menos de um milissegundo. Nos primeiros meses de produção, o expurgo leva segundos. A degradação é contínua e silenciosa, sem um degrau que dispare alerta, até o dia em que o tempo do job ultrapassa alguma coisa que importa: a janela de manutenção, o intervalo do agendador, o timeout de uma migração ou a paciência do pool de conexões.
Há uma consequência ainda menos intuitiva. Com NO ACTION, que é o padrão quando ninguém escreve nada, excluir um pedido que não tem nenhuma linha na filha custa exatamente a mesma varredura completa. O banco precisa provar a ausência, e sem índice a única forma de provar que nenhuma das quarenta milhões de linhas aponta para aquele pedido é ler todas elas.