Caçando a query que travava o dashboard
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: 2000pra 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. staleTimelongo onde faz sentido. No frontend, com TanStack Query, dashboards que não precisam ser tempo-real ganharamstaleTimelongo. 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.