Tecnologias/02 - Bancos de Dados/01 - Relacionais/01 - PostgreSQL7 min
Índices e o Planejador
Conceito
O Postgres tem o planejador baseado em custo mais sofisticado entre os bancos open source, e o conjunto de tipos de índice mais rico de qualquer banco relacional. As duas coisas juntas explicam por que ele resolve tanta coisa — e por que às vezes ele "decide errado".
Os tipos de índice, e quando cada um é o certo
| Tipo | Estrutura | Serve para | Não serve para |
|---|---|---|---|
| B-tree | árvore balanceada | =, <, >, BETWEEN, ORDER BY, prefixo de LIKE 'abc%' |
busca em array/JSON |
| GIN | índice invertido | jsonb, arrays, busca textual (tsvector), pg_trgm |
ordenação; escrita é mais caro |
| GiST | árvore genérica | geometria (PostGIS), ranges, vizinho mais próximo (KNN) | igualdade pura (B-tree é melhor) |
| BRIN | resumo por bloco | tabelas enormes e fisicamente ordenadas (log por data) | dados sem correlação física |
| Hash | hash | só =; menor que B-tree |
qualquer faixa ou ordenação |
| SP-GiST | árvore particionada | dados não balanceados (IP, quadtree) | uso geral |
E dois modificadores que são exclusividade prática do Postgres:
-- ÍNDICE PARCIAL: indexa só o subconjunto que a query usa
CREATE INDEX pedidos_pendentes ON pedidos (criado_em) WHERE status = 'pendente';
-- 10 mil linhas indexadas em vez de 50 milhões. Menor, mais quente no cache,
-- e mais barato de manter na escrita.
-- ÍNDICE POR EXPRESSÃO: indexa o resultado de uma função
CREATE UNIQUE INDEX users_email_lower ON users (lower(email));
SELECT * FROM users WHERE lower(email) = lower($1); -- usa o índice
O índice parcial é a ferramenta mais subutilizada do Postgres. Fila de trabalho, soft
delete (WHERE deletado_em IS NULL), registros ativos — todos são casos onde 99% da
tabela não precisa estar no índice.
Como o Postgres lê um índice: três estratégias
Index Scan → percorre o índice, busca cada linha no heap (aleatório).
Bom para poucas linhas.
Bitmap Heap Scan → coleta todos os ctid no índice, ORDENA por página,
e lê o heap sequencialmente. Bom para muitas linhas.
Pode combinar VÁRIOS índices (BitmapAnd / BitmapOr).
Index Only Scan → responde SÓ com o índice, sem tocar o heap.
Exige que a Visibility Map diga "página toda visível" — ou seja,
DEPENDE DE VACUUM RECENTE. Ver [[4. MVCC, Tuplas e VACUUM]].
Isto é uma diferença estrutural em relação ao InnoDB: como o Postgres é
heap e não índice clusterizado, o índice guarda ctid (endereço físico) e sempre
precisa de um segundo acesso — a menos que o index-only scan seja possível.
-- INCLUDE: colunas carregadas no índice só para habilitar index-only scan
CREATE INDEX ON pedidos (cliente_id) INCLUDE (total, status);
-- a coluna não entra na chave (não serve para filtro/ordem), mas evita ir ao heap
O planejador: custo, não regra
O Postgres estima o custo de cada plano e escolhe o menor. As estimativas vêm de
pg_statistic (populado por ANALYZE): cardinalidade, valores mais comuns
(most common values), histograma, correlação física.
custo ≈ (páginas sequenciais × seq_page_cost)
+ (páginas aleatórias × random_page_cost)
+ (linhas processadas × cpu_tuple_cost)
+ ...
Dois parâmetros dominam a decisão "índice vs varredura":
random_page_cost(padrão 4.0) — herança de disco mecânico. Em SSD, use 1.1. Deixar 4.0 é a causa nº 1 de "ele não usa meu índice".effective_cache_size— quanto ele acredita que está em cache. Alto → favorece índice.
E quando a estimativa erra por correlação entre colunas, existe remédio:
-- o planejador assume independência: P(cidade='SP' E uf='SP') = P(a)×P(b).
-- Mas cidade determina uf! A estimativa sai 100× menor que a real.
CREATE STATISTICS s_cidade_uf (dependencies, ndistinct) ON cidade, uf FROM enderecos;
ANALYZE enderecos;
Trade-offs
Todo índice é um imposto na escrita. Cada INSERT escreve em todos os índices; cada
UPDATE de coluna indexada impede o HOT update (4. MVCC, Tuplas e VACUUM). Índices
não usados são custo puro — e é fácil descobrir quais são (query abaixo).
GIN é caro de escrever. Um índice GIN em jsonb de uma tabela com escrita intensa
pode dominar o custo do INSERT. Mitigue com fastupdate (padrão ligado, que acumula em
lista pendente) e ciente de que a lista pendente é limpa pelo VACUUM.
Índice composto tem ordem e ela importa. (a, b) serve WHERE a = ?,
WHERE a = ? AND b = ? e ORDER BY a, b — mas historicamente não servia WHERE b = ?. O
Postgres 18 trouxe skip scan, que permite usar o índice filtrando só pela segunda
coluna quando a primeira tem poucos valores distintos. Ajuda, mas não substitui a ordem
correta.
Mais estatística resolve mais que mais índice. Muitos planos ruins não são falta de
índice — são estimativa errada. ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 1000
(padrão 100) e CREATE STATISTICS corrigem casos que nenhum índice novo resolveria.
O trade-off central: o planejador do Postgres é excelente quando as estatísticas descrevem a realidade e os custos descrevem o hardware. Quase todo "o planejador é burro" é, na verdade, estatística velha ou
random_page_costde 2005.
Exemplo prático
Ler um EXPLAIN de verdade
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT p.id, p.total FROM pedidos p
WHERE p.cliente_id = 4711 AND p.status = 'pago'
ORDER BY p.criado_em DESC LIMIT 20;
Limit (cost=0.43..8.90 rows=20 width=20) (actual time=0.032..0.180 rows=20 loops=1)
Buffers: shared hit=24 read=2
-> Index Scan using pedidos_cliente_status_criado on pedidos p
Index Cond: ((cliente_id = 4711) AND (status = 'pago'::text))
(actual time=0.028..0.170 rows=20 loops=1)
Planning Time: 0.21 ms
Execution Time: 0.20 ms
O que olhar, em ordem de importância:
rowsestimado vsactual rows. Diferença de 10× ou mais = estatística ruim → é a raiz do problema, não o plano.Buffers: shared hitvsread.hité RAM,readé disco. Muitoreadrepetido = cache pequeno ou query varrendo demais.Rows Removed by Filter. Se o índice trouxe 500 mil linhas e o filtro descartou 499.980, falta uma coluna no índice.loops.loops=5000num nó interno é o 7. Problema N+1 dentro do próprio plano (nested loop mal escolhido).Sort Method: external merge Disk: 42MB. O sort não caiu emwork_meme foi para disco: subawork_memnesta sessão.
Higiene de índices
-- índices que ninguém usa: custo de escrita sem benefício de leitura
SELECT s.relname AS tabela, s.indexrelname AS indice,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS tamanho, s.idx_scan
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0 AND NOT i.indisunique
ORDER BY pg_relation_size(s.indexrelid) DESC;
-- tabelas sofrendo seq scan em volume
SELECT relname, seq_scan, seq_tup_read, idx_scan,
seq_tup_read / nullif(seq_scan, 0) AS linhas_por_scan
FROM pg_stat_user_tables
WHERE seq_scan > 0 ORDER BY seq_tup_read DESC LIMIT 10;
CREATE INDEX CONCURRENTLY em produção, sempreCREATE INDEX normal toma lock de escrita na tabela pelo tempo todo da construção.
CONCURRENTLY não bloqueia escrita (custa duas varreduras e pode falhar deixando um
índice INVALID, que você dropa e recria). O mesmo vale para
REINDEX CONCURRENTLY e DROP INDEX CONCURRENTLY.
Como isso falha em produção
| Sintoma | Causa |
|---|---|
| "Ele não usa meu índice" | random_page_cost=4; estatística velha; tipo divergente (bigint vs int em coluna text); função na coluna sem índice por expressão |
| Index-only scan parou de acontecer | VACUUM atrasado → Visibility Map desatualizada |
| Query rápida em dev, lenta em prod | volume e distribuição diferentes; plano diferente |
| Plano bom virou ruim de repente | ANALYZE mudou as estatísticas; dado cresceu para outro regime |
LIKE '%termo%' sempre em seq scan |
B-tree só serve prefixo; use pg_trgm + GIN |
ORDER BY gastando disco |
work_mem pequeno para o sort |
CREATE INDEX travou a aplicação |
faltou CONCURRENTLY |
Aprofundamento aplicado
Formular uma hipótese sobre seletividade
Consulta de exemplo: últimos pedidos de um cliente, ordenados por data e ID.
Um índice (cliente_id, criado_em DESC, id DESC) pode alinhar igualdade inicial e
ordenação. Buscar apenas por criado_em, porém, muda o caminho e precisa de outro exame.
Não conclua que o índice é bom apenas porque apareceu no plano. Observe quantas linhas
foram lidas para produzir o resultado e quanto trabalho foi repetido. Em nós com vários
loops, números por execução precisam ser interpretados junto com a quantidade de chamadas.
Referência: leitura de planos.
Experimento com dados desiguais
Compare um cliente com 5 pedidos e outro com 500 mil. Se a consulta retorna uma grande fração da tabela, uma leitura sequencial pode ser uma escolha racional. Estatísticas inadequadas podem esconder essa diferença do planejador.
Exercício: compare os dois clientes com EXPLAIN (ANALYZE, BUFFERS) em um ambiente
de estudo. Resultado esperado: justifique o plano por seletividade, leituras e
estimativa versus realidade; os custos estimados não são milissegundos.
Relacionado
- 4. MVCC, Tuplas e VACUUM
- 7. Diagnóstico e Tuning
- 2. Arquitetura de Processos e Memória
- 5. Índices
- SQL Tuning
- 7. Problema N+1
- 4. Índices e o Otimizador
Parte de MOC PostgreSQL · Indexes (docs)