Bancos de Dados: O Poder (e o Perigo) dos Índices
Como B-Trees funcionam internamente, EXPLAIN ANALYZE para diagnóstico, índices compostos e sua ordem de colunas, índices parciais, covering indexes e o custo real de cada índice em writes.
Uma API que funcionava perfeitamente com 1.000 usuários começa a ficar lenta com 100.000. Não é o servidor, não é o Node.js, não é o React — é o banco de dados fazendo Full Table Scan: lendo cada linha da tabela para responder uma query. Em uma tabela de 500.000 pedidos, isso significa ler 500.000 linhas para encontrar os pedidos de um único usuário.
Índices resolvem isso, mas a maioria dos desenvolvedores entende apenas a superfície: 'adiciona um INDEX e fica rápido'. A realidade é mais rica — a ordem das colunas em um índice composto importa, índices têm custo real em writes, e o PostgreSQL tem EXPLAIN ANALYZE para te dizer exatamente o que está acontecendo.
Como B-Trees Funcionam: A Estrutura Real
O tipo de índice padrão no PostgreSQL (e na maioria dos bancos relacionais) é a B-Tree (Balanced Tree). Entender sua estrutura explica por que ela é eficiente e quais operações ela acelera:
Uma B-Tree organiza os valores indexados em nós ordenados. Cada nó interno aponta para filhos que contêm valores em um intervalo específico. Para encontrar o email 'maria@email.com' em uma tabela de 1 milhão de registros:
- O banco começa no nó raiz e compara:
'maria...' > 'mmmm...'? - Desce para o nó filho do intervalo correto
- Repete por ~20 níveis (log₂ de 1.000.000 ≈ 20)
- Encontra o ponteiro para a posição real na tabela (heap tuple)
- Lê apenas essa linha
Resultado: 20 operações de I/O ao invés de 1.000.000. Isso explica a diferença de 4 segundos para 50ms. Uma B-Tree suporta operações de igualdade (=), range (>, <, BETWEEN) e ordenação (ORDER BY) no mesmo índice — por isso é o tipo padrão.
EXPLAIN ANALYZE: Veja o Que o Banco Está Fazendo
Antes de criar qualquer índice, use EXPLAIN ANALYZE para entender o plano de execução atual. É a ferramenta mais importante para diagnóstico de performance:
-- EXPLAIN: mostra o plano sem executar a query (estimativas)
-- ANALYZE: executa a query e mostra números reais
EXPLAIN ANALYZE
SELECT id, status, total
FROM orders
WHERE customer_id = 'uuid-123'
AND status = 'PENDING'
ORDER BY created_at DESC;
-- Saída típica SEM índice:
-- Seq Scan on orders (cost=0.00..8432.00 rows=1 width=48)
-- (actual time=0.024..124.832 rows=3 loops=1)
-- Filter: ((customer_id = 'uuid-123') AND (status = 'PENDING'))
-- Rows Removed by Filter: 499997
-- Planning Time: 0.3 ms
-- Execution Time: 124.9 ms ← Full Table Scan!
-- Saída típica COM índice:
-- Index Scan using idx_orders_customer_status on orders
-- (cost=0.43..12.47 rows=3 width=48)
-- (actual time=0.012..0.025 rows=3 loops=1)
-- Index Cond: ((customer_id = 'uuid-123') AND (status = 'PENDING'))
-- Planning Time: 0.4 ms
-- Execution Time: 0.08 ms ← Index Scan!Os termos mais importantes no EXPLAIN ANALYZE:
- `Seq Scan` — Full Table Scan. Sinal de alerta se a tabela for grande.
- `Index Scan` — usa o índice + lê as linhas no heap. Bom para baixa seletividade.
- `Index Only Scan` — responde apenas com dados do índice, sem tocar o heap. O mais eficiente.
- `cost=0.00..8432.00` — estimativa de custo do planejador (unidades arbitrárias). O segundo número é o total.
- `rows=X` — linhas retornadas. Quando muito diferente da estimativa, as estatísticas estão desatualizadas (
ANALYZE). - `Rows Removed by Filter: 499997` — quantas linhas foram lidas mas descartadas. Alto valor = candidato a índice.
Índices Simples: Quando e Como Criar
-- Índice simples: busca por email
CREATE INDEX idx_users_email ON users (email);
-- Índice único: garante unicidade E acelera buscas
CREATE UNIQUE INDEX idx_users_email_unique ON users (email);
-- Índice em chave estrangeira: SEMPRE indexe FKs
-- JOINs sem índice na FK são Full Scans
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
-- Índice em colunas de status usadas em WHERE frequentemente
CREATE INDEX idx_orders_status ON orders (status);Índices Compostos: A Ordem das Colunas Importa
Índices compostos (múltiplas colunas) são poderosos, mas a ordem das colunas define para quais queries o índice funciona. A regra é o prefixo: um índice em (a, b, c) acelera queries que filtram por (a), (a, b), ou (a, b, c) — mas não acelera queries que filtram apenas por (b) ou (c).
-- Índice composto para a query: WHERE customer_id = ? AND status = ?
CREATE INDEX idx_orders_customer_status
ON orders (customer_id, status);
-- Este índice acelera:
-- WHERE customer_id = 'uuid' ✅ (prefixo: customer_id)
-- WHERE customer_id = 'uuid' AND status = 'PENDING' ✅ (prefixo completo)
-- Este índice NÃO acelera:
-- WHERE status = 'PENDING' ❌ (não começa pelo prefixo)
-- Regra para definir a ordem das colunas no índice composto:
-- 1. Colunas de igualdade (=) primeiro
-- 2. Colunas de range (>, <, BETWEEN) por último
-- Exemplo: WHERE customer_id = ? AND created_at > ?
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at DESC);
-- A direção DESC no índice é importante para ORDER BY DESC!Covering Indexes: Index Only Scan
Um Covering Index inclui todas as colunas que uma query precisa, eliminando a necessidade de acessar o heap (as linhas reais da tabela). O resultado é um Index Only Scan — o tipo de leitura mais eficiente possível:
-- Query que lista pedidos de um cliente:
SELECT id, status, total, created_at
FROM orders
WHERE customer_id = 'uuid-123'
ORDER BY created_at DESC
LIMIT 10;
-- Índice normal: Index Scan + Heap Fetch (2 I/Os por linha)
CREATE INDEX idx_orders_customer ON orders (customer_id);
-- Covering Index: inclui todas as colunas do SELECT
-- Resultado: Index Only Scan (1 I/O, sem tocar o heap)
CREATE INDEX idx_orders_customer_covering
ON orders (customer_id, created_at DESC)
INCLUDE (id, status, total);
-- A cláusula INCLUDE adiciona colunas ao índice sem incluí-las na ordenação
-- (disponível no PostgreSQL 11+ e SQL Server)Índices Parciais: Indexando Apenas o Que Importa
Um Partial Index (índice parcial) indexa apenas um subconjunto das linhas baseado em uma condição WHERE. Isso reduz drasticamente o tamanho do índice e o custo de manutenção em writes:
-- Cenário: tabela com 1 milhão de pedidos
-- 95% tem status 'DELIVERED', 5% tem status 'PENDING'
-- Você só busca por pedidos PENDENTES com frequência
-- Índice normal: indexa 1.000.000 de linhas
CREATE INDEX idx_orders_status ON orders (status);
-- Índice parcial: indexa apenas as 50.000 linhas PENDING
CREATE INDEX idx_orders_pending
ON orders (customer_id, created_at)
WHERE status = 'PENDING';
-- Resultado: índice 95% menor, writes 95% mais rápidos para ele
-- A query abaixo usará este índice automaticamente:
SELECT * FROM orders
WHERE customer_id = 'uuid-123' AND status = 'PENDING';
-- Outro uso clássico: soft delete
-- Indexar apenas registros não deletados
CREATE INDEX idx_users_active
ON users (email)
WHERE deleted_at IS NULL;O Custo Real dos Índices: Writes
Todo índice tem um custo em operações de escrita. Quando você faz um INSERT, UPDATE ou DELETE, o banco precisa atualizar cada índice da tabela além dos dados em si. Uma tabela com 10 índices faz 11 operações de I/O para cada write:
-- Verificando quantos índices uma tabela tem:
SELECT
indexname,
indexdef,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_indexes
JOIN pg_class ON pg_class.relname = pg_indexes.indexname
WHERE tablename = 'orders'
ORDER BY pg_relation_size(indexrelid) DESC;
-- Identificando índices não utilizados (candidatos para remoção):
SELECT
schemaname,
tablename,
indexname,
idx_scan AS vezes_utilizado,
pg_size_pretty(pg_relation_size(indexrelid)) AS tamanho
FROM pg_stat_user_indexes
JOIN pg_indexes USING (schemaname, tablename, indexname)
WHERE idx_scan = 0 -- Nunca foi usado!
AND indexname NOT LIKE '%pkey%' -- Exclui PKs
ORDER BY pg_relation_size(indexrelid) DESC;Nunca indexe colunas de baixa cardinalidade como booleanos (is_active com valores true/false) com índice simples — o banco prefere Full Scan porque o índice não filtra o suficiente. Use Partial Index nesses casos. Também evite indexar colunas que mudam constantemente (como updated_at) pois o custo de manutenção do índice supera os benefícios.
Criando Índices sem Bloquear a Tabela em Produção
Por padrão, CREATE INDEX bloqueia writes na tabela enquanto o índice é construído — inaceitável em produção. A solução é o CONCURRENTLY:
-- Cria o índice sem bloquear a tabela
-- Demora mais, mas não impacta a produção
CREATE INDEX CONCURRENTLY idx_orders_customer_id
ON orders (customer_id);
-- Desvantagem: não pode ser feito dentro de uma transaction
-- Monitore o progresso:
SELECT phase, blocks_done, blocks_total,
ROUND(100.0 * blocks_done / NULLIF(blocks_total, 0), 1) AS percent
FROM pg_stat_progress_create_index
WHERE relid = 'orders'::regclass;Conclusão
O workflow correto para otimização de queries é: medir primeiro (com EXPLAIN ANALYZE), identificar Seq Scans em tabelas grandes, criar o índice mais específico possível (partial > covering > composto > simples), e verificar com pg_stat_user_indexes se os índices existentes estão sendo usados. Índices desnecessários são passivo, não ativo — aumentam o tempo de writes e consomem disco sem trazer benefício.