Tecnologias/02 - Bancos de Dados/01 - Relacionais/02 - MySQL7 min
Índices e o Otimizador
Conceito
O otimizador do MySQL é mais simples que o do Postgres —
historicamente muito mais. O 8.0 fechou grande parte dessa distância (hash join,
histogramas, CTE, window functions, EXPLAIN ANALYZE), mas o modelo mental continua
diferente: no MySQL, você projeta o índice para a query; no Postgres, você confia mais no
planejador.
O que é um índice bom aqui: a regra de ouro
Todo índice do InnoDB é uma árvore B+ ordenada. Uma consulta só usa o índice enquanto ela consegue percorrer o prefixo da ordenação. Daí a regra prática, na ordem:
1. colunas de IGUALDADE (=, IN)
2. coluna de ORDENAÇÃO (ORDER BY / GROUP BY)
3. coluna de FAIXA (>, <, BETWEEN, LIKE 'x%')
4. colunas só para COBRIR (SELECT), se valer a pena
A regra crítica é a 4: assim que o índice encontra uma faixa, ele para de poder usar as colunas seguintes para filtro ou para ordenação.
-- índice (status, criado_em)
WHERE status = 'pago' ORDER BY criado_em DESC -- ✅ igualdade + ordem: perfeito
WHERE status = 'pago' AND criado_em > '2026-01-01'-- ✅ igualdade + faixa: perfeito
WHERE criado_em > '2026-01-01' AND status = 'pago'-- ✅ mesma coisa (a ordem no WHERE não importa)
-- índice (criado_em, status) ← ordem invertida
WHERE criado_em > '2026-01-01' AND status = 'pago'-- ⚠️ a faixa vem primeiro:
-- `status` NÃO filtra no índice,
-- só descarta depois (ICP)
Covering index: eliminar o segundo percurso
Como o índice secundário guarda a PK (2. InnoDB por Dentro), buscar colunas extras custa um percurso adicional na árvore da PK. Se todas as colunas da query estiverem no índice, esse percurso desaparece:
-- 2 percursos: idx → PK → linha
CREATE INDEX idx1 ON pedidos (cliente_id);
SELECT total, status FROM pedidos WHERE cliente_id = 42;
-- 1 percurso: "Using index" no EXPLAIN
CREATE INDEX idx2 ON pedidos (cliente_id, total, status);
SELECT total, status FROM pedidos WHERE cliente_id = 42;
Using index no Extra do EXPLAIN é o selo de covering index. É o ganho mais fácil de
obter em MySQL — e o MySQL não tem INCLUDE como o Postgres, então as colunas de cobertura
entram na própria chave.
As otimizações que o 8.0+ trouxe
| Recurso | O que faz |
|---|---|
| Index Condition Pushdown (ICP) | empurra parte do WHERE para o nível do índice, reduzindo acessos à PK |
| MRR (Multi-Range Read) | ordena os acessos à PK por posição física → I/O menos aleatório |
| Hash join | substitui block nested loop em joins sem índice: ganho de ordens de magnitude |
| Histogramas | estatística de distribuição de coluna sem índice (ANALYZE TABLE ... UPDATE HISTOGRAM) |
| Índice invisível | ALTER TABLE ... ALTER INDEX x INVISIBLE: testa o impacto de remover antes de remover |
| Índice descendente | INDEX (a ASC, b DESC) de verdade (antes o DESC era ignorado) |
| Índice funcional | INDEX ((CAST(j->>'$.cpf' AS CHAR(11)))) — indexa expressão, inclusive JSON |
EXPLAIN ANALYZE |
execução real com tempo por operador (8.0.18+) |
Ler EXPLAIN
EXPLAIN SELECT ... ;
As colunas que importam, em ordem:
| Coluna | O que olhar |
|---|---|
type |
const > eq_ref > ref > range > index > ALL (varredura: quase sempre ruim) |
key |
qual índice foi escolhido (NULL = nenhum) |
rows |
estimativa de linhas examinadas |
filtered |
% que sobra após o WHERE — rows × filtered é a estimativa final |
Extra |
a coluna mais informativa |
Valores de Extra que você precisa reconhecer:
Using index → covering index. Ótimo.
Using index condition → ICP ativo. Bom.
Using where → filtro aplicado após ler a linha. Aceitável.
Using filesort → ordenação fora do índice. Ruim em volume.
Using temporary → tabela temporária (GROUP BY/DISTINCT). Ruim em volume.
Using join buffer (hash join) → join sem índice; ok em analítico, ruim em OLTP.
Trade-offs
Índice composto resolve mais que vários índices simples. O MySQL raramente combina
dois índices (existe index merge, mas é limitado e frequentemente pior). Um índice
(a, b, c) serve a, (a,b) e (a,b,c) — três índices separados servem cada um só a
si. Menos índices, mais bem ordenados.
Covering index engorda o índice. Cada coluna de cobertura é copiada. Um
(cliente_id, total, status, criado_em, payload) pode ficar quase do tamanho da tabela —
aí você duplicou os dados e o custo de escrita.
ORDER BY ... LIMIT é onde o otimizador mais erra. Ele pode escolher o índice da
ordenação (para evitar filesort) e varrer meio índice porque o filtro elimina quase
tudo. O sintoma é uma query que fica mais lenta com LIMIT menor. Remédios:
FORCE INDEX, reescrever com subquery, ou índice que atenda filtro e ordem juntos.
Histograma serve onde índice não cabe. Coluna de baixa cardinalidade (status,
tipo) não merece índice, mas o otimizador precisa saber que 98% das linhas são
'concluido'. Histograma resolve isso de graça na escrita.
O trade-off central: o otimizador do MySQL premia quem projeta índice para a query. Isso dá mais controle e mais trabalho: quase todo problema de performance no MySQL se resolve com o índice composto correto — e quase todo excesso de índice se paga na escrita e na perda de concorrência (3. Redo, Undo e Locks).
Exemplo prático
Do problema ao índice, passo a passo
-- A query da tela
SELECT id, total, status FROM pedidos
WHERE cliente_id = 4711 AND status IN ('pago','enviado')
ORDER BY criado_em DESC LIMIT 20;
EXPLAIN ANALYZE SELECT ... ;
-- -> Limit: 20 row(s) (actual time=812..812 rows=20 loops=1)
-- -> Sort: pedidos.criado_em DESC (actual time=812..812 rows=20)
-- -> Filter: (pedidos.status in ('pago','enviado')) (actual rows=38412)
-- -> Index lookup on pedidos using idx_cliente (cliente_id=4711)
-- (actual time=0.2..640 rows=41277)
Diagnóstico linha por linha: leu 41 mil linhas pelo índice de cliente, descartou 3 mil no filtro, ordenou 38 mil em memória e devolveu 20. O trabalho útil é 20 linhas.
-- O índice que atende igualdade + ordem + cobertura
CREATE INDEX idx_cli_status_criado ON pedidos (cliente_id, status, criado_em, total);
-- -> Limit: 20 row(s) (actual time=0.05..0.09 rows=20 loops=1)
-- -> Index lookup on pedidos using idx_cli_status_criado
-- (cliente_id=4711, status in ('pago','enviado')) ← Using index
-- (actual rows=20)
De 812 ms para 0,09 ms — sem Sort, sem Filter, sem acesso à PK. O ganho não veio de
hardware nem de parâmetro: veio de colocar as colunas na ordem igualdade → ordenação →
cobertura.
Higiene e diagnóstico
-- índices redundantes e não usados (sys schema faz o trabalho)
SELECT * FROM sys.schema_redundant_indexes;
SELECT * FROM sys.schema_unused_indexes;
-- as queries mais custosas (equivalente ao pg_stat_statements)
SELECT digest_text, count_star,
ROUND(sum_timer_wait/1e12, 1) AS total_s,
ROUND(avg_timer_wait/1e9, 2) AS media_ms,
sum_rows_examined / NULLIF(sum_rows_sent, 0) AS examinadas_por_retornada
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC LIMIT 15;
-- histograma para coluna de baixa cardinalidade sem índice
ANALYZE TABLE pedidos UPDATE HISTOGRAM ON status WITH 16 BUCKETS;
-- testar remoção de índice sem remover
ALTER TABLE pedidos ALTER INDEX idx_antigo INVISIBLE;
sum_rows_examined / sum_rows_sent — quantas linhas o banco leu para cada linha que
devolveu. Perto de 1 é ótimo. Acima de 100 significa que a query varre e descarta: é
exatamente onde um índice composto vai render.
Como isso falha em produção
| Sintoma | Causa |
|---|---|
type: ALL numa tabela grande |
nenhum índice utilizável |
Using filesort com volume |
ordenação não coberta pelo índice |
Query mais lenta com LIMIT menor |
otimizador escolheu o índice da ordem e varre até completar o limite |
| Índice ignorado | tipo/collation divergente no JOIN, função na coluna, OR entre colunas diferentes |
%termo% sempre varrendo |
B-tree só serve prefixo; use FULLTEXT ou motor de busca |
Estimativa rows muito errada |
falta ANALYZE TABLE; falta histograma |
| Índice novo não usou | index dive limitado por eq_range_index_dive_limit, ou estatística desatualizada |
Aprofundamento aplicado
Índice composto e ordem das perguntas
Para WHERE cliente_id = ? AND criado_em >= ? ORDER BY criado_em, o índice
(cliente_id, criado_em) aproxima os pedidos do mesmo cliente e ordena o intervalo.
Acrescentar total pode cobrir uma projeção, mas aumenta armazenamento e custo das escritas.
Um filtro por função, conversão implícita ou collation incompatível pode impedir o caminho esperado. Antes de acrescentar outro índice, verifique os tipos dos parâmetros que o driver envia e a expressão realmente executada.
Experimento: compare a consulta que retorna id, criado_em com uma que retorna
um campo grande não coberto. Resultado esperado: reconheça quando foi necessário
buscar a linha no índice clusterizado, além de percorrer o secundário.
Não use um número fixo de linhas como regra universal para escolher índice. Seletividade, distribuição, cache e custo de retornar os dados mudam a decisão. A hipótese precisa ser testada com dados que representem clientes pequenos e grandes.
Referência: índices de múltiplas colunas.
Relacionado
- 2. InnoDB por Dentro
- 3. Redo, Undo e Locks
- 6. DDL Online e Operação
- 5. Índices e o Planejador
- 5. Índices
- SQL Tuning
- 7. Problema N+1
Parte de MOC MySQL · Optimization (docs)