flowchart TD
A[Coluna candidata a indice] --> B{Usada em WHERE, JOIN ou ORDER BY?}
B -- Nao --> X[Nao indexe]
B -- Sim --> C{A coluna tem alta cardinalidade?}
C -- Nao --> D[Considere bitmap ou nao indexe]
C -- Sim --> E{A tabela tem muitas escritas?}
E -- Sim --> F[Avalie custo de manutencao]
E -- Nao --> G[Crie o indice]
F --> G
Índices, otimização de consultas e estruturas de indexação (B-tree e Hash)
Chegamos a um dos módulos que mais separam quem entende banco de dados de quem apenas escreve SQL: a indexação e a otimização de consultas. Eu te garanto que esse tema cai em prova, e cai de formas variadas — desde a pergunta conceitual “para que serve um índice” até a pergunta capciosa sobre por que um índice Hash não serve para uma busca por intervalo. Quero que você termine este módulo enxergando o índice não como uma caixa-preta mágica que “deixa a consulta mais rápida”, mas como uma estrutura de dados concreta, com custo de espaço, custo de manutenção e um comportamento previsível. Vou te conduzir do conceito geral até o detalhe das B-trees e das tabelas hash, e no fim você saberá escolher a estrutura certa para cada cenário. Repare comigo: tudo aqui gira em torno de uma única obsessão — reduzir o número de acessos ao disco.
Por que índices existem
Imagine que você precisa encontrar a palavra “transação” em um livro de oitocentas páginas sem índice remissivo. Você leria página por página. Esse é o método que chamamos de full table scan (varredura completa da tabela): o SGBD percorre cada linha verificando a condição. Agora imagine o mesmo livro com um índice remissivo no fim — você vai direto à entrada certa e salta para a página. O índice de banco de dados é exatamente esse índice remissivo: uma estrutura auxiliar que mapeia valores de uma coluna para a localização física dos registros, evitando a leitura sequencial de tudo.
O propósito central, então, é reduzir acessos a disco. Em uma tabela com dez milhões de linhas, uma varredura completa pode significar dezenas de milhares de leituras de página; uma busca via índice bem construído resolve o mesmo em três ou quatro leituras. Essa diferença é o que separa uma consulta de milissegundos de uma consulta de minutos.
Definição. Um índice é uma estrutura de dados secundária, mantida pelo SGBD em paralelo à tabela, que organiza os valores de uma ou mais colunas (a chave de indexação) de modo a acelerar a localização de registros. Ele não armazena os dados em si necessariamente, mas ponteiros para onde os dados estão.
Tipos de índice e como criá-los
Você cria índices em SQL com o comando CREATE INDEX. Deixa eu te mostrar os tipos mais comuns, porque a prova adora distingui-los. O mais simples é o índice de coluna única, ideal quando você filtra repetidamente por um atributo:
Quando suas consultas filtram por mais de uma coluna ao mesmo tempo, usa-se o índice composto. Aqui há uma sutileza que vale ouro em prova: a ordem das colunas importa. Um índice em (nome, curso) serve para filtrar só por nome, ou por nome e curso juntos, mas não serve bem para filtrar apenas por curso. É o que chamamos de princípio do prefixo mais à esquerda.
Há também o índice único, que além de acelerar buscas impõe uma restrição de integridade: nenhum valor duplicado é aceito na coluna. Ele é o mecanismo físico por trás das chaves primárias e das restrições UNIQUE.
Além desses, existem índices especializados: o índice de texto completo, otimizado para buscar palavras dentro de campos textuais longos, e o índice espacial, voltado para coordenadas geográficas. Em PostgreSQL você ainda encontra índices bitmap, que representam a presença de valores com cadeias de bits e brilham em colunas de baixa cardinalidade. Por ora, foque nos três primeiros tipos, que são os que mais aparecem.
Atenção — o índice não é de graça. Cada índice ocupa espaço em disco e precisa ser atualizado a cada INSERT, UPDATE ou DELETE na coluna indexada. Em uma tabela com muitas escritas, índices em excesso degradam o desempenho. A regra é: indexe o que você consulta com frequência, não tudo. Índice é acelerador de leitura pago com lentidão de escrita.
Vou organizar visualmente essa decisão de quando criar um índice, porque ela é o coração prático do tema:
Índices clusterizados e não clusterizados
Aqui está uma distinção que você precisa dominar. Um índice clusterizado define a ordem física dos registros no disco — as linhas são gravadas na ordem da chave do índice. Por isso só pode existir um índice clusterizado por tabela: você não pode ordenar fisicamente os mesmos dados de duas maneiras ao mesmo tempo. Em muitos SGBDs, a chave primária cria automaticamente o índice clusterizado.
Já um índice não clusterizado é uma estrutura separada que guarda os valores da chave ordenados e, ao lado de cada valor, um ponteiro para a localização da linha real. Você pode ter vários índices não clusterizados por tabela. A analogia que eu gosto: o índice clusterizado é a própria ordem das páginas do livro; o não clusterizado é o índice remissivo no fim, que aponta para as páginas.
| Característica | Clusterizado | Não clusterizado |
|---|---|---|
| Quantidade por tabela | Apenas um | Vários |
| Ordena fisicamente os dados | Sim | Não |
| Busca por intervalo | Muito eficiente | Eficiente, com saltos |
| Custo de leitura do registro | Direto | Indireto, via ponteiro |
| Custo de escrita | Maior (reordena) | Menor |
O otimizador de consultas
Agora chegamos a um componente que muita gente nem sabe que existe: o otimizador de consultas. Quando você submete um SELECT, o SGBD não executa a consulta da forma literal como você a escreveu. Ele primeiro decide como executá-la — qual índice usar, em que ordem juntar as tabelas, se vale a pena varrer ou indexar. Esse plano de operações é o plano de execução.
O ponto que sempre surpreende o aluno é que a maioria dos otimizadores modernos é baseada em custo. Isso significa que o otimizador estima, para cada estratégia possível, um custo numérico aproximado (em termos de leituras de disco e processamento) e escolhe a de menor custo. E como ele estima esse custo? Por meio de estatísticas: histogramas e contagens que o SGBD mantém sobre a distribuição dos dados — quantas linhas há, quantos valores distintos uma coluna tem, como esses valores se distribuem.
flowchart LR
A[Consulta SQL] --> B[Parser]
B --> C[Reescrita logica]
C --> D[Otimizador baseado em custo]
E[(Estatisticas)] --> D
D --> F[Plano de execucao]
F --> G[Motor de execucao]
G --> H[Resultado]
Daqui sai uma lição prática poderosa: se as estatísticas estiverem desatualizadas, o otimizador toma decisões ruins. Ele pode achar que uma coluna tem dez valores distintos quando na verdade tem dez mil, e escolher uma varredura completa onde um índice seria muito melhor. Por isso, manter estatísticas atualizadas é uma das boas práticas mais subestimadas.
Lendo planos de execução com EXPLAIN
A ferramenta que te dá raio-X do plano é o EXPLAIN. Ela mostra a estratégia que o otimizador escolheu sem necessariamente executar a consulta. No PostgreSQL, EXPLAIN ANALYZE vai além e executa de fato, retornando os tempos reais.
O que você procura ali? Principalmente se aparece um Seq Scan (varredura sequencial, lê a tabela inteira) ou um Index Scan (usa o índice). Para uma consulta que retorna poucas linhas de uma tabela grande, ver um Seq Scan é sinal de alerta — provavelmente falta um índice ou as estatísticas estão erradas. Veja a diferença que um índice faz:
| Antes do índice | Depois do índice |
|---|---|
| Seq Scan on estudantes | Index Scan using idx_estudante_curso |
| custo estimado: 18500 | custo estimado: 32 |
| linhas examinadas: 1.000.000 | linhas examinadas: 240 |
Definição. O plano de execução é a sequência ordenada de operações físicas — varreduras, buscas em índice, junções, ordenações — que o SGBD efetivamente realiza para responder a uma consulta. O EXPLAIN revela esse plano; aprender a lê-lo é o que transforma você de quem escreve SQL em quem otimiza SQL.
Boas práticas de otimização
Deixa eu te passar, em prosa, as práticas que realmente movem o ponteiro. A primeira é usar índices de forma apropriada: crie-os nas colunas que aparecem em WHERE, JOIN e ORDER BY, e evite o excesso que penaliza escritas. A segunda é reescrever consultas para não cegar o índice. Um erro clássico é aplicar uma função sobre a coluna indexada na cláusula WHERE — isso geralmente impede o uso do índice, porque o valor calculado não está indexado.
A terceira prática é limitar resultados com LIMIT quando você não precisa de tudo, reduzindo dados transferidos. A quarta é evitar SELECT * e pedir só as colunas necessárias — isso, inclusive, pode permitir que o índice cubra a consulta inteira sem tocar na tabela. Para volumes muito grandes, entram técnicas estruturais como particionamento de tabelas, desnormalização controlada (lembre do nosso módulo de Normalização — às vezes uma redundância calculada compensa em leitura) e visões materializadas, que congelam o resultado de consultas pesadas. Por fim, monitore regularmente, faça revisão periódica dos índices para remover os que ninguém usa, e teste com cargas realistas.
Estruturas de indexação: a B-tree em profundidade
Agora vamos abrir a caixa-preta. Quando você cria um índice comum, o que o SGBD constrói por baixo, em quase todos os casos, é uma B-tree. Ela foi proposta por Rudolf Bayer e Edward McCreight em 1970, projetada deliberadamente para memória externa — ou seja, para minimizar leituras de disco. A ideia central é genial: em vez de uma árvore binária, onde cada nó tem dois filhos e a árvore fica alta, a B-tree é m-ária — cada nó tem até m filhos, onde m é a ordem da árvore. Com nós largos, a árvore fica baixa, e baixa significa poucos acessos a disco.
Uma B-tree de ordem m obedece a regras precisas que você deve memorizar. Cada nó tem no máximo m filhos e no máximo m-1 chaves. Cada nó interno, exceto a raiz, tem ao menos \lceil m/2 \rceil filhos — é a propriedade de ocupação mínima, que garante que a árvore não degenere. A raiz, se não for folha, tem pelo menos dois filhos. Um nó não-folha com k filhos guarda exatamente k-1 chaves, que funcionam como separadores entre as subárvores. E a propriedade decisiva: todas as folhas estão no mesmo nível. É isso que torna a árvore balanceada e garante desempenho previsível.
graph TD
R["50 | 80"]
A["10 | 30"]
B["60 | 70"]
C["85 | 90 | 95"]
R --> A
R --> B
R --> C
A estrutura interna de um nó intercala ponteiros e chaves nesta forma: [P_1, K_1, P_2, K_2, \dots, K_{n-1}, P_n], onde cada chave K_i separa as subárvores apontadas por P_i e P_{i+1}. Tudo à esquerda de K_1 é menor que K_1; tudo entre K_1 e K_2 está nesse intervalo, e assim por diante.
A busca começa na raiz, compara a chave procurada com as chaves do nó, identifica o intervalo correto e desce pelo ponteiro correspondente, repetindo até achar a chave ou chegar à folha. Como a árvore é balanceada e larga, a complexidade é O(\log_m n) — logarítmica na base m, o que na prática significa altura de duas a quatro para milhões de registros.
A inserção localiza a folha apropriada e insere a chave em ordem. Se a folha estourar o limite de m-1 chaves, ela se divide em duas, e a chave do meio sobe para o pai. Se o pai também estourar, a divisão se propaga para cima; se chegar à raiz, cria-se uma nova raiz e a árvore cresce em altura — sempre pelo topo, jamais pelas folhas, o que preserva o balanceamento.
flowchart TD
A[Inserir chave] --> B[Localiza folha correta]
B --> C{Folha tem espaco?}
C -- Sim --> D[Insere em ordem e termina]
C -- Nao --> E[Divide a folha em duas]
E --> F[Promove chave do meio ao pai]
F --> G{Pai ficou cheio?}
G -- Nao --> H[Termina]
G -- Sim --> I[Divide o pai e propaga para cima]
I --> G
A remoção é a operação mais delicada. Se a chave está numa folha que continua ao menos meio cheia após a remoção, basta tirá-la. Se a folha cai abaixo do mínimo, o SGBD tenta redistribuir chaves com um irmão vizinho; se não der, funde a folha com o irmão. Quando a chave está num nó interno, substitui-se ela pelo predecessor ou sucessor vindo das folhas, e remove-se esse de baixo. Se a fusão se propagar até esvaziar a raiz, ela é removida e a árvore encolhe em altura. As complexidades ficam assim:
| Operação | Complexidade | Observação |
|---|---|---|
| Busca | O(\log_m n) | Pior caso, altura da árvore |
| Inserção | O(\log_m n) + divisão | Divisão custa O(m) por nó |
| Remoção | O(\log_m n) + fusão | Fusão e redistribuição custam O(m) |
| Busca por intervalo | O(\log_m n + k) | k é o número de chaves no intervalo |
A variante mais usada em bancos reais é a B+ tree. Nela, todos os dados reais ficam apenas nas folhas, e os nós internos guardam só cópias de chaves como guias de navegação. Além disso, as folhas são encadeadas entre si por ponteiros laterais. O ganho é enorme para buscas por intervalo: você desce até a primeira folha do intervalo e depois percorre a lista ligada de folhas sequencialmente, sem voltar à raiz. Por isso, quando alguém diz “índice B-tree” num SGBD moderno, quase sempre é uma B+ tree por baixo.
Para fixar a B-tree. Largura grande significa árvore baixa significa poucos acessos a disco. Balanceamento automático mantém todas as folhas no mesmo nível. E o trunfo que nenhuma outra estrutura simples oferece: por manter os dados ordenados, a B-tree resolve com elegância buscas por intervalo, ordenação e comparações como maior que ou menor que.
Estruturas de indexação: hashing em profundidade
A segunda grande família é o hashing, e a filosofia é oposta à da árvore. Em vez de navegar por uma hierarquia, você calcula diretamente onde o dado está. No centro está a função hash: ela recebe a chave e devolve o endereço de um bucket (balde) onde o registro deve estar. Uma boa função hash precisa ser determinística — a mesma chave sempre gera o mesmo endereço —, uniforme — espalha as chaves por igual para evitar concentração — e eficiente de calcular.
A estrutura é um array de buckets, cada bucket com slots para registros. Como a posição é calculada e não navegada, uma busca por igualdade exata custa, em média, O(1) — tempo constante, independente do tamanho da tabela. Esse é o grande atrativo do hashing.
graph LR
K1["Chave: 'ana'"] --> H{Funcao hash}
K2["Chave: 'bruno'"] --> H
K3["Chave: 'carla'"] --> H
H --> B0["Bucket 0: bruno"]
H --> B1["Bucket 1: ana, carla"]
H --> B2["Bucket 2: vazio"]
O problema inevitável é a colisão: duas chaves distintas que a função mapeia para o mesmo bucket. Como sempre haverá mais chaves possíveis do que buckets, colisões são uma certeza, não uma exceção, e tratá-las bem é o que define a qualidade da implementação. Há duas grandes estratégias. No encadeamento (hashing aberto), cada bucket mantém uma lista ligada de registros que colidiram ali. No endereçamento aberto (hashing fechado), procura-se outro slot livre na própria tabela: a sondagem linear tenta o próximo slot sequencialmente, a sondagem quadrática salta em intervalos quadráticos para reduzir aglomerações, e o hashing duplo usa uma segunda função hash para definir o passo da sondagem.
| Tratamento de colisão | Como funciona | Risco |
|---|---|---|
| Encadeamento | Lista ligada por bucket | Listas longas degradam para O(n) |
| Sondagem linear | Próximo slot livre | Aglomeração primária |
| Sondagem quadrática | Saltos quadráticos | Aglomeração secundária |
| Hashing duplo | Passo por 2ª função | Mais cálculo por inserção |
Há ainda a distinção entre hashing estático e dinâmico. No hashing estático, o número de buckets é fixo na criação. O drama aparece quando a tabela enche: o fator de carga (razão entre registros e capacidade) sobe, as colisões explodem e o desempenho desaba. A solução é o rehashing — recriar a tabela maior e recalcular o endereço de todos os registros —, uma operação cara. O hashing dinâmico resolve isso permitindo que a estrutura cresça gradualmente. No hashing extensível, um diretório de ponteiros mapeia para buckets, e cada bucket cheio se divide, podendo dobrar o diretório quando necessário. No hashing linear, a tabela cresce de forma incremental: um ponteiro de divisão percorre os buckets e divide um por vez conforme ocorrem estouros, sem reorganizar tudo de uma vez. As complexidades médias e de pior caso:
| Operação | Caso médio | Pior caso |
|---|---|---|
| Busca | O(1) | O(n) |
| Inserção | O(1) | O(n) |
| Remoção | O(1) | O(n) |
O pior caso O(n) acontece quando uma função hash ruim joga quase todas as chaves no mesmo bucket — toda a vantagem evapora. Por isso o hashing é tão sensível à qualidade da função.
B-tree versus Hash: a comparação que cai em prova
Agora a comparação direta, que é onde o examinador adora pegar o aluno desatento. Memorize a regra de ouro: Hash brilha na igualdade exata, B-tree brilha em intervalos e ordenação. O motivo é estrutural. O hash calcula um endereço para um valor específico — perfeito para WHERE id = 42. Mas ele não preserva ordem alguma: valores próximos vão para buckets espalhados, então uma consulta WHERE idade BETWEEN 20 AND 30 obrigaria a varrer a tabela inteira. A B-tree, por manter tudo ordenado, encontra o início do intervalo e percorre as folhas em sequência.
| Critério | B-tree | Hash |
|---|---|---|
| Busca por igualdade | O(\log n), ótimo | O(1) médio, superior |
| Busca por intervalo | Excelente | Inviável, requer varredura |
Ordenação e ORDER BY |
Natural, dados ordenados | Não suporta |
| Mantém ordem dos dados | Sim | Não |
| Sensibilidade à distribuição | Baixa | Alta, depende da função |
| Acesso a disco | Localizado, sequencial | Aleatório |
| Flexibilidade de tamanho | Cresce naturalmente | Estático exige rehashing |
| Concorrência | Bloqueios em níveis altos | Granularidade fina por bucket |
Em termos de ambiente, o hash tende a vencer em memória principal e quando o conjunto cabe inteiro na RAM, além de se adaptar bem a sistemas distribuídos via particionamento. A B-tree domina em armazenamento secundário, onde sua estrutura hierárquica minimiza acessos a disco, e em qualquer cenário que misture leituras, escritas e necessidade de ordem.
flowchart TD
A[Que tipo de consulta predomina?] --> B{Igualdade exata apenas?}
B -- Sim --> C{Dados cabem em memoria?}
C -- Sim --> D[Use indice Hash]
C -- Nao --> E[B-tree ainda compete bem]
B -- Nao --> F{Precisa de intervalo ou ordem?}
F -- Sim --> G[Use B-tree]
F -- Nao --> G
Na prática, os SGBDs implementam as duas e deixam você escolher. No PostgreSQL, por exemplo, o índice padrão é B-tree, mas você pode pedir explicitamente um hash quando só fará buscas por igualdade:
Síntese para a prova. Índice é estrutura auxiliar que troca espaço e custo de escrita por velocidade de leitura — indexe colunas de WHERE, JOIN e ORDER BY, sem excesso. Índice clusterizado ordena fisicamente os dados e há só um por tabela; não clusterizados são vários e usam ponteiros. O otimizador é baseado em custo e depende de estatísticas atualizadas; EXPLAIN revela o plano, e ver Seq Scan numa busca seletiva é sinal de problema. Funções sobre a coluna no WHERE cegam o índice. A B-tree é uma árvore m-ária balanceada, com folhas no mesmo nível, busca em O(\log_m n), mantém dados ordenados e por isso resolve intervalos; a B+ tree põe os dados nas folhas encadeadas. O Hash calcula o endereço pela função hash, busca em O(1) médio mas O(n) no pior caso, trata colisões por encadeamento ou endereçamento aberto, e não preserva ordem. A regra final: Hash para igualdade exata, B-tree para intervalos, ordenação e o uso geral.