Query planner imprevisível: o pesadelo do DBA PostgreSQL
A query rodava em 30 ms e virou 40 segundos sem nenhum deploy. Por que o planner troca de plano, como ler o EXPLAIN de verdade, estatísticas estendidas, e o que fazer quando o otimizador sai dos trilhos.

Não tem chamado pior. Ninguém fez deploy, ninguém mexeu em índice, o volume cresceu o de sempre — e a query que rodava em 30 ms está levando 40 segundos desde as 6 da manhã. O banco não está lento. O planner mudou de ideia.
Como o planner decide
O PostgreSQL usa otimizador baseado em custo. Ele estima quantas linhas cada passo vai devolver, aplica constantes de custo de I/O e CPU e escolhe o plano mais barato. Tudo depende de uma coisa só: a estimativa de cardinalidade. Errou a estimativa, errou o plano.
O sintoma clássico: o planner acha que vai voltar 12 linhas, escolhe Nested Loop, e voltam 4 milhões. Nested Loop com 4 milhões de iterações é aquela sua terça-feira arruinada.
Lendo o EXPLAIN do jeito certo
EXPLAIN sozinho mostra o plano estimado. Só o ANALYZE mostra a realidade:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT TEXT)
SELECT p.id, c.nome, SUM(i.valor) AS total
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
JOIN itens i ON i.pedido_id = p.id
WHERE p.criado_em >= now() - interval '7 days'
AND p.status = 'FATURADO'
GROUP BY p.id, c.nome;
O que eu olho, nesta ordem:
rows=X ... actual rows=Y. Se Y é 100 vezes X, achei o culpado. Comece pelo nó mais fundo onde a divergência aparece — os de cima só herdam o erro.Buffers: shared readalto. Está indo ao disco. Pode ser cache frio, pode ser índice inchado (veja VACUUM e bloat).Rows Removed by Filter. Leu muito e jogou fora. Falta índice, ou o índice existente não cobre o predicado.loops=Nem Nested Loop. Multiplique o tempo pelo número de loops antes de achar que o nó é barato.
As quatro causas reais de troca de plano
1. Estatística velha
Carga em massa, expurgo grande ou tabela nova sem ANALYZE. O autoanalyze usa os mesmos gatilhos preguiçosos do autovacuum. Depois de qualquer carga relevante, seja explícito:
ANALYZE VERBOSE pedidos;
-- Quando foi o último?
SELECT relname, last_analyze, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
ORDER BY n_mod_since_analyze DESC
LIMIT 15;
2. Amostragem insuficiente
O padrão de default_statistics_target é 100 — ou seja, 100 buckets de histograma. Em coluna com distribuição torta e milhões de valores distintos, isso é pouco. Aumente por coluna, não globalmente:
ALTER TABLE pedidos ALTER COLUMN status SET STATISTICS 1000;
ANALYZE pedidos;
3. Correlação entre colunas (a mais traiçoeira)
O planner assume independência entre colunas. Se você filtra cidade = 'São Paulo' AND estado = 'SP', ele multiplica as seletividades e estima uma fração minúscula — quando na prática uma coluna determina a outra. Desde o PostgreSQL 10 existe cura:
CREATE STATISTICS st_pedidos_geo (dependencies, ndistinct, mcv)
ON cidade, estado FROM pedidos;
ANALYZE pedidos;
SELECT stxname, stxkeys FROM pg_statistic_ext WHERE stxrelid = 'pedidos'::regclass;
Estatística estendida é, na minha experiência, a ferramenta mais subutilizada do Postgres. Resolve uma classe inteira de plano ruim e quase ninguém usa.
4. Parâmetro de bind e plano genérico
Prepared statement executado seis vezes faz o Postgres considerar trocar o plano customizado por um genérico. Quando os dados são torcidos, o genérico é péssimo. Controle:
SET plan_cache_mode = 'force_custom_plan'; -- por sessao
-- ou 'auto' (padrao) / 'force_generic_plan'
E os query hints?
O PostgreSQL não tem hints por decisão de projeto: a comunidade entende que hint esconde o problema e envelhece mal. Concordo com a filosofia e discordo do horário — às três da manhã, eu quero um hint. O que existe na prática:
| Recurso | O que faz | Quando usar |
|---|---|---|
SET enable_nestloop = off | Desabilita um tipo de nó na sessão | Emergência e diagnóstico. Nunca deixe global |
CREATE STATISTICS | Ensina correlação ao planner | Correção definitiva de estimativa |
pg_hint_plan | Hints estilo Oracle, via comentário | Query de terceiro que você não pode reescrever |
pg_plan_advice | Captura e reaplica o plano bom | Novidade recente; promissora, ainda pouco adotada |
CTE com MATERIALIZED | Força barreira de otimização | Quando você sabe algo que o planner não sabe |
-- Barreira explicita: no PG 12+ a CTE e inline por padrao
WITH recentes AS MATERIALIZED (
SELECT id, cliente_id FROM pedidos
WHERE criado_em >= now() - interval '7 days'
)
SELECT c.nome, count(*)
FROM recentes r JOIN clientes c ON c.id = r.cliente_id
GROUP BY c.nome;
O que eu deixo instalado em todo servidor
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT substr(query, 1, 70) AS query,
calls,
round(total_exec_time::numeric, 0) AS tempo_total_ms,
round(mean_exec_time::numeric, 2) AS media_ms,
round(stddev_exec_time::numeric, 2) AS desvio
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Dica que vale o artigo inteiro: ordene por stddev_exec_time, não só por tempo total. Desvio padrão alto é a assinatura de query que troca de plano. É ali que mora o incidente de amanhã.
Complemente com auto_explain para capturar o plano ruim no momento em que ele acontece — logar o plano de tudo que passa de 3 segundos custa quase nada e economiza noites.
O panorama completo está no guia definitivo de PostgreSQL em produção. Se a query ruim ataca dados vetoriais, o ajuste é outro: veja pgvector na prática.
Sobre o autor: Alexandre Almeida atua com bancos de dados há mais de 25 anos, foi instrutor oficial de MySQL e Oracle para mais de 1.000 alunos e escreveu Aprendendo MySQL: Prática, Teoria e Laboratórios. Exemplos validados em PostgreSQL 16 e 17.
Fontes: PostgreSQL Documentation — Using EXPLAIN, Planner Statistics, Extended Statistics e Controlling the Planner with Explicit JOIN Clauses; documentação de pg_stat_statements, auto_explain e pg_hint_plan.