Banco de dados lento é um dos problemas mais comuns e mais mal diagnosticados no desenvolvimento de software. A reação instintiva é adicionar cache, trocar de banco ou jogar mais hardware no problema. Essas soluções às vezes funcionam por acidente, mas raramente resolvem a causa raiz.
A causa raiz, na imensa maioria dos casos, é uma query que faz mais trabalho do que precisaria. E descobrir isso exige uma habilidade específica: saber ler o plano de execução de uma query.
O que é um plano de execução
Quando você envia uma query para o banco de dados, ele não a executa diretamente. Primeiro, o otimizador analisa a query, os dados disponíveis, as estatísticas das tabelas e os índices existentes, e decide qual é a forma mais eficiente de obter o resultado. Esse planejamento é o plano de execução.
O plano de execução é uma árvore de operações. Cada nó da árvore representa uma operação: um scan de tabela, uma busca por índice, um join, uma ordenação. Ler esse plano é como ver o rascunho mental que o banco fez antes de responder sua query.
No PostgreSQL, você acessa o plano com EXPLAIN. Com
EXPLAIN ANALYZE, o banco executa a query de verdade e
mostra os números reais ao lado das estimativas. Essa diferença é
importante: às vezes o otimizador faz uma escolha ruim porque as
estatísticas estão desatualizadas.
Os sinais de problema mais comuns
Sequential scan em tabelas grandes. Seq Scan significa que o banco está lendo a tabela inteira para encontrar as linhas que você quer. Em tabelas pequenas isso é normal — às vezes é até mais rápido que usar um índice. Em tabelas com milhões de linhas, é quase sempre um sinal de que um índice está faltando ou não está sendo usado.
Nested loop com muitas iterações. Nested loop é uma estratégia de join onde para cada linha de uma tabela, o banco busca as correspondentes na outra. Isso é eficiente quando uma das tabelas é pequena. Quando ambas são grandes, o número de operações cresce quadraticamente e o resultado é uma query que nunca termina.
Sort sem índice. Se uma query tem ORDER BY
em uma coluna sem índice e o resultado tem muitas linhas, o banco
precisa carregar tudo em memória e ordenar. Isso é lento e consome
memória. Um índice na coluna de ordenação resolve.
Estimativas muito erradas. Quando o plano mostra
"rows=1" mas o ANALYZE mostra "rows=50000", o otimizador tomou
decisões com informações erradas. Rodar ANALYZE na
tabela atualiza as estatísticas e geralmente muda o plano para melhor.
Como identificar as queries problemáticas
Antes de otimizar qualquer coisa, você precisa saber o que otimizar. Otimizar uma query que roda uma vez por dia não tem o mesmo impacto que otimizar uma que roda mil vezes por minuto.
No PostgreSQL, a extensão pg_stat_statements registra
estatísticas de todas as queries executadas: quantas vezes rodou,
tempo total, tempo médio, linhas retornadas. Com ela ativa, uma
consulta simples mostra as queries que mais consomem tempo total —
que é a métrica mais relevante para o desempenho geral do sistema.
Ferramentas de APM (Application Performance Monitoring) como Datadog, New Relic ou o próprio pganalyze fazem isso de forma visual e com alertas. Mas mesmo sem essas ferramentas, o slow query log do banco — que registra queries que passam de um threshold de tempo — já é um ponto de partida muito útil.
Índices: quando ajudam e quando não ajudam
A solução mais comum para queries lentas é criar um índice. E frequentemente é a solução certa. Mas índices têm custo: eles ocupam espaço em disco e tornam inserções, atualizações e deleções mais lentas, porque o banco precisa manter o índice atualizado.
Um índice em uma coluna com poucos valores distintos (como uma coluna booleana ou um campo de status com três opções) raramente ajuda. O banco muitas vezes prefere o seq scan porque o índice levaria a muitas linhas de qualquer forma.
Índices compostos — em múltiplas colunas — podem ser muito eficientes
para queries que filtram por várias condições juntas, mas só funcionam
se a query respeita a ordem das colunas no índice. Um índice em
(a, b) ajuda queries que filtram por a ou
por a e b juntos. Não ajuda queries que
filtram só por b.
O problema do N+1
O problema do N+1 não aparece no plano de execução de uma única query — porque cada query individual pode ser rápida. O problema está no padrão: sua aplicação faz uma query para buscar N registros, e depois faz mais N queries para buscar dados relacionados de cada um.
Com 10 registros isso é imperceptível. Com 1.000 são 1.001 queries por request. A solução é usar joins ou carregar os dados relacionados em uma única query adicional — o que ORMs modernos chamam de eager loading.
Identificar N+1 requer observar os logs de queries durante uma operação real. Se você vê a mesma query se repetindo dezenas de vezes com parâmetros diferentes, é quase certamente N+1.
Quando o problema não é a query
Às vezes a query está bem, mas o banco ainda está lento. Nesse caso, o problema pode ser outra coisa: lock contention (queries esperando outras liberarem locks), conexões esgotadas (a aplicação espera por uma conexão disponível no pool), ou simplesmente volume — queries corretas ficam lentas quando a tabela cresce para um tamanho que não foi considerado no design original.
Para lock contention, pg_locks e pg_stat_activity
mostram quem está esperando por quem. Para volume, a solução costuma
ser particionamento de tabela ou arquivamento de dados históricos.
Conclusão
Banco de dados lento é um sintoma, não um problema. O problema é quase sempre uma operação que faz mais trabalho do que o necessário — uma tabela inteira sendo lida quando só precisava de algumas linhas, um join mal planejado, estatísticas desatualizadas enganando o otimizador.
A habilidade de ler um plano de execução, identificar as queries mais custosas e entender por que o banco fez as escolhas que fez é o que separa uma investigação eficiente de um processo de tentativa e erro. Não é difícil de aprender — e resolve a grande maioria dos problemas de desempenho sem precisar trocar de tecnologia.