Trilha 12 · Bancos de dados

Índices e B-trees

Uma consulta que demora 40 segundos e passa a demorar 3 milissegundos depois de uma linha de comando: é o efeito mais espetacular que existe em banco de dados. Mas índice não é mágica nem é sempre bom — e entender por que ele funciona é o que separa criar índices por palpite de resolver o problema.

① Intuição

O índice remissivo de um livro

Para achar todas as menções a "recursão" num livro de 800 páginas, há duas opções: ler as 800 páginas, ou ir ao índice remissivo no fim, achar "recursão" (que está em ordem alfabética) e ler a lista de páginas. O índice remissivo é uma estrutura separada, ordenada e menor que aponta de volta para o conteúdo.

Um índice de banco é exatamente isso. E carrega os mesmos custos: ocupa páginas extras no fim do livro, e se você reescrever um capítulo, precisa refazer o índice. É por isso que um banco com muitos índices lê rápido e escreve devagar — cada INSERT precisa atualizar cada um deles.

Por que B+tree, e não hash table? Uma hash table acharia o email exato em O(1) — melhor que O(log n). Mas ela não sabe responder "todos os pedidos de janeiro", "os 10 mais caros" ou "ordene por data", porque o hash destrói a ordem. A B+tree mantém tudo ordenado e por isso serve para igualdade, intervalos e ordenação com a mesma estrutura. Por isso ela é o índice padrão, e o índice hash é a exceção especializada.
② Visualização interativa

A árvore, e o ponto em que ela deixa de valer a pena

Na primeira aba, busque uma chave e conte as páginas lidas com e sem índice; troque para o modo intervalo para ver por que as folhas são encadeadas. Na segunda, mexa na seletividade e encontre o ponto em que o otimizador passa a preferir varrer a tabela inteira.

Um índice é uma B+tree: uma árvore de busca em que cada nó ocupa exatamente uma página de disco. Aqui a árvore tem 32 chaves, 4 por nó. Repare que todos os valores ficam nas folhas, e as folhas são ligadas entre si — o que torna varreduras por intervalo baratas.

85
254565
105125145
5101520
25303540
45505560
65707580
859095100
105110115120
125130135140
145150155160
folhas ligadas em lista — é isso que permite ler um intervalo sequencialmente
85
Com índice
3
páginas lidas (altura da árvore)
Sem índice (full scan)
8
páginas lidas (a tabela inteira)
WHERE id = 85 → desce 3 níveis e encontra a chave na folha. Sem índice seriam 8 leituras. A vantagem cresce com o tamanho: numa tabela de 1 bilhão de linhas, uma B+tree com fanout 256 tem altura 4 — 4 leituras contra milhões.
③ Explicação técnica

Por que B+tree

-- Um índice é uma estrutura ORDENADA e separada, mantida pelo banco,
-- que mapeia valor → localização física da linha.

CREATE INDEX idx_email ON usuarios(email);

-- Por que B+tree e não uma árvore binária balanceada?
-- Porque o gargalo aqui não é comparação de CPU, é LEITURA DE DISCO.
-- Ler 1 byte custa quase o mesmo que ler uma página de 8 KB.
--
--   Árvore binária, 1 bilhão de chaves:
--     altura ≈ log₂(10⁹) = 30 → até 30 leituras de disco
--
--   B+tree com 256 chaves por nó (uma página cada):
--     altura ≈ log₂₅₆(10⁹) = 4 → 4 leituras de disco
--
-- Mesmo O(log n). Base do logaritmo diferente. Sete vezes menos I/O.

-- Duas escolhas de projeto da B+tree, e o porquê de cada uma:
--   1. os VALORES ficam só nas folhas; os nós internos guardam
--      apenas separadores → cabem mais chaves por página → menor altura
--   2. as folhas são LIGADAS entre si → ler um intervalo é descer
--      uma vez e seguir a lista, sem voltar à raiz a cada valor

Quando o índice é usado — e quando não é

-- O índice serve para:                    Mas NÃO serve para:
WHERE email = '[email protected]'          -- ✓  igualdade
WHERE idade BETWEEN 20 AND 30     -- ✓  intervalo (por ser ordenado)
ORDER BY criado_em DESC          -- ✓  ordenação de graça
WHERE nome LIKE 'Ana%'           -- ✓  prefixo fixo

WHERE nome LIKE '%Ana'           -- ✗  sufixo: a ordem não ajuda
WHERE LOWER(email) = '[email protected]'  -- ✗  função sobre a coluna
WHERE idade + 1 > 30            -- ✗  expressão sobre a coluna
WHERE ativo = true              -- ✗  seletividade péssima (50%)

-- A regra: o índice guarda o VALOR DA COLUNA. Se você aplica uma
-- função nela, o banco não tem o resultado dessa função guardado
-- em lugar nenhum e precisa calcular linha a linha.

-- Solução: índice sobre a EXPRESSÃO
CREATE INDEX idx_email_lower ON usuarios(LOWER(email));
-- agora WHERE LOWER(email) = ... usa índice

-- Índice PARCIAL: só indexa o que interessa. Menor e mais rápido.
CREATE INDEX idx_pendentes ON pedidos(criado_em)
    WHERE status = 'pendente';
Seletividade é o conceito que explica quase tudo. É a fração das linhas que a consulta retorna. Um índice em cpf tem seletividade excelente (uma linha em um milhão). Um índice em ativo (true/false) tem seletividade péssima: retorna metade da tabela, e buscar meia tabela linha a linha, com saltos aleatórios, é mais caro que ler tudo em ordem sequencial. Por isso o otimizador às vezes ignora um índice que existe — e está certo. É a explicação mais comum para "criei o índice e não melhorou nada".

Índices compostos e a regra do prefixo

-- ÍNDICE COMPOSTO: a ordem das colunas muda tudo.
CREATE INDEX idx ON pedidos(cliente_id, criado_em);

-- Pense numa lista telefônica ordenada por (sobrenome, nome).
-- Você consegue achar "todos os Silva" e "Silva, Ana".
-- Mas achar "todas as Anas", de qualquer sobrenome? Impossível
-- sem ler tudo — porque as Anas estão espalhadas.

-- Este índice serve para:
WHERE cliente_id = 7                          -- ✓ prefixo
WHERE cliente_id = 7 AND criado_em > '2024-01-01' -- ✓ completo
WHERE cliente_id = 7 ORDER BY criado_em         -- ✓ ordena de graça

-- E NÃO serve para:
WHERE criado_em > '2024-01-01'                  -- ✗ pula a 1ª coluna

-- Isso se chama regra do PREFIXO MAIS À ESQUERDA.
-- Corolário prático: um índice em (a, b) torna desnecessário um
-- índice só em (a) — mas não substitui um índice só em (b).

-- ÍNDICE COBRINDO (covering index): se todas as colunas da consulta
-- estão no índice, o banco responde SEM TOCAR na tabela.
CREATE INDEX idx ON pedidos(cliente_id) INCLUDE (valor);
SELECT valor FROM pedidos WHERE cliente_id = 7;
--   → "Index Only Scan" no EXPLAIN. O caminho mais rápido possível.

O custo dos índices

-- Índice não é grátis. O que ele custa:
--
--  1. ESPAÇO — um índice pode ocupar 10-30% do tamanho da tabela.
--     Cinco índices podem ocupar mais que os dados.
--
--  2. ESCRITA — todo INSERT/UPDATE/DELETE precisa atualizar TODOS
--     os índices da tabela, cada um com sua própria descida na
--     B+tree e eventual divisão de página.
--     Uma tabela com 8 índices pode ter escrita 5x mais lenta.
--
--  3. PLANOS PIORES — mais opções para o otimizador significa mais
--     chance de ele escolher errado com estatísticas ruins.

-- Como decidir: MEÇA, não adivinhe.
EXPLAIN ANALYZE SELECT ... ;   -- o plano real, com tempos

-- No PostgreSQL, índices que ninguém usa aparecem aqui:
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0;              -- nunca usados: candidatos a DROP

-- E como criar sem travar a tabela em produção:
CREATE INDEX CONCURRENTLY idx ON tabela(coluna);
-- (mais lento, mas não bloqueia escritas — em tabela grande,
--  a diferença entre uma manutenção invisível e uma queda)
④ Projeto para programar

Medindo de verdade

Mini projeto: crie uma tabela com 1 milhão de linhas geradas (em PostgreSQL, generate_series faz isso numa linha). Rode EXPLAIN ANALYZE numa consulta por igualdade, crie o índice, rode de novo e compare os tempos e os planos. Depois teste LIKE 'abc%' e LIKE '%abc' e confirme que só o primeiro usa o índice.

Projeto principal: encontre o ponto de virada da seletividade no seu próprio banco. Com uma coluna que tenha valores repetidos em proporções variadas, rode a mesma consulta filtrando valores que retornam 0,1%, 1%, 5% e 20% da tabela, sempre com EXPLAIN ANALYZE. Anote em que ponto o plano muda de Index Scan para Seq Scan. Você acabou de medir na prática a curva do widget acima — e o número vai depender do seu hardware, porque em SSD o custo do acesso aleatório é bem menor que em disco rígido.

Desafio extra: meça o custo de escrita. Crie duas tabelas idênticas, uma sem índices e outra com seis, e cronometre a inserção de 100 mil linhas em cada. Depois compare o tamanho em disco (pg_total_relation_size). O resultado costuma surpreender quem só pensou em índices do ponto de vista da leitura — e é o argumento que falta em toda discussão sobre "vamos indexar tudo por precaução".

⑤ Exercícios rápidos

Teste sua intuição

Por que índices usam B+trees em vez de árvores binárias balanceadas?
Por que o otimizador às vezes ignora um índice existente e varre a tabela inteira?
Com um índice em (cliente_id, criado_em), quais consultas conseguem usá-lo?
Qual é o custo de manter um índice?
⑥ Aplicações no mundo real

Onde você encontra isso

📈

O incidente clássico de escala

O sistema funciona bem por meses e um dia fica lento de repente. A causa mais comum: uma consulta sem índice que sempre esteve lá. Com 10 mil linhas, varrer tudo levava 5 ms e ninguém notou; com 10 milhões, leva 5 segundos. O custo O(n) não mudou — só o n. É por isso que EXPLAIN em desenvolvimento vale mais que otimização depois.

🌲

LSM-trees: a alternativa para escrita pesada

B+trees são ótimas para leitura, mas cada escrita é aleatória. Bancos feitos para ingestão massiva — Cassandra, RocksDB, LevelDB, ScyllaDB — usam LSM-trees: acumulam escritas na memória e as despejam em arquivos ordenados e imutáveis, que depois são mesclados em segundo plano. Todas as escritas viram sequenciais. O preço é uma leitura mais cara (pode ser preciso olhar vários níveis) e a compactação rodando o tempo todo.

🔤

Índices que não são B-trees

PostgreSQL oferece GIN para busca em texto completo e JSON (um índice invertido, como o de um buscador), GiST para dados geométricos e geográficos, BRIN para tabelas gigantes naturalmente ordenadas por data — que guarda só o mínimo e o máximo por bloco e ocupa quase nada. Escolher o tipo certo de índice é tão importante quanto escolher a coluna certa.

← Anterior: JOINs Próxima: Transações e ACID →