PostgreSQL lento: como identificar e otimizar queries

PostgreSQL lento quase sempre é causado por queries sem índice ou planos de execução ineficientes. As ferramentas certas, pg_stat_statements para encontrar os gargalos e EXPLAIN ANALYZE para entender o plano, revelam a causa em minutos. Na maioria dos casos, adicionar um índice ou reescrever uma subquery resolve o problema sem aumentar a RAM ou trocar de servidor.

Habilitar pg_stat_statements

pg_stat_statements é a extensão mais importante para diagnóstico, registra tempo total, contagem e variância de todas as queries executadas:

bash
# Adicionar ao postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
pg_stat_statements.max = 10000

# Reiniciar o PostgreSQL (necessário para shared_preload_libraries)
docker compose restart postgres

# Ativar a extensão no banco
docker compose exec postgres psql -U postgres -d meu_banco \
  -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"

# Ver as 10 queries mais lentas (por tempo total)
docker compose exec postgres psql -U postgres -d meu_banco -c "
SELECT
  round(total_exec_time::numeric, 2) AS total_ms,
  calls,
  round(mean_exec_time::numeric, 2) AS avg_ms,
  round(stddev_exec_time::numeric, 2) AS stddev_ms,
  left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;"

EXPLAIN ANALYZE: entender o plano de execução

EXPLAIN ANALYZE executa a query e mostra o plano real com tempos. É a ferramenta fundamental para entender por que uma query está lenta:

sql
-- Analisar uma query específica
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.email, COUNT(o.id) as total_orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2024-01-01'
GROUP BY u.email
ORDER BY total_orders DESC;

-- O que procurar no output:
-- "Seq Scan" em tabela grande = candidato a índice
-- "rows=10000" vs "actual rows=1" = estatísticas desatualizadas (ANALYZE)
-- "Hash Join" com buffers altos = considere índice na coluna de join
-- Tempo > 100ms em nó interno = gargalo identificado

Criar índices para queries lentas

Seq Scan em tabelas grandes com filtros WHERE é o sinal mais claro de índice ausente. Crie o índice sem bloquear a tabela:

sql
-- Ver tabelas com Seq Scans frequentes
SELECT
  schemaname, tablename,
  seq_scan, seq_tup_read,
  idx_scan, idx_tup_fetch,
  round(seq_scan::numeric / NULLIF(seq_scan + idx_scan, 0) * 100, 1) AS seq_pct
FROM pg_stat_user_tables
WHERE seq_scan > 100
ORDER BY seq_tup_read DESC
LIMIT 10;

-- Criar índice sem bloquear (CONCURRENTLY)
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
CREATE INDEX CONCURRENTLY idx_users_created_at ON users(created_at);

-- Índice parcial (só indexa linhas relevantes — menor, mais rápido)
CREATE INDEX CONCURRENTLY idx_orders_pending
  ON orders(created_at)
  WHERE status = 'pending';

-- Índice composto (para queries com múltiplos filtros)
CREATE INDEX CONCURRENTLY idx_orders_user_status
  ON orders(user_id, status);

Queries N+1 e joins ineficientes

Queries N+1 (uma query por linha do resultado) são o padrão mais comum de performance ruim em ORMs. Identifique pelo número de calls repetido no pg_stat_statements:

sql
-- Sinal de N+1: mesma query parametrizada com milhares de calls
-- Ex: SELECT * FROM users WHERE id = $1 com 5000 calls em 1 minuto

-- Solução: reescrever com JOIN ou IN
-- N+1 (ruim):
-- SELECT * FROM posts WHERE user_id = 1;
-- SELECT * FROM posts WHERE user_id = 2;
-- (repete N vezes)

-- Com JOIN (correto):
SELECT u.email, p.title, p.created_at
FROM users u
JOIN posts p ON p.user_id = u.id
WHERE u.id = ANY($1::int[]);   -- um array de IDs

-- Verificar índices existentes na tabela
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'posts';

-- Remover índices duplicados ou não utilizados
SELECT indexrelid::regclass AS index, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

Atualizar estatísticas e monitoramento contínuo

Estatísticas desatualizadas fazem o planner escolher planos ruins. Execute ANALYZE regularmente e monitore com queries de diagnóstico:

sql
-- Atualizar estatísticas de todas as tabelas
ANALYZE VERBOSE;

-- Resetar pg_stat_statements (zera contadores)
SELECT pg_stat_statements_reset();

-- Monitorar queries ativas em tempo real
SELECT pid, now() - query_start AS duracao, state, left(query, 100) AS query
FROM pg_stat_activity
WHERE state != 'idle' AND query_start IS NOT NULL
ORDER BY duracao DESC;

-- Matar query travada (substituir PID)
SELECT pg_terminate_backend(12345);

Não quer configurar manualmente?

Implante o PostgreSQL em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

EXPLAIN mostra o plano estimado sem executar a query (instantâneo, seguro). EXPLAIN ANALYZE executa a query e mostra tempos reais, revela quando o planner estimou errado. Para diagnóstico de performance, sempre use EXPLAIN ANALYZE para ver os tempos reais. Para queries de escrita, envolva em BEGIN/ROLLBACK.

Não há limite fixo, mas cada índice adiciona overhead de escrita (INSERT/UPDATE/DELETE precisam atualizar todos os índices). Remova índices não utilizados (idx_scan = 0 por semanas). Um bom sinal: se você tem mais índices que colunas na tabela, revise os que não são usados.

Sim, especialmente para apps com muitas conexões de curta duração. PgBouncer em modo transaction pooling permite 1000 conexões de app com apenas 20 conexões reais ao banco. Reduz overhead de criação de processo (PostgreSQL fork um processo por conexão) e consumo de memória.

SELECT pid, pg_blocking_pids(pid) AS blocked_by, query FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;, mostra queries bloqueadas e qual PID está bloqueando. Para ver a query que segura o lock: SELECT query FROM pg_stat_activity WHERE pid = <pid_bloqueador>.

PostgreSQL com JSONB suporta índices GIN em campos JSON, queries com operadores @> e ?, e tem performance comparável ao MongoDB para a maioria dos casos. Para dados semiestruturados misturados com relacionais, JSONB no PostgreSQL é mais simples que manter dois bancos. MongoDB só tem vantagem real em escritas massivas com documentos muito grandes e sem necessidade de transações ACID.