Pular para o conteúdo
iauaiiauai — portal de tecnologia, IA e Cloud
EngenhariaIntermediário

Postgres: o que todo dev deveria saber

EXPLAIN ANALYZE, índices (B-tree, composto, parcial), N+1 em ORM, transações, pooling. Debug de queries lentas com números e soluções práticas reais.

Por Equipe iauai · 6 de agosto de 2026 · 14 min de leitura

Nesta página

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 o demora 0.234 ms — 2.000 linhas, rápido
  • Seq Scan on users u demora 0.001 ms — 100 linhas, filtrado por status
  • Hash Left Join demora 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 novos
  • REPEATABLE READ: snapshot, transações longas precisam desse
  • SERIALIZABLE: 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.

Continue lendo