O problema real
Sua aplicação começa lenta. Redis tá lotado de cache, você limpa, não muda. Dig fundo, descobre que uma query no Postgres demora 8 segundos. Roda nos logs, vê:
SELECT u.id, u.email, COUNT(o.id) as total_orders
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.id, u.email
Nunca se perguntou se precisa de índice. Nunca olhou o plano de execução. Acha que é "normal". Sobe 2 réplicas de read, custa $200/mês extra em RDS, e ninguém pergunta por quê.
Realidade: 99% das queries lentas têm solução simples — índice errado, join sem ON, ou N+1 dormindo no ORM.
EXPLAIN ANALYZE: lendo o plano real
EXPLAIN ANALYZE mostra como Postgres realmente executa sua query:
EXPLAIN ANALYZE
SELECT u.id, u.email, COUNT(o.id) as total_orders
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.id, u.email;
Resultado (exemplo real):
GroupAggregate (cost=0.42..12.50 rows=45 width=40) (actual time=0.234..8.123 rows=45 loops=1)
-> Sort (cost=0.42..2.50 rows=1000 width=40) (actual time=0.012..0.034 rows=1000 loops=1)
Sort Key: u.id, u.email
-> Hash Left Join (cost=0.29..5.50 rows=1000 width=40) (actual time=0.001..0.456 rows=1000 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0.00..35.00 rows=2000 width=8) (actual time=0.001..0.234 rows=2000 loops=1)
-> Hash (cost=0.28..0.28 rows=100 width=40) (actual time=0.001..0.001 rows=100 loops=1)
-> Seq Scan on users u (cost=0.00..0.28 rows=100 width=40) (actual time=0.001..0.001 rows=100 loops=1)
Filter: (status = 'active')
O que significam os números:
| Termo | Significado | Alarme |
|---|---|---|
Seq Scan |
Lê tabela inteira linha por linha | ❌ Se tabela grande e filter simples, precisa índice |
Index Scan |
Lê apenas linhas do índice | ✓ Rápido |
cost=0.42..12.50 |
Unidade interna Postgres (não segundos) | Comparar desses valores entre queries |
actual time=0.234..8.123 |
Tempo real em ms | Aqui vê o gargalo |
rows=45 |
Quantas linhas retorna | Se estimativa != real, índice mal aproveitado |
loops=1 |
Quantas vezes executou | Se loops=1000, é N+1 |
Neste exemplo:
Seq Scan on orders odemora 0.234 ms — 2.000 linhas, rápidoSeq Scan on users udemora 0.001 ms — 100 linhas, filtrado por statusHash Left Joindemora 0.456 ms — 1.000 linhas combinadas- Total: 8.123 ms — o gargalo é o
Sort(0.012..0.034 ms não, mas sort com GROUP BY depois é pesado)
Solução: índice em users(status, id) ou orders(user_id) evita Seq Scan.
Índices: B-tree, composto, parcial
B-tree (padrão)
-- Índice simples
CREATE INDEX idx_users_status ON users(status);
-- Query agora usa Index Scan
EXPLAIN ANALYZE
SELECT * FROM users WHERE status = 'active';
Postgres automaticamente escolhe usar índice se beneficiar (menos de ~5% da tabela).
Índice composto (ordem importa!)
-- ❌ ERRADO: ordem aleatória
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
-- Query que filtra user_id E depois created_at: ✓ usa índice
SELECT * FROM orders WHERE user_id = 5 AND created_at > '2026-01-01';
-- Query que filtra SÓ created_at: ❌ NÃO usa índice (created_at é segunda coluna)
SELECT * FROM orders WHERE created_at > '2026-01-01';
Regra: coloque na frente do índice as colunas que você filtra com igualdade, depois as que você filtra com range:
-- ✓ CORRETO: igualdade (user_id) depois range (created_at)
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
Índice parcial: economiza espaço
-- Índice SÓ pra usuários ativos
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';
-- Query que filtra status = 'active' usa este índice reduzido
SELECT email FROM users WHERE status = 'active' AND email LIKE 'a%';
Economia: 10 milhões de usuários, 90% ativos = índice com 9 milhões de linhas em vez de 10 milhões. Menos I/O, mais rápido.
GIN para JSONB e busca textual
Se armazena JSON:
-- Sem índice: SELECT demora 5 segundos em 1M de linhas
SELECT * FROM events WHERE data->>'type' = 'purchase';
-- Com índice GIN
CREATE INDEX idx_events_data ON events USING GIN (data);
-- Query agora demora 10 ms
SELECT * FROM events WHERE data->>'type' = 'purchase';
Para busca textual:
-- GIN pra full-text search
CREATE INDEX idx_articles_body ON articles USING GIN(to_tsvector('portuguese', body));
-- Busca rápida
SELECT * FROM articles WHERE to_tsvector('portuguese', body) @@ to_tsquery('inteligencia & artificial');
N+1 em ORM: o problema invisível
Seu código Python/Node:
# ❌ N+1 PROBLEM
users = db.session.query(User).filter(User.status == 'active').all()
for user in users: # loops = 100
orders = db.session.query(Order).filter(Order.user_id == user.id).all() # 100 queries!
print(f"{user.email}: {len(orders)} orders")
# Resultado: 101 queries (1 pra users + 100 pra cada user)
Tempo real (2 ms por query): 101 * 2 = 202 ms.
Solução 1: JOIN no SQL
# ✓ 1 query
results = db.session.query(User, func.count(Order.id).label('order_count')) \
.outerjoin(Order) \
.filter(User.status == 'active') \
.group_by(User.id) \
.all()
# Tempo: 2 ms
Solução 2: joinedload no ORM
# ✓ 1 query com JOIN, mas retorna objetos User completos
users = db.session.query(User) \
.outerjoin(Order) \
.filter(User.status == 'active') \
.options(joinedload(User.orders)) \
.all()
# Tempo: 2 ms
Transações e níveis de isolamento
Por padrão, Postgres usa READ COMMITTED. Leia dois valores:
-- Transação 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- retorna 100
-- [outro cliente muda pra 50]
SELECT balance FROM accounts WHERE id = 1; -- retorna 50 (vê mudança)
COMMIT;
Se precisa ver sempre o mesmo valor (snapshot consistente):
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- retorna 100
-- [outro cliente muda pra 50]
SELECT balance FROM accounts WHERE id = 1; -- retorna 100 (snapshot congelado)
COMMIT;
Níveis reais:
READ UNCOMMITTED: vê mudanças não commitadas (raro usar)READ COMMITTED: padrão, vê commits novosREPEATABLE READ: snapshot, transações longas precisam desseSERIALIZABLE: mais lento, mas garantias máximas
Use: pra transferência bancária, sempre REPEATABLE READ:
# SQLAlchemy
db.session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"))
Connection pooling: por que Postgres sofre com muitas conexões
Postgres cria processo OS por conexão. 100 conexões = 100 processos. Cada um usa RAM (5-10 MB). 100 * 10 = 1 GB só em conexões.
-- Ver conexões
SELECT count(*) FROM pg_stat_activity;
-- Limite (default 100)
SELECT current_setting('max_connections');
Solução: connection pool
Aplicação conecta em pool (ex: PgBouncer), pool conecta em Postgres. Reutiliza conexões:
App 1 → ┐
App 2 → ├→ PgBouncer (10 conexões) → Postgres
App 3 → ┘
Reduz de 300 conexões (100 * 3 apps) para 10 + overhead.
NumPy em docker-compose.yml:
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: app
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
pgbouncer:
image: edoburu/pgbouncer
environment:
DATABASE_URL: "postgres://postgres:secret@postgres:5432/app"
POOL_MODE: "transaction" # pool por transação
MAX_CLIENT_CONN: "1000"
DEFAULT_POOL_SIZE: "25"
ports:
- "6432:6432"
app:
image: myapp:latest
environment:
DATABASE_URL: "postgres://app:pass@pgbouncer:6432/app" # conecta em pool
Armadilhas comuns
1. Sem LIMIT em query exploratória:
-- ❌ Maluco. Postgres vai ler 50 milhões de linhas
SELECT * FROM events;
-- ✓ Sempre com limite
SELECT * FROM events LIMIT 1000;
2. Índice demais: Cada índice que você cria custa escrita (Postgres atualiza índice em todo INSERT/UPDATE). Se tem 50 índices, INSERT fica lento.
3. NOT IN com valor NULL:
-- ❌ Retorna vazio, porque NULL IN comparação é desconhecido
SELECT * FROM users WHERE id NOT IN (SELECT id FROM users WHERE status = NULL);
-- ✓ Use NOT EXISTS
SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM users WHERE status IS NULL);
Quando NÃO usar Postgres
Volume gigantesco (petabytes): use data warehouse (BigQuery, Snowflake).
Dados não-estruturados (imagens, videos): use object storage (S3, GCS). Postgres armazena, mas não foi feito pra isso.
Latência crítica (millisegundos): cache com Redis em frente. Postgres é excelente, mas nunca vai ser mais rápido que memória.
Próximos passos
Observe queries lentas em aplicações reais com Observabilidade em aplicações com LLM — inclui logging de queries.
Quer busca vetorial? Vector database na prática integra com Postgres via extensão.
Teste local: instale Postgres (brew install postgresql no Mac ou Docker), roda o docker-compose acima, cria tabela com 1 milhão de linhas (pgbench), e roda EXPLAIN ANALYZE em queries lentas. A diferença entre Seq Scan e Index Scan é visual e imediata.