JB
_
·5 min de leitura

PostgreSQL na Prática: Queries de Alta Performance e Índices Inteligentes

Domine EXPLAIN ANALYZE, índices parciais e compostos, CTEs recursivas, window functions e estratégias de otimização que fazem a diferença em bancos de dados com milhões de registros.

PostgreSQLSQLPerformanceDatabase

Por que queries lentas acontecem?

Um banco de dados com mil registros funciona de qualquer jeito. Com dez milhões, cada detalhe importa. A maioria dos problemas de performance vem de três causas: falta de índice, índice errado ou query que não usa o índice que existe.

EXPLAIN ANALYZE — seu melhor amigo

Antes de qualquer otimização, entenda o que está acontecendo:

EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) as total_orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.name
ORDER BY total_orders DESC
LIMIT 20;

Leia o resultado de dentro para fora. Procure por:

  • Seq Scan numa tabela grande → falta índice
  • cost=0.00..XXXXX muito alto → query cara
  • rows=XXXXX actual rows=YYY → estimativa errada (statistics desatualizado)
  • Nested Loop com tabela grande no lado de dentro → problema sério
-- Atualizar statistics quando os dados mudam muito
ANALYZE users;
ANALYZE orders;
 
-- Ou tudo de uma vez
ANALYZE VERBOSE;

Índices — Muito Além do Básico

Índice Composto com Ordem Correta

-- ❌ Query usa created_at e status mas o índice está errado
CREATE INDEX idx_orders_status ON orders(status);
-- Se a query filtra status = 'pending' E ordena por created_at,
-- o banco ainda faz sort depois do filter
 
-- ✅ Índice composto na ordem correta
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);
-- Agora a query usa o índice para filter E sort em um só passo

A regra: igualdade primeiro, range depois, colunas de ORDER BY no final.

Índice Parcial

-- Índice em TODA a tabela orders (cara)
CREATE INDEX idx_orders_status ON orders(status);
 
-- Índice só nos pedidos pendentes (80% menores, mais rápido)
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';
 
-- A query que usa:
SELECT * FROM orders
WHERE status = 'pending'
  AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC;
-- Usa o índice parcial perfeitamente

Índice em Expressão

-- Busca case-insensitive sem índice → Seq Scan
SELECT * FROM users WHERE LOWER(email) = 'joao@email.com';
 
-- Criar índice na expressão
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
 
-- Agora usa o índice!
SELECT * FROM users WHERE LOWER(email) = 'joao@email.com';

GIN Index para Arrays e JSONB

-- Tabela com coluna JSONB
ALTER TABLE products ADD COLUMN metadata JSONB;
CREATE INDEX idx_products_metadata ON products USING GIN(metadata);
 
-- Query que aproveita o GIN index
SELECT * FROM products
WHERE metadata @> '{"category": "electronics", "in_stock": true}';
 
-- Para arrays
CREATE TABLE posts (tags TEXT[]);
CREATE INDEX idx_posts_tags ON posts USING GIN(tags);
 
SELECT * FROM posts WHERE tags @> ARRAY['postgresql', 'performance'];

Window Functions — Análises Poderosas

-- Ranking de vendedores por região
SELECT
  seller_name,
  region,
  total_sales,
  RANK() OVER (PARTITION BY region ORDER BY total_sales DESC) as rank_in_region,
  ROUND(100.0 * total_sales / SUM(total_sales) OVER (PARTITION BY region), 2) as pct_of_region
FROM sales_summary
ORDER BY region, rank_in_region;
 
-- Média móvel de 7 dias
SELECT
  date,
  revenue,
  AVG(revenue) OVER (
    ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) as moving_avg_7d
FROM daily_revenue
ORDER BY date;
 
-- Diferença em relação ao dia anterior
SELECT
  date,
  revenue,
  revenue - LAG(revenue, 1) OVER (ORDER BY date) as day_over_day_change
FROM daily_revenue;

CTEs Recursivas — Para Estruturas em Árvore

-- Categorias com subcategorias aninhadas
WITH RECURSIVE category_tree AS (
  -- Base: categorias raiz
  SELECT id, name, parent_id, 0 as depth, name::TEXT as path
  FROM categories
  WHERE parent_id IS NULL
 
  UNION ALL
 
  -- Recursão: filhas das categorias já encontradas
  SELECT c.id, c.name, c.parent_id, ct.depth + 1, ct.path || ' > ' || c.name
  FROM categories c
  INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT * FROM category_tree
ORDER BY path;
 
-- Resultado:
-- Electronics
-- Electronics > Phones
-- Electronics > Phones > Smartphones
-- Electronics > Laptops

Particionamento de Tabelas

Para tabelas que crescem muito (logs, eventos, transações):

-- Criar tabela particionada por mês
CREATE TABLE events (
  id BIGSERIAL,
  user_id INT NOT NULL,
  event_type VARCHAR(50),
  payload JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
 
-- Criar partições
CREATE TABLE events_2026_01 PARTITION OF events
  FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
 
CREATE TABLE events_2026_02 PARTITION OF events
  FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
 
CREATE TABLE events_2026_03 PARTITION OF events
  FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
 
-- Índice em cada partição (automático para índices globais no PG 17)
CREATE INDEX ON events_2026_03(user_id, created_at DESC);
 
-- Query usa APENAS a partição correta (partition pruning)
SELECT * FROM events
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'
AND user_id = 42;

Upsert com ON CONFLICT

-- Inserir ou atualizar se já existe (atomicamente)
INSERT INTO user_stats (user_id, login_count, last_login)
VALUES (42, 1, NOW())
ON CONFLICT (user_id) DO UPDATE
  SET login_count = user_stats.login_count + 1,
      last_login = NOW()
WHERE user_stats.last_login < NOW() - INTERVAL '1 hour';
 
-- Ignorar duplicatas silenciosamente
INSERT INTO event_log (event_id, processed_at)
VALUES (123, NOW())
ON CONFLICT (event_id) DO NOTHING;

Checklist de Performance

-- Ver índices não utilizados (candidatos a remover)
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexname NOT LIKE '%pkey%'
ORDER BY schemaname, tablename;
 
-- Ver queries mais lentas (requer pg_stat_statements)
SELECT query, calls, total_exec_time / calls as avg_ms, rows / calls as avg_rows
FROM pg_stat_statements
WHERE calls > 100
ORDER BY avg_ms DESC
LIMIT 20;
 
-- Ver locks que estão bloqueando queries
SELECT blocked.pid, blocked.query, blocking.pid as blocking_pid, blocking.query as blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';

Conclusão

Otimizar PostgreSQL é uma habilidade que se constrói com prática. Comece sempre com EXPLAIN ANALYZE, adicione índices cirurgicamente (não em massa) e monitore com pg_stat_statements. Um índice bem colocado pode transformar uma query de 30 segundos em 2 milissegundos.

O segredo não é ter mais índices — é ter os índices certos para as queries que realmente importam.