Saltar para o conteúdo

professor Índices B-tree Lição 6

Lição 6 · extensão ~1 hora

O custo do outro lado

Cinco lições a ver o que um índice poupa. Esta é sobre o que ele cobra — e sobre as duas ferramentas que reduzem a fatura: índices parciais e de expressão.

1. Objetivo

No fim consegues defender a decisão de não criar um índice com números, identificar os índices que uma base de dados tem a mais, e usar índices parciais e de expressão nos casos em que resolvem mais barato do que um índice completo.

2. Recuperar

  1. Que campo do EXPLAIN denuncia trabalho desperdiçado a filtrar linhas?
    Resposta

    Rows Removed by Filter.

  2. Índice sobre (a, b): torna redundante que outro índice?
    Resposta

    Um índice só sobre (a)a é prefixo do composto.

  3. Porque é que um Index Only Scan pode ir à tabela na mesma?
    Resposta

    Porque as páginas não estão marcadas como todas-visíveis no visibility map. Vê-se em Heap Fetches.

3. O mecanismo

Um índice é uma estrutura a mais para manter

Cada INSERT tem de inserir uma entrada em cada índice da tabela, no sítio certo da árvore — o que pode obrigar a dividir páginas. Cada UPDATE de uma coluna indexada é, na prática, uma remoção e uma inserção. Cada DELETE deixa entradas mortas até ao VACUUM.

Medido na base de treino (Postgres 17.11), a inserir 200 000 linhas numa tabela vazia:

TabelaÍndicesTempo do INSERT
t_sem0182 ms
t_com41232 ms

6,8 vezes mais lento. E o espaço vai na mesma direção: no fim, a tabela ocupava 13 MB e os índices 16 MB — os índices pesavam mais do que os dados.

⚠️ Este número é o teto, não a média

Um INSERT de 200 000 linhas de uma vez é o caso mais favorável à tabela sem índices. Numa aplicação que insere uma linha de cada vez, o custo fixo por transação dilui a diferença, e o fator é menor. O que se retém não é «6,8×» — é que existe um fator, que cresce com o número de índices, e que se mede na tua carga.

Índices parciais: indexar só o que interessa

Se as queries só procuram um subconjunto das linhas, só esse subconjunto precisa de estar no índice.

CREATE INDEX idx_pendentes ON encomendas (criada_em) WHERE estado = 'pendente';
ÍndiceTamanho
(estado) — completo6904 kB
(criada_em) WHERE estado = 'pendente'384 kB

18 vezes mais pequeno, porque só indexa 2% das linhas. E o ganho não é só espaço: as 97% de linhas «entregues» não tocam neste índice quando são inseridas.

O caso canónico é uma fila de trabalho: WHERE processado = false. A tabela cresce para sempre; o índice fica do tamanho da fila.

Índices de expressão: indexar o resultado de uma função

A lição 3 mostrou que WHERE lower(email) = ? não usa um índice em email. A saída é indexar a própria expressão:

CREATE INDEX idx_email_min ON clientes ((lower(email)));

Duas condições, e ambas apanham gente desprevenida:

🔴 A função tem de ser IMMUTABLE

Uma função só pode ser indexada se devolver sempre o mesmo resultado para a mesma entrada. Encontrei isto ao escrever esta lição, a tentar indexar o ano de uma data:

CREATE INDEX idx_ano ON encomendas ((date_part('year', criada_em)));
ERROR:  functions in index expression must be marked IMMUTABLE

A causa: criada_em é timestamptz, e extrair o ano depende do fuso horário da sessão. A mesma linha daria 2025 em Lisboa e 2024 em São Paulo — e um índice não pode depender de quem o consulta. Confirma-se no catálogo: a função está marcada s (stable), não i (immutable).

A correção é fixar o fuso, tornando a expressão determinística:

CREATE INDEX idx_ano ON encomendas ((date_part('year', criada_em AT TIME ZONE 'UTC')));
⚠️ A query tem de usar exatamente a mesma expressão

Medido: com o índice acima, WHERE date_part('year', criada_em AT TIME ZONE 'UTC') = 2025 usa-o (11,8 ms). Mas WHERE date_part('year', criada_em) = 2025 — sem o AT TIME ZONE — volta a Parallel Seq Scan (31,4 ms). São expressões diferentes, e o planeador não as reconcilia.

É um índice fácil de criar e de deixar por usar sem ninguém dar conta.

Como encontrar os índices a mais

SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS tamanho
FROM pg_stat_user_indexes WHERE relname = 'encomendas' ORDER BY idx_scan;

idx_scan = 0 quer dizer que o índice nunca foi usado desde a última reposição das estatísticas. Antes de apagar, três cuidados:

4. Exemplo trabalhado

Decidir se vale a pena um índice — com números, não com opinião.

O caso: tabela eventos, 5000 INSERTs por minuto. Um relatório diário corre WHERE tipo = 'erro' AND criada_em > ? e demora 8 s. Alguém propõe CREATE INDEX ON eventos (tipo, criada_em).

Passo 1 — quanto se ganha (medir, não estimar)

Porquê primeiro: se o ganho for pequeno, a discussão acaba aqui.

BEGIN;
CREATE INDEX idx_teste ON eventos (tipo, criada_em);
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
ROLLBACK;   -- o índice desaparece com a transação

⭐ A técnica vale por si: CREATE INDEX é transacional em Postgres. Podes experimentar um índice e desfazê-lo sem deixar rasto. (Numa tabela em produção com escrita, usa-se CREATE INDEX CONCURRENTLY — que não é transacional, precisamente por não bloquear.)

Digamos que o relatório passa de 8 s para 40 ms. Ganho: ~8 s por dia.

Passo 2 — quanto se perde

5000 INSERTs/min = 7,2 milhões por dia. Se cada um custar mais 20 µs por causa do índice, são 144 s por dia de trabalho extra, espalhados por todas as escritas.

Este número mede-se, não se adivinha: insere-se um lote representativo com e sem o índice, como na tabela acima. O que se está a comparar é 8 s poupados uma vez contra 144 s gastos continuamente.

Passo 3 — perguntar se há forma mais barata

Porquê antes de decidir: a escolha raramente é «índice sim ou não». Aqui:

Passo 4 — decidir e escrever porquê

✅ Decisão: índice parcial

Porque dá quase todo o ganho de leitura com uma fração do custo de escrita. E porque, sendo pequeno, é barato de manter e de apagar se as queries mudarem.

A parte que se esquece: escrever a razão onde alguém a encontre — numa migração, num comentário. Daqui a um ano, quem vir um índice parcial estranho e não souber porquê, ou o apaga (e parte o relatório) ou não lhe toca por medo (e nunca mais é revisto).

5. Exemplo com lacunas

Completa a auditoria

Dado. Tabela sessoes, escrita intensa, com estes índices:

Índiceidx_scanTamanho
sessoes_pkey (id)4 100 000210 MB
(utilizador_id)890 000180 MB
(utilizador_id, criada_em)1 200 000260 MB
(agente_utilizador)0340 MB
(expira_em)52190 MB

Passo 1 (completa). Qual é redundante por definição, e porquê? ______

Passo 2 (completa). Qual é o candidato mais óbvio a apagar? ______ O que confirmas antes? ______

Passo 3 (completa). O de expira_em tem 52 usos. Apagas? ______ Porquê? ______

Passo 4 (completa). Quanto espaço poupas, e o que ganhas além do espaço? ______

Ver os passos em falta

Passo 1: (utilizador_id) — é prefixo de (utilizador_id, criada_em), portanto tudo o que serve já é servido pelo composto. 180 MB e trabalho em cada escrita, a troco de nada.

Passo 2: (agente_utilizador): zero usos e o maior de todos, 340 MB. Antes de apagar, confirma-se stats_reset (zero desde ontem não é zero), e se alguma réplica o usa.

Passo 3: Não, sem investigar. 52 usos podem ser um trabalho de limpeza que corre uma vez por dia — e que sem o índice passa a varrer a tabela inteira. Um índice pouco usado pode ser o que impede um incidente noturno. A pergunta certa não é «quantas vezes?», é «quem o usa, e o que acontece sem ele?».

Passo 4: 520 MB (340 + 180). Além do espaço: duas árvores a menos para manter em cada INSERT numa tabela de escrita intensa, menos páginas a disputar a cache (o que melhora os outros índices), e menos trabalho para o VACUUM.

6. Erros comuns

A ideia erradaComo se reconheceO que é mesmo
«Um índice a mais não faz mal»Dez índices numa tabela de escritaMedido: 4 índices → INSERT 6,8× mais lento, e índices a pesar mais que a tabela
«idx_scan = 0, apaga-se»Apagar sem ver stats_resetZero desde ontem não é zero. E pode ser o índice de uma limpeza semanal, ou usado só na réplica
«Criei o índice de expressão, está resolvido»Não verificar o plano depoisA query tem de usar a expressão literalmente igual. Medido: sem o AT TIME ZONE, volta a Seq Scan
«Qualquer função pode ser indexada»ERROR: functions in index expression must be marked IMMUTABLESó funções determinísticas. now(), random() e conversões dependentes de fuso ou de locale não servem
«Índices parciais são um caso de nicho»Nunca os usarFilas de trabalho, soft deletes, estados raros — são comuníssimos, e a poupança é grande (18× aqui)
«A query está lenta, logo precisa de índice»Índice como primeira respostaÀs vezes a resposta é não indexar: um relatório noturno de 8 s pode não ser um problema. «Lento» ≠ «problema»

7. Praticar

●○○

E1 — parcial ou completo?

Para cada caso, diz se farias índice completo, parcial (com que condição), ou nenhum:

  1. Fila: WHERE processado = false, 0,1% das linhas, tabela cresce para sempre.
  2. Soft delete: quase todas as queries têm WHERE apagado_em IS NULL; 5% apagadas.
  3. WHERE pais = ?, cinco países com ~20% cada.
Solução
  1. Parcial, WHERE processado = false. O caso canónico: o índice fica do tamanho da fila em vez do tamanho da história, e as linhas já processadas deixam de lhe tocar.
  2. Parcial, WHERE apagado_em IS NULL, acrescentando as colunas que as queries usam. Poupa só 5% do tamanho — mas o Postgres passa a poder usar o índice sem revalidar a condição, porque ela faz parte da definição. O ganho é mais em planos do que em bytes.
  3. Provavelmente nenhum. 20% por país é pouco seletivo e, pela lição 3, se as linhas estiverem dispersas o índice não poupa páginas. Mede-se com BUFFERS antes de decidir. Se houvesse um país com 0,5%, um índice parcial só para esse faria sentido.
●●○

E2 — código que corre: mede o custo de escrita da tua máquina

Não aceites o 6,8×. Mede o teu:

CREATE TABLE t_sem (LIKE encomendas);
CREATE TABLE t_com (LIKE encomendas);
CREATE INDEX ON t_com(cliente_id); CREATE INDEX ON t_com(estado);
CREATE INDEX ON t_com(total_cents); CREATE INDEX ON t_com(cliente_id, criada_em);
\timing on
INSERT INTO t_sem SELECT * FROM encomendas LIMIT 200000;
INSERT INTO t_com SELECT * FROM encomendas LIMIT 200000;
SELECT pg_size_pretty(pg_table_size('t_com')), pg_size_pretty(pg_indexes_size('t_com'));
DROP TABLE t_sem, t_com;

Depois repete com um índice em vez de quatro. O custo cresce de forma proporcional?

Solução

Medido em Postgres 17.11: 182 ms sem índices, 1232 ms com quatro — 6,8×. Tabela 13 MB, índices 16 MB.

Com um índice o custo não é ¼ do de quatro: há um custo fixo por linha que não depende dos índices (escrever na tabela, no WAL), e os índices variam bastante entre si — um sobre estado (3 valores, muito deduplicado) é mais barato de manter do que um sobre total_cents (50 000 valores distintos, entradas espalhadas por toda a árvore).

⭐ É por isso que a pergunta útil nunca é «quantos índices tenho?», mas «este índice, nesta coluna, paga-se?».

●●●

E3 — o índice de expressão que não é usado

Reproduz a armadilha: cria o índice sobre date_part('year', criada_em) e vê o erro. Corrige com AT TIME ZONE. Depois mostra que a query sem o AT TIME ZONE não o usa. Por fim: que solução é melhor do que o índice de expressão, para este caso concreto?

Solução

O erro e a correção estão no mecanismo, acima. Medido: com a expressão exata, 11,8 ms (Bitmap Index Scan on idx_ano); sem o AT TIME ZONE, 31,4 ms (Parallel Seq Scan).

A solução melhor: não usar índice de expressão nenhum. Reescrever a query como intervalo sobre a coluna:

WHERE criada_em >= '2025-01-01' AND criada_em < '2026-01-01'

Porque é melhor:

  • Usa um índice normal sobre criada_em, que provavelmente já existe e serve muitas outras queries — em vez de mais uma árvore só para esta.
  • Não depende de quem escreve a query acertar na expressão exata.
  • Não tem o problema do fuso: a comparação é feita sobre o valor, não sobre uma extração dele.

🔴 O índice de expressão é a ferramenta certa quando a expressão é irredutívellower(email) para comparações sem maiúsculas, por exemplo. Não é a ferramenta certa para contornar uma query que podia ser reescrita como intervalo.

8. Quiz

  1. CREATE INDEX ON t ((date_part('year', criada_em))) onde criada_em é timestamptz. O que acontece?

  2. Um índice tem idx_scan = 0 e ocupa 340 MB. Apagas?

  3. Fila de trabalho: WHERE processado = false, 0,1% das linhas, tabela que cresce para sempre. Que índice?

  4. Queres saber se um índice ajuda, sem o deixar criado. O que fazes?

  5. Relatório noturno de 8 s numa tabela com 5000 INSERTs por minuto. Qual é a melhor primeira pergunta?

9. Explica por palavras tuas

Entregar

Guardar em topicos/indices-btree-sql/respostas/AAAA-MM-DD.md.

✅ Acabaste o curso

Falta o que decide se fica sabido: o teste de domínio (intercalado, entrega-se para correção) e o revisao.ics no calendário. A folha de consulta imprime-se e fica ao lado do teclado.

10. Resumo

11. Fontes