← Todos os casos
Diagnosticar

O incidente que se resolvia sozinho: banco de produção saturado por paginação

119.145linhas lidas para devolver 15: a causa de 4 incidentes

Quatro vezes em três semanas

O alerta chegava sempre do mesmo jeito: CPU do PostgreSQL de produção acima de 80%. Quando alguém abria o gráfico, o banco já estava em 97, 98, 99% do limite. Os clientes sentiam a plataforma lenta, todos ao mesmo tempo. E então, uma hora depois, sem que ninguém tivesse mexido em nada, a CPU descia e o alerta se resolvia sozinho.

Na primeira vez, isso parece sorte. Na quarta vez em três semanas, é um padrão. Decidi tratar as quatro ocorrências como um único problema e usar cada repetição para enxergar um pouco mais.

Primeiro, o banco não via nada

Na primeira ocorrência, só tinha as estatísticas do próprio PostgreSQL. Elas mostraram coisas que mereciam atenção (tabelas sem índice na coluna de filtro, conexões ociosas sem pooler, uma versão fora de suporte), mas o que mais me preocupou não tinha relação direta com a CPU: o banco não tinha réplica nem recuperação ponto a ponto. Se o disco falhasse, a empresa podia perder até seis horas de dados. Registrei isso como prioridade antes de voltar ao sintoma.

Na segunda ocorrência, o EXPLAIN da listagem paginada mostrou cada chamada ordenando cerca de 400 MB em disco. O banco já tinha gerado 32 TB de arquivos temporários em menos de um mês. Medi o número de transações minuto a minuto durante o pico e ele ficou plano. O tráfego não tinha aumentado. Eram as mesmas poucas consultas, só que duzentas vezes mais pesadas.

Para descobrir quem fazia essas consultas, eu precisava do log de queries lentas, que estava desligado. Liguei com reload a quente, sem reiniciar o banco e sem derrubar nenhuma conexão. O limite de 10 MB para registrar arquivos temporários não foi chute: era o valor que separava o funcionamento normal do incidente nas medições que eu tinha feito.

O causador ganha um nome

O índice recomendado foi criado e o sort em disco desapareceu. Mesmo assim, na terceira ocorrência a CPU voltou a 98%.

Dessa vez, o log que eu tinha ligado mostrou os parâmetros das consultas, e a resposta apareceu: 99,97% das execuções lentas vinham de um único cliente, com cerca de 140 mil registros, paginando a listagem do começo ao fim. As linhas lidas por segundo tinham subido 69 vezes com o tráfego estável. Parte disso vinha de um detalhe pequeno: um count(i.*) obrigava o banco a montar a linha inteira só para contá-la. Trocar por count(*) permitia ler apenas o índice.

Também mostrei que o número que importava não era a porcentagem de CPU, e sim o throttling: o banco estava congelado em 97% das janelas de 100 ms, e isso deixava todos os clientes lentos, não só o que causava o problema.

E o incidente acabou sozinho mais uma vez. Agora eu sabia por quê: o cliente tinha parado de paginar. A causa continuava no código, e o gatilho dependia do comportamento de alguém de fora. Ia acontecer de novo.

Separar o que é de cada time

O time de desenvolvimento aplicou a troca do count, e ela funcionou. Na quarta ocorrência, o que sobrava era a paginação por OFFSET: para devolver 15 linhas, uma chamada lia 119.145.

Nesse ponto, a conversa podia virar empurra-empurra entre infra e desenvolvimento, então separei com números o que cabia a cada lado. O código definia o teto do custo, e nenhum ajuste de infraestrutura mudaria isso. A infra funcionava como multiplicador: com o gatilho padrão, o autovacuum deixava o visibility map desatualizado e anulava parte do ganho que o time já tinha entregado.

Também corrigi a prioridade que tinha sido passada ao time. A contagem respondia por cerca de 92% da leitura, e o OFFSET, por 8%. Sem esse número, eles teriam trocado só a paginação e deixado a maior parte da carga onde estava.

Para não depender de outro pico, propus um alerta que avisa antes: linhas lidas por scan naquela tabela, um indicador que sobe antes de a CPU passar de 80% e que não dispara quando o tráfego cresce de forma legítima.

O mesmo padrão em outro produto

Depois, a mesma assinatura apareceu numa plataforma de agentes de IA. Dessa vez comecei pela borda: as requisições por segundo no ingress acompanhavam a CPU do banco minuto a minuto. A pergunta deixou de ser "o que quebrou no banco" e passou a ser "quem está chamando". Era um único cliente da API pública relendo 641 páginas de histórico em loop.

Limitar esse cliente era uma decisão de produto, então montei também um plano que não dependia dele: cache de resposta com invalidação por versão (pelas chamadas reais do pico, o acerto teórico ficava entre 80% e 94%), cache da validação de token e um limite de consultas simultâneas ao banco.

O que mudou

A causa saiu do "pico isolado" e virou uma linha de código com dono. O sort em disco foi de 215 MiB/s de arquivos temporários para zero, e a contagem passou a ler só o índice. O banco ganhou a observabilidade que não tinha (log de queries lentas, coleta de logs e painéis de saturação) sem nenhum restart em produção. E os riscos que eram mais graves que o sintoma, como a falta de réplica e de recuperação ponto a ponto, entraram na lista de prioridades.

O que eu levo disso

Quando um incidente some sem ninguém fazer nada, a pergunta certa é o que parou, e não o que consertou. Cada repetição é uma oportunidade de instrumentar mais um pouco: foi o log ligado na segunda ocorrência que deu nome ao causador na terceira.

Aprendi também que, quando a causa está dividida entre infra e código, o número é o que impede cada lado de esperar pelo outro.