PostgreSQL: EXPLAIN ANALYZE e otimização de queries

Queries lentas são a causa número 1 de lentidão em APIs. O PostgreSQL tem ferramentas poderosas para diagnosticar e corrigir isso, EXPLAIN ANALYZE mostra exatamente como o planner executa cada query, e índices bem escolhidos podem transformar uma query de segundos em milissegundos sem mudar uma linha da aplicação.

EXPLAIN ANALYZE: ler o plano de execução

Entender o que o PostgreSQL está fazendo internamente:

sql
-- Sintaxe básica:
EXPLAIN ANALYZE SELECT * FROM pedidos WHERE usuario_id = 123;

-- Com mais detalhes:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
  SELECT p.*, u.nome
  FROM pedidos p
  JOIN usuarios u ON u.id = p.usuario_id
  WHERE p.criado_em > NOW() - INTERVAL '30 days'
  ORDER BY p.criado_em DESC
  LIMIT 20;

-- Resultado a analisar:
-- Seq Scan       → varredura completa da tabela (ruim para tabelas grandes)
-- Index Scan     → usando índice (bom)
-- Index Only Scan → usando apenas o índice, sem ir na tabela (ótimo)
-- Nested Loop    → JOIN por loop (ruim para muitas linhas)
-- Hash Join      → JOIN por hash (bom para tabelas médias)
-- Merge Join     → JOIN por merge (bom para dados ordenados)

-- "rows=X" vs "actual rows=Y" — se muito diferente: ANALYZE a tabela:
ANALYZE pedidos;

-- Identificar queries lentas sem saber qual é o gargalo:
SELECT query, mean_exec_time, calls, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

Criar índices estratégicos

Tipos de índice e quando usar cada um:

sql
-- Índice simples (mais comum):
CREATE INDEX idx_pedidos_usuario_id ON pedidos(usuario_id);

-- Índice composto (ordem importa — coluna mais seletiva primeiro):
CREATE INDEX idx_pedidos_status_criado ON pedidos(status, criado_em DESC);
-- Usado em: WHERE status = 'pago' ORDER BY criado_em DESC

-- Índice parcial (apenas subconjunto de linhas — menor e mais rápido):
CREATE INDEX idx_pedidos_pendentes ON pedidos(usuario_id)
  WHERE status = 'pendente';
-- Usado em: WHERE status = 'pendente' AND usuario_id = 123

-- Índice para busca por texto (LIKE):
CREATE INDEX idx_produtos_nome ON produtos USING gin(nome gin_trgm_ops);
-- Requer: CREATE EXTENSION pg_trgm;
-- Usado em: WHERE nome LIKE '%camisa%'

-- Índice para JSON (JSONB):
CREATE INDEX idx_eventos_tipo ON eventos USING gin(payload);
CREATE INDEX idx_eventos_tipo_spec ON eventos((payload->>'tipo'));

-- Índice covering (inclui colunas extras para Index Only Scan):
CREATE INDEX idx_pedidos_usuario_covering
  ON pedidos(usuario_id) INCLUDE (total, status, criado_em);

-- Ver índices e seu uso:
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;  -- índices com zero scans podem ser removidos

Otimizar JOINs e subqueries com CTEs

Reescrever queries para aproveitar o planner do PostgreSQL:

sql
-- Antipadrão: subquery correlacionada (executa N vezes):
SELECT u.nome,
  (SELECT COUNT(*) FROM pedidos p WHERE p.usuario_id = u.id) AS total_pedidos
FROM usuarios u;

-- Correto: JOIN com aggregação:
SELECT u.nome, COUNT(p.id) AS total_pedidos
FROM usuarios u
LEFT JOIN pedidos p ON p.usuario_id = u.id
GROUP BY u.id, u.nome;

-- CTE para reutilizar resultado (PostgreSQL 12+ materializa automaticamente):
WITH usuarios_ativos AS (
  SELECT id FROM usuarios
  WHERE ultimo_acesso > NOW() - INTERVAL '30 days'
),
pedidos_recentes AS (
  SELECT usuario_id, SUM(total) AS receita
  FROM pedidos
  WHERE criado_em > NOW() - INTERVAL '30 days'
  GROUP BY usuario_id
)
SELECT u.nome, COALESCE(pr.receita, 0) AS receita_30d
FROM usuarios_ativos ua
JOIN usuarios u ON u.id = ua.id
LEFT JOIN pedidos_recentes pr ON pr.usuario_id = ua.id
ORDER BY receita_30d DESC;

-- Forçar materialização de CTE (útil quando planner faz escolha ruim):
WITH minha_cte AS MATERIALIZED (
  SELECT ... FROM tabela_grande WHERE condicao_seletiva
)
SELECT * FROM minha_cte WHERE ...

Detectar e resolver N+1 queries

O problema mais comum em ORMs e como resolver:

typescript
-- Problema N+1: uma query por registro (1 + N queries)
-- Antipadrão em Node.js com Prisma/TypeORM:
const usuarios = await db.usuario.findMany()
for (const usuario of usuarios) {
  // Executa 1 query para cada usuário!
  const pedidos = await db.pedido.findMany({ where: { usuarioId: usuario.id } })
}

-- Correto: eager loading (1 query com JOIN):
const usuarios = await db.usuario.findMany({
  include: { pedidos: true }  // Prisma: gera JOIN automático
})

-- SQL equivalente:
SELECT u.*, p.*
FROM usuarios u
LEFT JOIN pedidos p ON p.usuario_id = u.id;

-- Detectar N+1 com pg_stat_statements:
SELECT query, calls
FROM pg_stat_statements
WHERE query LIKE '%WHERE usuario_id%'
ORDER BY calls DESC
LIMIT 5;
-- Se mesma query aparece com calls muito alto em curto período: N+1

-- Habilitar pg_stat_statements:
-- postgresql.conf: shared_preload_libraries = 'pg_stat_statements'
-- Depois: CREATE EXTENSION pg_stat_statements;

Autovacuum e manutenção de performance

Garantir que o PostgreSQL mantém boa performance ao longo do tempo:

sql
-- Verificar tabelas com muitas linhas mortas (necessitam VACUUM):
SELECT relname, n_dead_tup, n_live_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

-- Forçar VACUUM em tabela específica:
VACUUM ANALYZE pedidos;

-- VACUUM FULL (reorganiza a tabela — bloqueia escritas, use com cuidado):
VACUUM FULL pedidos;  -- apenas em manutenção planejada

-- Verificar bloat de índices:
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass)) AS tamanho
FROM pg_indexes
WHERE tablename = 'pedidos'
ORDER BY pg_relation_size(indexname::regclass) DESC;

-- Recriar índice sem bloquear (PostgreSQL 12+):
REINDEX INDEX CONCURRENTLY idx_pedidos_usuario_id;

-- Ajustar autovacuum para tabelas de alta escrita:
ALTER TABLE pedidos SET (
  autovacuum_vacuum_scale_factor = 0.01,  -- vacuumar ao atingir 1% de linhas mortas
  autovacuum_analyze_scale_factor = 0.005
);

Não quer configurar manualmente?

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

Perguntas frequentes

Execute EXPLAIN ANALYZE, se aparecer "Seq Scan" em tabela com mais de 10.000 linhas e "cost" alto, provavelmente precisa de índice. Verifique também pg_stat_statements para queries com mean_exec_time > 100ms e muitas calls. Regra prática: qualquer coluna usada em WHERE, JOIN ON ou ORDER BY de queries frequentes é candidata a índice.

Sim, cada índice adiciona overhead em INSERT, UPDATE e DELETE (o PostgreSQL precisa atualizar todos os índices da tabela). Para tabelas com muito write: monitore a proporção de idx_scan por índice via pg_stat_user_indexes. Índices com zero scans em 30+ dias são candidatos a remoção. O ideal é ter exatamente os índices que as queries usam, nada mais.

PostgreSQL nunca sobrescreve linhas, UPDATE/DELETE cria novas versões e marca as antigas como "dead tuples". Bloat é o acúmulo dessas linhas mortas que ocupam disco e degradam performance. VACUUM limpa as linhas mortas; VACUUM FULL recupera o espaço em disco (mas bloqueia a tabela). Autovacuum faz isso automaticamente, se a tabela não está sendo vacuumada: investigue autovacuum_freeze_max_age.

OFFSET grande é lento, PostgreSQL precisa ler e descartar todas as linhas anteriores. Alternativa: keyset pagination (cursor-based). Em vez de OFFSET 1000, use WHERE id > ULTIMO_ID LIMIT 20. Muito mais eficiente em tabelas grandes. Para paginação clássica com números de página: aceite o custo ou use COUNT materializado em tabela separada.

EXPLAIN: mostra o plano estimado sem executar a query, seguro para queries destrutivas (DELETE, UPDATE). EXPLAIN ANALYZE: executa a query e mostra o plano real com tempos reais, indispensável para debugging pois mostra a diferença entre linhas estimadas e reais. Para DELETE/UPDATE: use EXPLAIN ANALYZE dentro de uma transação e faça ROLLBACK.