Voltar para o Blog
Construindo em Público9 / 10
Construindo em PúblicoParte 9 de 10

Caçando a query que travava o dashboard

5 min de leitura
Compartilhar
Construindo em Público

Tem um tipo específico de bug que ensina humildade: aquele que só existe em produção. Local está rápido, o CI está verde, a tela abre num piscar de olhos na sua máquina — e o cliente abre o mesmo dashboard e olha uma tela girando até estourar. Foi exatamente o que aconteceu com o nosso dashboard financeiro: rápido em dev, travado em prod, com timeout de statement batendo no limite. Este post é a caçada: como a gente foi da "está lento" até a causa raiz, o que descobrimos sobre a diferença entre o nosso banco de dev e o de prod, e o que a gente mudou pra parar de apagar incêndio.

"Está lento" não é um diagnóstico

O primeiro instinto do time inexperiente é começar a otimizar no chute: adicionar um índice aqui, um cache ali, torcer. A gente já aprendeu que isso é caçar no escuro. A regra é: nenhuma correção antes da causa raiz.

Então o começo é reprodução e observação, não conserto.

  • Reproduzir onde dói. O bug era de produção, e produção tem um volume de dados que dev não tem. O dashboard financeiro que voava local travava em prod porque prod tinha ordens de magnitude mais linhas. Otimizar contra o banco de dev teria "resolvido" um problema que não existia.
  • Ler o sintoma exato. Não era "lento", era statement timeout — o Postgres matando a query por exceder o tempo máximo. Isso já diz muito: o problema é uma query específica cara, não latência de rede ou render pesado no cliente.
  • Achar a query, não o endpoint. Um dashboard dispara várias queries. A que trava é uma. Isolar qual delas estoura o timeout é metade da batalha — e faz a diferença entre otimizar a coisa certa e enfeitar as erradas.

Só depois de saber qual query, contra qual volume, estourando qual limite é que faz sentido pensar em conserto.

O que a gente encontrou embaixo do capô

Caçando queries lentas ao longo do tempo, alguns padrões se repetem — e valem mais que qualquer índice pontual.

  • Full scan disfarçado. Uma query que parece filtrada mas cujo predicado não casa com nenhum índice varre a tabela inteira. Em dev, tabela pequena, ninguém nota. Em prod, é o timeout. Vários dos nossos índices existem justamente pra matar full scans que só apareceram com volume real.
  • Cache que não estava cacheando. Um caso clássico: uma tabela de "último valor" que devia ser mantida atualizada por trigger por linha. Alguém desativou o trigger num reprocessamento em massa e esqueceu de religar — o cache passou a servir dados velhos e caros de recalcular. A correção foi trocar por uma materialized view com refresh concorrente por carga, em vez de depender do trigger frágil.
  • Over-fetch. Buscar limit: 2000 pra mostrar 20 linhas na tela é pagar caro por dado que ninguém vê. Kanban e listas grandes ganharam paginação por coluna em vez de puxar tudo e cortar no cliente.
  • N+1 escondido em cron. Não era o dashboard, mas o padrão é o mesmo: um loop serial fazendo uma query por rede, ~1.5k redes, estourava o tempo. Paralelizar os loops por-rede e remover o cap artificial resolveu. N+1 é o full scan da camada de aplicação.

Consertar a causa, e depois blindar

Achada a causa, a correção tem duas partes: matar o problema imediato e reduzir a chance de a classe inteira voltar.

  • Query primeiro, cache depois. A tentação é jogar um cache por cima de uma query lenta. A gente prefere fazer a query ser rápida — índice certo, agregação em SQL em vez de em JS, materialized view quando o cálculo é caro e tolera leve defasagem. Cache por cima de query ruim só adia o problema e adiciona um jeito novo de servir dado velho.
  • Conta no banco, não na aplicação. Vários ganhos vieram de mover a agregação pra dentro do SQL — COUNT, DISTINCT ON, filtros — em vez de trazer linhas e processar no Node. O banco faz isso melhor, e você para de trafegar dado que vai jogar fora.
  • staleTime longo onde faz sentido. No frontend, com TanStack Query, dashboards que não precisam ser tempo-real ganharam staleTime longo. A query mais rápida é a que você não dispara.
  • Guarda contra o timeout. Onde uma query pode legitimamente demorar, timeout por query e streaming por bloco (Suspense por ato) fazem a tela mostrar o que já tem em vez de travar tudo esperando a parte lenta.

Por que prod ≠ dev, sempre

A lição que atravessa tudo: o banco de dev mente pra você sobre performance. Ele é pequeno, quente de cache, sem concorrência. Todo plano de query parece ótimo. O gargalo de verdade só nasce com volume, com dados reais e distribuição real. Isso não quer dizer testar em prod — quer dizer entender que uma query "rápida" local é uma hipótese, não um fato, e tratar volume como uma dimensão de primeira classe ao escrever qualquer coisa que lê muita linha.

O que faríamos de novo

A disciplina de causa-raiz antes de conserto foi o que mais rendeu. Toda vez que a gente pulou pra otimização no chute, ou não resolveu, ou "resolveu" a coisa errada. Reproduzir contra volume real, achar a query exata, entender o plano — chato, mas é o caminho curto de verdade.

O que a gente reforçaria: pensar em volume no momento de escrever a query, não no momento em que ela trava. Perguntar "e quando essa tabela tiver um milhão de linhas?" antes do deploy, não depois do timeout.

Se você é o tipo de dev que sente um prazer meio doentio em abrir um plano de execução e entender por que o Postgres escolheu aquele caminho — que trata "está lento" como o começo da investigação e não como o bug — você vai adorar caçar por aqui. As queries que travam produção são algumas das histórias mais satisfatórias de resolver que a gente tem.

Curtiu como a gente pensa?

É assim que trabalhamos todo dia.

Se esse jeito de pensar é o seu, vem construir com a gente — dá uma olhada nas vagas abertas.

Ver vagas