Dia 7 — Desempenho de consulta
EXPLAIN: ver o plano da consulta
EXPLAIN responde o que o banco pretende fazer
A consulta lenta é o sintoma, e ninguém sabe por quê. EXPLAIN é a pergunta que transforma palpite em plano de execução: mostra como o MySQL pretende executar a consulta, não o que ele executou. É o primeiro passo para otimizar consulta sem reescrever nada no escuro.
EXPLAIN SELECT id FROM tb_consulta_demo WHERE id = 7;
Ele devolve uma linha por tabela envolvida. SELECT, UPDATE, INSERT e DELETE respondem à mesma pergunta com a mesma estrutura, e por isso o mesmo código serve para medir os quatro.
O plano não é o resultado
EXPLAIN não toca nos dados. É um plano, do jeito que uma rota de ônibus é um plano: existe antes de o ônibus partir, e publicar o plano não faz o ônibus chegar.
O exemplo prova isso no primeiro bloco: faz EXPLAIN DELETE numa tabela com a linha id = 7, imprime o plano, e conta a linha outra vez. Ela continua lá.
linhas com id = 7 antes: 1 devolveu um plano: tb_consulta_demo: range rows=1 key=PRIMARY linhas com id = 7 depois: 1 — o DELETE nao aconteceu, o banco so mostrou o que faria
Duas consequências práticas. Primeiro, dá para rodar EXPLAIN em produção sem medo, inclusive sobre DELETE e UPDATE. Segundo, o que aparece na linha não é o que a consulta devolveu: rows não é contagem de resultado, é estimativa de leitura.
As três colunas que decidem
O EXPLAIN devolve colunas demais para ler de uma vez. Três respondem quase toda pergunta de desempenho:
| Coluna | Pergunta que ela responde |
|---|---|
type | como o banco vai chegar na linha |
key | qual índice ele escolheu |
rows | quantas linhas ele acha que vai ler |
E possible_keys, que é a quarta e a mais importante na prática, porque é ela que separa "não existe índice" de "existe índice e não foi usado".
type: ALL, const, range, ref
type é o veredito. Ele vai do pior ao melhor, e a ordem importa mais que o nome:
type | O que o plano faz |
|---|---|
ALL | lê a tabela inteira, linha por linha |
index | lê o índice inteiro, sem filtrar |
range | lê um intervalo e para no fim dele |
ref | acha as linhas por um valor não-único e compara o resto |
const | achou a linha pela chave única, no máximo uma |
ALL é o que o material chama de varredura completa, ou full scan em inglês. Não é defeito por si só: numa tabela de cinco linhas é a melhor escolha que existe, e o plano de SELECT * FROM tb_consulta_demo sai com type = ALL justamente porque ler tudo é o que foi pedido.
O caminho de ALL até const é o que a aula inteira persegue: usar índice em vez de varrer a tabela inteira. É exatamente isso que o CREATE INDEX da aula 2 faz, e o que muda o plano sem mudar a consulta.
possible_keys e key: o sinal
possible_keys é a lista de índices que o otimizador poderia usar. key é o índice que ele realmente usou. A diferença entre os dois é o diagnóstico.
| O que se vê | O que significa |
|---|---|
possible_keys vazio, key vazio | não existe índice para esta coluna |
possible_keys cheio, key vazio | o índice existe e foi recusado |
possible_keys cheio, key preenchido | o index foi usado |
O segundo caso é o que vale a aula: é o sinal de um índice que existe e não está sendo usado. O mesmo SELECT, na mesma coluna, em três tabelas com o mesmo dado:
indice completo | possible_keys=idx_tb_consulta_ordem_nm_cliente | key=idx_tb_consulta_ordem_nm_cliente | rows=1 so prefixo | possible_keys=idx_tb_consulta_prefixo_nm_cliente | key=null | rows=600 sem indice | possible_keys=null | key=null | rows=600
A segunda linha é o sinal: o servidor ofereceu o índice e escolheu não usar. A terceira é o caso fácil — não há índice, e a solução é criar um. A segunda exige resposta: por que recusaram um índice que existe?
rows é estimativa, e a estimativa envelhece
rows sai do catálogo, não de uma contagem. O servidor guarda estatísticas da tabela e usa esses números para escolher o plano — e essas estatísticas envelhecem quando ninguém recalcula.
o plano estimou rows=31 a consulta de verdade devolveu 31 linhas
Aqui bateram. Com volume grande e dado mudando todo dia eles divergem, e o plano pode estar errado por causa da estimativa, não da consulta. ANALYZE TABLE recalcula as estatísticas — e é por isso que o exemplo roda logo depois de popular o dado.
O próprio exemplo demonstra o quanto a estimativa é aproximada: o mesmo EXPLAIN, na mesma tabela, devolveu rows=19644 numa execução e rows=20169 na outra. O InnoDB não conta as linhas todas — ele amostra páginas e multiplica. Em tabela de 600 linhas a diferença é ruído; em tabela de milhões, é a diferença entre um plano bom e um plano que varre tudo.
Um plano escolhido sobre uma estimativa errada é o pior tipo de plano ruim: o SQL está certo, o índice existe, e mesmo assim a consulta varre a tabela. Antes de reescrever a consulta, rode
ANALYZE TABLE.
EXPLAIN ANALYZE e FORMAT=JSON
EXPLAIN ANALYZE executa a consulta e devolve o tempo real de cada passo. Ele é a resposta para "o plano bom está lento de verdade?" — e ele não existe em toda versão do servidor. Onde falta, a resposta é ER_PARSE_ERROR, e é isso que o exemplo imprime:
tentando EXPLAIN ANALYZE (executa a consulta de verdade): erro: ER_PARSE_ERROR - You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'ANALYZE SELECT id FROM tb_consulta_demo WHERE id = 7' at line 1 sqlState: 42000 `EXPLAIN ANALYZE` nao existe nesta versao do servidor.
O erro.code é o que o Node compara; erro.message muda entre versões e não serve para decidir nada. E note o que o erro não é: não é ER_UNKNOWN_COMMAND, é recusa de gramática — o comando chegou ao servidor e foi lido.
EXPLAIN FORMAT=JSON traz o mesmo plano com outro formato: access_type no lugar de type, e a árvore inteira aninhada num texto. Serve para quando o plano tem várias tabelas e a tabela de resultado não mostra a ordem em que o banco vai ler.
Por que ler coluna pelo nome fixo quebra
O nome das colunas do plano varia com a versão do servidor. O exemplo imprime as que encontrou em vez de afirmar quais são:
colunas que este banco devolveu no EXPLAIN: id, select_type, table, type, possible_keys, key, key_len, ref, rows, Extra
Um painel de administração que faz linha.type quebra em outra máquina. SELECT * com Object.keys() é o que resolve.
A ordem das colunas no índice decide o resto
Um índice composto é uma árvore ordenada pela primeira coluna e, dentro dela, pela segunda. O exemplo mede os três casos numa tabela com idx_tb_consulta_ordem_status_data (cd_status, dt_cadastro):
as duas colunas | possible_keys=idx_tb_consulta_ordem_status_data | key=idx_tb_consulta_ordem_status_data | rows=1 so a primeira | possible_keys=idx_tb_consulta_ordem_status_data | key=idx_tb_consulta_ordem_status_data | rows=200 so a segunda | possible_keys=null | key=idx_tb_consulta_ordem_status_data | rows=600
O idx_tb_consulta_ordem_status_data serve para "pago de 2026-03-04" e para "pago". Não serve para achar "2026-03-04" sozinho: essa data aparece em três lugares da árvore, um dentro de cada cd_status, e o banco teria de varrer os três. Não é defeito do índice — é a ordem das colunas no índice que decide o que ele serve.
type como dicionário, não como teoria
Cada valor de type tem uma consulta que o produz. O exemplo roda os casos e imprime o resultado lado a lado:
const | rows= 1 | key=PRIMARY | demo WHERE id = 7 range | rows= 31 | key=PRIMARY | demo WHERE id BETWEEN 10 AND 40 ALL | rows= 600 | key=null | demo index | rows= 600 | key=idx_tb_consulta_ordem_status_data | ordem WHERE dt_cadastro = '2026-03-04' ref | rows= 1 | key=idx_tb_consulta_ordem_nm_cliente | ordem WHERE nm_cliente = 'cliente 0007' ref | rows= 200 | key=idx_tb_consulta_ordem_status_data | ordem WHERE cd_status = 'pago'
Três leituras que esse bloco resolve de uma vez.
ALL e index leem o conjunto inteiro. A diferença é o quê: ALL lê a tabela, index lê o índice. Quando as colunas pedidas estão todas dentro do índice, ler o índice é mais barato — e Extra ganha Using index. Quando sobra a tabela inteira mesmo assim, o índice é trabalho de graça.
ref com rows=200 é um índice que quase não filtra. As duas últimas linhas usam índice e as duas estão ref, mas uma lê 1 linha e a outra lê 200. A diferença é a coluna: nm_cliente tem 600 valores distintos, cd_status tem 3. Índice em coluna de pouquíssimos valores existe, funciona, e não ajuda.
O mesmo SQL em tabelas diferentes dá planos diferentes. As três últimas linhas do exemplo repetem WHERE nm_cliente = 'cliente 0007' em três tabelas com o mesmo dado, e o plano muda de ref para ALL conforme o índice existe, existe e é inútil, ou não existe. A diferença não está no SQL — está no esquema.
EXPLAINnão diz quanto tempo a consulta leva.rowsé estimativa, e dois planos com o mesmorowspodem ter tempos muito diferentes. O material mostra plano e estimativa, e deixa o tempo real para a saída embutida, que sai da máquina de quem roda: um número de tempo escrito no texto seria mentira em qualquer outra máquina.
EXPLAINtambém não enxerga o que o Node faz antes e depois da consulta, nem o tráfego entre a aplicação e o banco. Uma consulta rápida de plano bonito pode ser lenta de verdade porque o processo sobe a cada requisição.
Ler o plano antes de criar o índice é o atalho que mais engana:
EXPLAINde umWHEREsobre coluna sem índice dáALL, e o plano melhora quando o índice entra. Mas o índice também tem que manter-se — a aula 2 mede o custo de escrita e mostra quando ele não vale.
Exemplo
'use strict'; // Exemplo da aula 1 do dia 7: `EXPLAIN`, o plano de execucao. // // A tese da aula: `EXPLAIN` mostra o que o MySQL PRETENDE fazer, e nao o que // ele fez. Quem nunca leu essa linha le e acha que e um relatorio de execucao — // e conclui que `rows: 4` quer dizer "quatro linhas voltaram". // // Por isso o exemplo comeca provando que `EXPLAIN` nao executa nada: ele faz // `EXPLAIN DELETE` numa tabela real e mostra que a linha continua la depois. // Daí em diante sao tres colunas que decidem se a consulta vai ser lenta: // // possible_keys o indice que o otimizador PODERIA usar // key o indice que ele REALMENTE escolheu (null = nenhum) // rows quantas linhas ele estima ler // // E o sinal que vale a aula: `possible_keys` preenchido com `key` vazio e o // indice existe e NAO esta sendo usado. O exemplo produz esse caso de // proposito, numa tabela cujo unico indice sobre `nm_cliente` e um indice de // PREFIXO — e o caso em que o servidor ate lista o indice e ainda assim // desiste dele, que e o sinal mais enganoso que existe. // // POR QUE ESTE EXEMPLO DERRUBA E RECRIA A TABELA // A aula precisa comparar tres esquemas que tem o MESMO dado: um sem indice, // um com indice, um so com indice de prefixo. `CREATE TABLE IF NOT EXISTS` nao // serve aqui, porque ela nao mexe em tabela que ja existe: um indice que ficou // de outra aula continua la, e o exemplo passa a afirmar "sem indice" sobre uma // tabela que tem um. Por isso o `DROP TABLE IF EXISTS` seguido de `CREATE TABLE`: // e a unica forma de garantir que o esquema e exatamente o que a aula ensina. // Repete todo o esquema a cada execucao e devolve sempre o mesmo resultado. // // A conexao vem do harness do material (`await conexao`): este exemplo nao // segura servidor nenhum, e quem fecha o socket e o proprio harness. // // A versao do banco NUNCA e escrita aqui. O exemplo pergunta com // `SELECT VERSION()` e imprime o que a maquina que rodou respondeu — pode ser // outra na maquina de quem le, e e por isso que o mesmo codigo cuida do caso // em que o comando nao existe: `EXPLAIN ANALYZE` chegou em uma versao // especifica do MySQL e responde `ER_PARSE_ERROR` onde nao ha. // // ============================================================== 1. os dados // 600 linhas, geradas no codigo: e o mesmo conjunto toda vez que o exemplo // roda, entao o plano medido e sempre o mesmo plano. // // O desenho do dado e o que faz a aula ter caso: `nm_cliente` tem 600 valores // distintos (alta cardinalidade), `cd_status` tem 3 (baixa), e as datas se // espalham por dois anos. A mesma coluna consultada de duas formas produz // planos completamente diferentes, e a diferenca esta no dado, nao na consulta. const TOTAL_LINHAS = 600; const STATUS = ['aberto', 'pago', 'cancelado']; function valoresDeDemonstracao(total) { const linhas = []; for (let i = 1; i <= total; i++) { const ano = i % 2 === 0 ? 2025 : 2026; const mes = String(1 + (i % 12)).padStart(2, '0'); const dia = String(1 + (i % 28)).padStart(2, '0'); const status = STATUS[i % STATUS.length]; const nome = 'cliente ' + String(i).padStart(4, '0'); linhas.push('(' + i + ", '" + nome + "', '" + status + "', '" + ano + '-' + mes + '-' + dia + "', " + (100 + i) + '.50)'); } return linhas.join(', '); } // `TRUNCATE` no COMECO e nao no fim: e ele que zera o `AUTO_INCREMENT`, e sem // isso o `id` das linhas impressas cresceria a cada execucao e a saida embutida // na pagina mudaria entre uma rodada e outra. async function criaTabelaVazia(conexao, tabela) { await conexao.query('TRUNCATE TABLE ' + tabela); await conexao.query( 'INSERT INTO ' + tabela + ' (id, nm_cliente, cd_status, dt_cadastro, vl_valor) VALUES ' + valoresDeDemonstracao(TOTAL_LINHAS)); // `ANALYZE TABLE` atualiza as estatisticas que o otimizador usa para // escolher o plano. Sem ele o `rows` sai de uma contagem antiga, e e o // primeiro numero que mente quando alguem mede uma tabela recem-populada. await conexao.query('ANALYZE TABLE ' + tabela); } // ============================================================ 2. o leitor // `EXPLAIN` devolve uma linha por tabela envolvida, com as colunas do plano. // Ele responde como um SELECT comum: e por isso que um painel de administracao // mostra o plano sem o usuario precisar abrir o cliente de SQL. async function planoDe(conexao, sql, parametros) { const [linhas] = await conexao.query('EXPLAIN ' + sql, parametros || []); return linhas; } // O mesmo plano em uma linha de texto. E a funcao que um painel chamaria para // mostrar o plano na tela, e ela funciona igual para `SELECT`, `UPDATE`, // `INSERT` e `DELETE` — porque todos devolvem a mesma estrutura. function comoTexto(plano) { return plano.map((l) => `${l.table}: ${l.type} rows=${l.rows}` + (l.key ? ` key=${l.key}` : ' key=null')).join(' | '); } // As tres colunas do bloco 3, alinhadas para o olho comparar. E o formato que // um painel de consulta lenta mostra: o nome, o indice que podia servir, o // indice que serviu, e quantas linhas o servidor espera ler. function comoDiagnostico(nome, plano) { const l = plano[0]; return ' ' + nome + ' | possible_keys=' + String(l.possible_keys === null ? 'null' : l.possible_keys).padEnd(30) + ' | key=' + String(l.key === null ? 'null' : l.key).padEnd(30) + ' | rows=' + l.rows; } async function imprimePlano(conexao, rotulo, sql, parametros) { const linhas = await planoDe(conexao, sql, parametros); console.log(rotulo); console.log(' ' + sql.replace(/\s+/g, ' ').trim()); for (const l of linhas) { console.log(' tabela=' + l.table + ' | type=' + l.type + ' | possible_keys=' + (l.possible_keys === null ? 'null' : l.possible_keys) + ' | key=' + (l.key === null ? 'null' : l.key) + ' | rows=' + l.rows + ' | Extra=' + (l.Extra === '' ? '(vazio)' : l.Extra)); } return linhas; } async function main() { // A versao e capturada, nunca afirmada. Quem le pode ter outra. const [versao] = await conexao.query('SELECT VERSION() AS versao'); console.log('--- 0. quem responde ---'); console.log('banco em uso:', versao[0].versao); console.log('a versao e lida do servidor de proposito: a da maquina de quem le'); console.log('pode ser outra, e o exemplo precisa funcionar nas duas.'); // Tres tabelas com o MESMO dado e esquemas diferentes, que e o que permite // comparar o plano sem trocar nada na consulta: // // tb_consulta_demo nenhum indice secundario (fora a chave primaria) // tb_consulta_ordem indice composto e indice simples // tb_consulta_prefixo SO indice de PREFIXO sobre nm_cliente for (const tabela of ['tb_consulta_demo', 'tb_consulta_ordem', 'tb_consulta_prefixo']) { await conexao.query('DROP TABLE IF EXISTS ' + tabela); } await conexao.query(` CREATE TABLE tb_consulta_demo ( id INT AUTO_INCREMENT PRIMARY KEY, nm_cliente VARCHAR(40) NOT NULL, cd_status VARCHAR(10) NOT NULL, dt_cadastro DATE NOT NULL, vl_valor DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await conexao.query(` CREATE TABLE tb_consulta_ordem ( id INT AUTO_INCREMENT PRIMARY KEY, nm_cliente VARCHAR(40) NOT NULL, cd_status VARCHAR(10) NOT NULL, dt_cadastro DATE NOT NULL, vl_valor DECIMAL(10,2) NOT NULL, KEY idx_tb_consulta_ordem_status_data (cd_status, dt_cadastro), KEY idx_tb_consulta_ordem_nm_cliente (nm_cliente) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await conexao.query(` CREATE TABLE tb_consulta_prefixo ( id INT AUTO_INCREMENT PRIMARY KEY, nm_cliente VARCHAR(40) NOT NULL, cd_status VARCHAR(10) NOT NULL, dt_cadastro DATE NOT NULL, vl_valor DECIMAL(10,2) NOT NULL, KEY idx_tb_consulta_prefixo_nm_cliente (nm_cliente(4)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); for (const tabela of ['tb_consulta_demo', 'tb_consulta_ordem', 'tb_consulta_prefixo']) { await criaTabelaVazia(conexao, tabela); } const [contagem] = await conexao.query( 'SELECT COUNT(*) AS total FROM tb_consulta_demo'); console.log('linhas em cada tabela:', contagem[0].total); console.log('nm_cliente: ' + TOTAL_LINHAS + ' valores distintos | cd_status: ' + STATUS.length + ' valores | datas em dois anos'); console.log('mesmo dado, tres esquemas: sem indice, com indice, com indice de prefixo'); // ================================================ EXPLAIN nao executa console.log('\n--- 1. EXPLAIN mostra o plano, nao o resultado ---'); const [antes] = await conexao.query( 'SELECT COUNT(*) AS n FROM tb_consulta_demo WHERE id = 7'); console.log('linhas com id = 7 antes:', antes[0].n); const planoDelete = await planoDe(conexao, 'DELETE FROM tb_consulta_demo WHERE id = 7'); console.log('EXPLAIN DELETE FROM tb_consulta_demo WHERE id = 7'); console.log(' devolveu um plano:', comoTexto(planoDelete)); const [depois] = await conexao.query( 'SELECT COUNT(*) AS n FROM tb_consulta_demo WHERE id = 7'); console.log('linhas com id = 7 depois:', depois[0].n, '— o DELETE nao aconteceu, o banco so mostrou o que faria'); // As colunas do plano sao lidas em Node como as de qualquer SELECT. O nome // delas varia com a versao do servidor, e por isso que o exemplo imprime as // que encontrou em vez de afirmar quais sao. const primeiroPlano = await planoDe(conexao, 'SELECT id FROM tb_consulta_demo'); console.log('colunas que este banco devolveu no EXPLAIN: ' + Object.keys(primeiroPlano[0]).join(', ')); // ==================================== 2. a mesma tabela, tres planos console.log('\n--- 2. a mesma tabela, sem filtro, com chave e com intervalo ---'); const semFiltro = await imprimePlano(conexao, 'a) sem WHERE, todas as colunas:', 'SELECT * FROM tb_consulta_demo'); const comChave = await imprimePlano(conexao, 'b) WHERE na chave primaria:', 'SELECT id FROM tb_consulta_demo WHERE id = 7'); const comIntervalo = await imprimePlano(conexao, 'c) WHERE em intervalo na chave primaria:', 'SELECT id FROM tb_consulta_demo WHERE id BETWEEN 10 AND 40'); console.log(''); console.log('sem filtro -> ' + comoTexto(semFiltro) + ' (tabela inteira)'); console.log('com filtro -> ' + comoTexto(comChave) + ' (le a chave e para)'); console.log('com intervalo -> ' + comoTexto(comIntervalo) + ' (le a faixa e para)'); console.log(''); console.log('os tres leem a MESMA tabela. O que muda e o type e o rows:'); console.log(' ALL o plano le a tabela inteira, linha por linha (varredura'); console.log(' completa, ou full scan em ingles)'); console.log(' const a chave unica foi achada direto, no maximo uma linha'); console.log(' range o plano le um intervalo da chave, e para no fim dele'); // Um JOIN produz uma linha por tabela, na ordem em que o banco vai ler. const planoJoin = await imprimePlano(conexao, 'd) JOIN: uma linha do plano por tabela:', 'SELECT a.id FROM tb_consulta_demo a ' + 'JOIN tb_consulta_demo b ON b.id = a.id WHERE a.id = 7'); console.log(''); console.log('o plano tem ' + planoJoin.length + ' linhas: uma por tabela do JOIN.'); console.log('Aqui as duas linhas ficaram `const`, e nao `eq_ref`: com chave'); console.log('primaria o lado de dentro e resolvido por busca direta, que ja e o'); console.log('caso mais barato que existe. O nome `eq_ref` aparece quando o lado'); console.log('de dentro e resolvido por um indice unico que NAO e a chave'); console.log('primaria. O que importa aqui e a forma: uma linha por tabela, na'); console.log('ordem em que o banco vai ler.'); // ==================================== 3. o sinal: existe e nao foi usado // Este e o bloco que separa a aula da anterior. `possible_keys` preenchido // com `key` vazio significa: o indice existe, serve para a coluna, e o // otimizador decidiu que nao vale a pena. As duas informacoes juntas sao o // diagnostico; olhar so uma delas nao diz nada. console.log('\n--- 3. os tres sinais de indice, lado a lado ---'); const comIndice = await planoDe(conexao, "SELECT id FROM tb_consulta_ordem WHERE nm_cliente = 'cliente 0007'"); const comPrefixo = await planoDe(conexao, "SELECT id FROM tb_consulta_prefixo WHERE nm_cliente = 'cliente 0007'"); const semNada = await planoDe(conexao, "SELECT id FROM tb_consulta_demo WHERE nm_cliente = 'cliente 0007'"); console.log('a mesma busca, em tres tabelas com o mesmo dado:'); console.log(comoDiagnostico('indice completo', comIndice)); console.log(comoDiagnostico('so prefixo ', comPrefixo)); console.log(comoDiagnostico('sem indice ', semNada)); console.log(''); console.log('Sinal 1 — possible_keys vazio, key vazio:'); console.log(' Nao existe indice para esta coluna. O plano e ALL e nao ha o que'); console.log(' investigar: a solucao e criar o indice.'); console.log(''); console.log('Sinal 2 — possible_keys preenchido, key vazio:'); console.log(' O indice existe, o servidor ate ofereceu, e ele NAO foi usado.'); console.log(' E o caso da tabela de prefixo, e ele e real, nao contrivedado.'); console.log(' O motivo esta no `KEY idx_tb_consulta_prefixo_nm_cliente'); console.log(' (nm_cliente(4))`: o indice guarda so os 4 primeiros caracteres.'); console.log(' Uma busca por igualdade no valor inteiro nao cabe em uma chave'); console.log(' de prefixo, entao o indice nao separa nada — e ler a tabela'); console.log(' inteira sai mais barato do que consultar o indice inteiro.'); console.log(' Este sinal e o que vale a aula: o indice existe e foi recusado,'); console.log(' e a pergunta passa a ser por que.'); console.log(''); console.log('Sinal 3 — key preenchido e rows do tamanho da tabela:'); console.log(' O indice foi usado e nao filter nada. O bloco 6 mostra esse caso.'); // ==================================== 4. rows e estimativa, e nao contagem console.log('\n--- 4. rows e o que o servidor ACOGHA que vai ler ---'); const planoEstimado = await planoDe(conexao, 'SELECT id FROM tb_consulta_demo WHERE id BETWEEN 10 AND 40'); const [reais] = await conexao.query( 'SELECT COUNT(*) AS n FROM tb_consulta_demo WHERE id BETWEEN 10 AND 40'); console.log('o plano estimou rows=' + planoEstimado[0].rows); console.log('a consulta de verdade devolveu ' + reais[0].n + ' linhas'); console.log(''); console.log('o `rows` vem do catalogo, nao de uma contagem:'); console.log(' - ele vem de estatisticas que o servidor guarda da tabela'); console.log(' - as estatisticas envelhecem quando ninguem roda ANALYZE TABLE'); console.log(' - `ANALYZE TABLE` recalcula, e e o que este exemplo roda depois'); console.log(' de popular o dado'); console.log(''); console.log('Aqui os dois bateram, e isso e sorte de tabela pequena e estavel.'); console.log('Com volume grande e dado mudando todo dia eles divergem, e o plano'); console.log('pode estar errado por causa disso — nao por causa da consulta.'); console.log(''); console.log('Um chute bem informado, e nao uma medicao. E por isso que o'); console.log('material mostra o plano e o `rows` estimado, e deixa o tempo real'); console.log('para a saida que o exemplo imprime na maquina de quem roda: um'); console.log('numero de tempo escrito no texto seria mentira em qualquer outra'); console.log('maquina.'); // ==================================== 5. EXPLAIN ANALYZE e FORMAT=JSON console.log('\n--- 5. as duas variantes que dependem do servidor ---'); // `EXPLAIN ANALYZE` EXECUTA a consulta e devolve o tempo real de cada passo. // Ele nao existe em toda versao: onde falta, a resposta e `ER_PARSE_ERROR`, // e o exemplo imprime a resposta em vez de afirmar que o comando existe. console.log('tentando EXPLAIN ANALYZE (executa a consulta de verdade):'); try { const [analise] = await conexao.query( 'EXPLAIN ANALYZE SELECT id FROM tb_consulta_demo WHERE id = 7'); console.log(' este banco respondeu com o plano executado:'); console.log(' ' + String(analise[0] && Object.values(analise[0])[0]).slice(0, 300)); console.log(' o tempo aqui e real, medido nesta maquina, e muda a cada execucao'); } catch (erro) { console.error(erro.code + ': ' + erro.message); console.log(' erro:', erro.code, '-', erro.message); console.log(' sqlState:', erro.sqlState); console.log(' `EXPLAIN ANALYZE` nao existe nesta versao do servidor.'); console.log(' O `erro.code` e o que o Node compara; a `erro.message` muda entre'); console.log(' versoes e nao serve para decidir nada. O `ER_PARSE_ERROR` aqui e'); console.log(' do SQL, nao do driver: o comando foi reconhecido e gramaticalmente'); console.log(' recusado, que e coisa diferente de "comando desconhecido".'); console.log(' Em um servidor com o comando, o bloco acima imprime o tempo real'); console.log(' em vez desta mensagem — e o numero muda a cada execucao.'); } // `EXPLAIN FORMAT=JSON` existe em outra faixa de versao e traz o mesmo // plano com outro formato: `access_type` no lugar de `type`, e o plano // inteiro aninhado em um texto. Quem le o JSON ve a arvore. try { const [json] = await conexao.query( 'EXPLAIN FORMAT=JSON SELECT id FROM tb_consulta_ordem ' + "WHERE cd_status = 'pago' AND dt_cadastro = '2026-03-04'"); const cru = json[0].EXPLAIN; const lido = typeof cru === 'string' ? JSON.parse(cru) : cru; console.log('EXPLAIN FORMAT=JSON respondeu, e veio como:' + (typeof cru === 'string' ? ' texto JSON em uma coluna' : ' objeto')); console.log(' ' + JSON.stringify(lido).slice(0, 240) + '...'); console.log(' o mesmo plano com `access_type` no lugar de `type`; e por isso que'); console.log(' ler a coluna pelo nome fixo quebra em outra versao do servidor.'); } catch (erro) { console.error(erro.code + ': ' + erro.message); console.log(' erro:', erro.code, '-', erro.message); console.log(' este servidor tambem recusou `FORMAT=JSON` — o material le os'); console.log(' dois formatos e mostra o que a maquina respondeu.'); } // ==================================== 6. a ordem das colunas no indice // Um indice composto e uma arvore ordenada pela primeira coluna e, DENTRO // dela, pela segunda. Por isso a segunda coluna sozinha nao localiza nada: // sem a primeira, as linhas da segunda estao espalhadas pela arvore toda. console.log('\n--- 6. a ordem das colunas no indice ---'); console.log('tb_consulta_ordem tem idx_tb_consulta_ordem_status_data ' + '(cd_status, dt_cadastro).'); console.log('A arvore e ordenada por cd_status primeiro e, dentro de cada'); console.log('cd_status, por dt_cadastro. Isso decide o que da para procurar:'); const duasColunas = await imprimePlano(conexao, 'a) as duas colunas, na ordem do indice:', 'SELECT id FROM tb_consulta_ordem ' + "WHERE cd_status = 'pago' AND dt_cadastro = '2026-03-04'"); const soSegunda = await imprimePlano(conexao, 'b) so a segunda coluna do indice:', "SELECT id FROM tb_consulta_ordem WHERE dt_cadastro = '2026-03-04'"); const soPrimeira = await imprimePlano(conexao, 'c) so a primeira coluna do indice:', "SELECT id FROM tb_consulta_ordem WHERE cd_status = 'pago'"); console.log(''); console.log('as duas colunas -> ' + comoTexto(duasColunas)); console.log('so a primeira -> ' + comoTexto(soPrimeira)); console.log('so a segunda -> ' + comoTexto(soSegunda)); console.log(''); console.log('O bloco (b) e o sinal 3 de tras, e ele e o resultado que confunde'); console.log('quem esta aprendendo: o `key` esta PREENCHIDO. O indice foi'); console.log('escolhido, o plano virou `index` (varredura do indice inteiro) e o'); console.log('`rows` subiu para ' + soSegunda[0].rows + ' — o tamanho da tabela.'); console.log(''); console.log('E nao e bug do indice: e a ordem das colunas nele. Um indice'); console.log('(cd_status, dt_cadastro) serve para achar "pago de 2026-03-04" e'); console.log('para "pago"; nao serve para achar "2026-03-04", porque essa data'); console.log('aparece em tres lugares da arvore, um dentro de cada cd_status, e o'); console.log('banco teria de varrer os tres. Notado: aqui `possible_keys` veio'); console.log('vazio, porque nenhum indice consegue estreitar a busca — o'); console.log('servidor nem ofereceu nada e assim mesmo varreu o indice todo.'); console.log(''); console.log('Comparar os tres blocos em uma linha:'); console.log(comoDiagnostico('as duas colunas', duasColunas)); console.log(comoDiagnostico('so a primeira ', soPrimeira)); console.log(comoDiagnostico('so a segunda ', soSegunda)); console.log(''); console.log('A aula 2 cria o indice na ordem certa e mede a diferenca.'); // ==================================== 7. o dicionario de type, medido console.log('\n--- 7. o dicionario de type, com o caso que produz cada valor ---'); const casos = [ ['const ', 'SELECT id FROM tb_consulta_demo WHERE id = 7'], ['range ', 'SELECT id FROM tb_consulta_demo WHERE id BETWEEN 10 AND 40'], ['ALL ', 'SELECT * FROM tb_consulta_demo'], ['index ', "SELECT id FROM tb_consulta_ordem WHERE dt_cadastro = '2026-03-04'"], ['ref ', "SELECT id FROM tb_consulta_ordem WHERE nm_cliente = 'cliente 0007'"], ['ref ', "SELECT id FROM tb_consulta_ordem WHERE cd_status = 'pago'"], ['ALL ', "SELECT id FROM tb_consulta_prefixo WHERE nm_cliente = 'cliente 0007'"], ['ALL ', "SELECT id FROM tb_consulta_demo WHERE nm_cliente = 'cliente 0007'"], ]; for (const [rotulo, sql] of casos) { const p = await planoDe(conexao, sql); console.log(rotulo.padEnd(8) + ' | rows=' + String(p[0].rows).padStart(4) + ' | key=' + (p[0].key === null ? 'null' : p[0].key.padEnd(20)) + ' | ' + sql.replace(/^SELECT \* FROM |^SELECT id FROM /, '') .replace(/tb_consulta_demo/, 'demo ') .replace(/tb_consulta_ordem/, 'ordem ') .replace(/tb_consulta_prefixo/, 'prefixo ')); } console.log(''); console.log('O mesmo SQL em tres tabelas diferentes, ultimas tres linhas:'); console.log(' ordem -> ref, rows=1, o indice existe e foi usado'); console.log(' prefixo -> ALL, rows=600, o indice existe, foi listado, e nao serviu'); console.log(' demo -> ALL, rows=600, o indice nao existe'); console.log(''); console.log('E compare `ref` com rows=1 (nm_cliente, 600 valores) com `ref` com'); console.log('rows=200 (cd_status, 3 valores): os dois estao usando indice, e so um'); console.log('dos dois descarta quase tudo. E a aula 2 que mede isso e mostra'); console.log('quando um indice nao vale o custo dele em disco e em escrita.'); console.log(''); console.log('`index` na quarta linha merece atencao: e `ALL` com uma diferenca.'); console.log('Os dois leem o conjunto inteiro, mas `ALL` le a TABELA e `index` le o'); console.log('INDICE. Quando as colunas pedidas estao todas no indice, ler o'); console.log('indice e mais barato — e `Extra` ganha `Using index`. Quando o que'); console.log('resta e a tabela inteira mesmo assim, o indice e trabalho de graça.'); console.log('\n--- o que o EXPLAIN nao diz ---'); console.log(' rows e estimativa, e nao tempo'); console.log(' um plano bom com volume errado fica ruim sem o plano mudar'); console.log(' dois planos com rows igual podem diferir no tempo real'); console.log(' o tempo real exige EXPLAIN ANALYZE, e ele nem sempre existe'); console.log(' EXPLAIN nao mostra o que o Node faz antes e depois da consulta'); console.log(' EXPLAIN nao mede o trafego entre a aplicacao e o banco'); } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
--- 0. quem responde ---
banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1
a versao e lida do servidor de proposito: a da maquina de quem le
pode ser outra, e o exemplo precisa funcionar nas duas.
linhas em cada tabela: 600
nm_cliente: 600 valores distintos | cd_status: 3 valores | datas em dois anos
mesmo dado, tres esquemas: sem indice, com indice, com indice de prefixo
--- 1. EXPLAIN mostra o plano, nao o resultado ---
linhas com id = 7 antes: 1
EXPLAIN DELETE FROM tb_consulta_demo WHERE id = 7
devolveu um plano: tb_consulta_demo: range rows=1 key=PRIMARY
linhas com id = 7 depois: 1 — o DELETE nao aconteceu, o banco so mostrou o que faria
colunas que este banco devolveu no EXPLAIN: id, select_type, table, type, possible_keys, key, key_len, ref, rows, Extra
--- 2. a mesma tabela, sem filtro, com chave e com intervalo ---
a) sem WHERE, todas as colunas:
SELECT * FROM tb_consulta_demo
tabela=tb_consulta_demo | type=ALL | possible_keys=null | key=null | rows=600 | Extra=(vazio)
b) WHERE na chave primaria:
SELECT id FROM tb_consulta_demo WHERE id = 7
tabela=tb_consulta_demo | type=const | possible_keys=PRIMARY | key=PRIMARY | rows=1 | Extra=Using index
c) WHERE em intervalo na chave primaria:
SELECT id FROM tb_consulta_demo WHERE id BETWEEN 10 AND 40
tabela=tb_consulta_demo | type=range | possible_keys=PRIMARY | key=PRIMARY | rows=31 | Extra=Using where; Using index
sem filtro -> tb_consulta_demo: ALL rows=600 key=null (tabela inteira)
com filtro -> tb_consulta_demo: const rows=1 key=PRIMARY (le a chave e para)
com intervalo -> tb_consulta_demo: range rows=31 key=PRIMARY (le a faixa e para)
os tres leem a MESMA tabela. O que muda e o type e o rows:
ALL o plano le a tabela inteira, linha por linha (varredura
completa, ou full scan em ingles)
const a chave unica foi achada direto, no maximo uma linha
range o plano le um intervalo da chave, e para no fim dele
d) JOIN: uma linha do plano por tabela:
SELECT a.id FROM tb_consulta_demo a JOIN tb_consulta_demo b ON b.id = a.id WHERE a.id = 7
tabela=a | type=const | possible_keys=PRIMARY | key=PRIMARY | rows=1 | Extra=Using index
tabela=b | type=const | possible_keys=PRIMARY | key=PRIMARY | rows=1 | Extra=Using index
o plano tem 2 linhas: uma por tabela do JOIN.
Aqui as duas linhas ficaram `const`, e nao `eq_ref`: com chave
primaria o lado de dentro e resolvido por busca direta, que ja e o
caso mais barato que existe. O nome `eq_ref` aparece quando o lado
de dentro e resolvido por um indice unico que NAO e a chave
primaria. O que importa aqui e a forma: uma linha por tabela, na
ordem em que o banco vai ler.
--- 3. os tres sinais de indice, lado a lado ---
a mesma busca, em tres tabelas com o mesmo dado:
indice completo | possible_keys=idx_tb_consulta_ordem_nm_cliente | key=idx_tb_consulta_ordem_nm_cliente | rows=1
so prefixo | possible_keys=idx_tb_consulta_prefixo_nm_cliente | key=null | rows=600
sem indice | possible_keys=null | key=null | rows=600
Sinal 1 — possible_keys vazio, key vazio:
Nao existe indice para esta coluna. O plano e ALL e nao ha o que
investigar: a solucao e criar o indice.
Sinal 2 — possible_keys preenchido, key vazio:
O indice existe, o servidor ate ofereceu, e ele NAO foi usado.
E o caso da tabela de prefixo, e ele e real, nao contrivedado.
O motivo esta no `KEY idx_tb_consulta_prefixo_nm_cliente
(nm_cliente(4))`: o indice guarda so os 4 primeiros caracteres.
Uma busca por igualdade no valor inteiro nao cabe em uma chave
de prefixo, entao o indice nao separa nada — e ler a tabela
inteira sai mais barato do que consultar o indice inteiro.
Este sinal e o que vale a aula: o indice existe e foi recusado,
e a pergunta passa a ser por que.
Sinal 3 — key preenchido e rows do tamanho da tabela:
O indice foi usado e nao filter nada. O bloco 6 mostra esse caso.
--- 4. rows e o que o servidor ACOGHA que vai ler ---
o plano estimou rows=31
a consulta de verdade devolveu 31 linhas
o `rows` vem do catalogo, nao de uma contagem:
- ele vem de estatisticas que o servidor guarda da tabela
- as estatisticas envelhecem quando ninguem roda ANALYZE TABLE
- `ANALYZE TABLE` recalcula, e e o que este exemplo roda depois
de popular o dado
Aqui os dois bateram, e isso e sorte de tabela pequena e estavel.
Com volume grande e dado mudando todo dia eles divergem, e o plano
pode estar errado por causa disso — nao por causa da consulta.
Um chute bem informado, e nao uma medicao. E por isso que o
material mostra o plano e o `rows` estimado, e deixa o tempo real
para a saida que o exemplo imprime na maquina de quem roda: um
numero de tempo escrito no texto seria mentira em qualquer outra
maquina.
--- 5. as duas variantes que dependem do servidor ---
tentando EXPLAIN ANALYZE (executa a consulta de verdade):
erro: ER_PARSE_ERROR - You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'ANALYZE SELECT id FROM tb_consulta_demo WHERE id = 7' at line 1
sqlState: 42000
`EXPLAIN ANALYZE` nao existe nesta versao do servidor.
O `erro.code` e o que o Node compara; a `erro.message` muda entre
versoes e nao serve para decidir nada. O `ER_PARSE_ERROR` aqui e
do SQL, nao do driver: o comando foi reconhecido e gramaticalmente
recusado, que e coisa diferente de "comando desconhecido".
Em um servidor com o comando, o bloco acima imprime o tempo real
em vez desta mensagem — e o numero muda a cada execucao.
EXPLAIN FORMAT=JSON respondeu, e veio como: texto JSON em uma coluna
{"query_block":{"select_id":1,"nested_loop":[{"table":{"table_name":"tb_consulta_ordem","access_type":"ref","possible_keys":["idx_tb_consulta_ordem_status_data"],"key":"idx_tb_consulta_ordem_status_data","key_length":"45","used_key_parts":[...
o mesmo plano com `access_type` no lugar de `type`; e por isso que
ler a coluna pelo nome fixo quebra em outra versao do servidor.
--- 6. a ordem das colunas no indice ---
tb_consulta_ordem tem idx_tb_consulta_ordem_status_data (cd_status, dt_cadastro).
A arvore e ordenada por cd_status primeiro e, dentro de cada
cd_status, por dt_cadastro. Isso decide o que da para procurar:
a) as duas colunas, na ordem do indice:
SELECT id FROM tb_consulta_ordem WHERE cd_status = 'pago' AND dt_cadastro = '2026-03-04'
tabela=tb_consulta_ordem | type=ref | possible_keys=idx_tb_consulta_ordem_status_data | key=idx_tb_consulta_ordem_status_data | rows=1 | Extra=Using where; Using index
b) so a segunda coluna do indice:
SELECT id FROM tb_consulta_ordem WHERE dt_cadastro = '2026-03-04'
tabela=tb_consulta_ordem | type=index | possible_keys=null | key=idx_tb_consulta_ordem_status_data | rows=600 | Extra=Using where; Using index
c) so a primeira coluna do indice:
SELECT id FROM tb_consulta_ordem WHERE cd_status = 'pago'
tabela=tb_consulta_ordem | type=ref | possible_keys=idx_tb_consulta_ordem_status_data | key=idx_tb_consulta_ordem_status_data | rows=200 | Extra=Using where; Using index
as duas colunas -> tb_consulta_ordem: ref rows=1 key=idx_tb_consulta_ordem_status_data
so a primeira -> tb_consulta_ordem: ref rows=200 key=idx_tb_consulta_ordem_status_data
so a segunda -> tb_consulta_ordem: index rows=600 key=idx_tb_consulta_ordem_status_data
O bloco (b) e o sinal 3 de tras, e ele e o resultado que confunde
quem esta aprendendo: o `key` esta PREENCHIDO. O indice foi
escolhido, o plano virou `index` (varredura do indice inteiro) e o
`rows` subiu para 600 — o tamanho da tabela.
E nao e bug do indice: e a ordem das colunas nele. Um indice
(cd_status, dt_cadastro) serve para achar "pago de 2026-03-04" e
para "pago"; nao serve para achar "2026-03-04", porque essa data
aparece em tres lugares da arvore, um dentro de cada cd_status, e o
banco teria de varrer os tres. Notado: aqui `possible_keys` veio
vazio, porque nenhum indice consegue estreitar a busca — o
servidor nem ofereceu nada e assim mesmo varreu o indice todo.
Comparar os tres blocos em uma linha:
as duas colunas | possible_keys=idx_tb_consulta_ordem_status_data | key=idx_tb_consulta_ordem_status_data | rows=1
so a primeira | possible_keys=idx_tb_consulta_ordem_status_data | key=idx_tb_consulta_ordem_status_data | rows=200
so a segunda | possible_keys=null | key=idx_tb_consulta_ordem_status_data | rows=600
A aula 2 cria o indice na ordem certa e mede a diferenca.
--- 7. o dicionario de type, com o caso que produz cada valor ---
const | rows= 1 | key=PRIMARY | demo WHERE id = 7
range | rows= 31 | key=PRIMARY | demo WHERE id BETWEEN 10 AND 40
ALL | rows= 600 | key=null | demo
index | rows= 600 | key=idx_tb_consulta_ordem_status_data | ordem WHERE dt_cadastro = '2026-03-04'
ref | rows= 1 | key=idx_tb_consulta_ordem_nm_cliente | ordem WHERE nm_cliente = 'cliente 0007'
ref | rows= 200 | key=idx_tb_consulta_ordem_status_data | ordem WHERE cd_status = 'pago'
ALL | rows= 600 | key=null | prefixo WHERE nm_cliente = 'cliente 0007'
ALL | rows= 600 | key=null | demo WHERE nm_cliente = 'cliente 0007'
O mesmo SQL em tres tabelas diferentes, ultimas tres linhas:
ordem -> ref, rows=1, o indice existe e foi usado
prefixo -> ALL, rows=600, o indice existe, foi listado, e nao serviu
demo -> ALL, rows=600, o indice nao existe
E compare `ref` com rows=1 (nm_cliente, 600 valores) com `ref` com
rows=200 (cd_status, 3 valores): os dois estao usando indice, e so um
dos dois descarta quase tudo. E a aula 2 que mede isso e mostra
quando um indice nao vale o custo dele em disco e em escrita.
`index` na quarta linha merece atencao: e `ALL` com uma diferenca.
Os dois leem o conjunto inteiro, mas `ALL` le a TABELA e `index` le o
INDICE. Quando as colunas pedidas estao todas no indice, ler o
indice e mais barato — e `Extra` ganha `Using index`. Quando o que
resta e a tabela inteira mesmo assim, o indice e trabalho de graça.
--- o que o EXPLAIN nao diz ---
rows e estimativa, e nao tempo
um plano bom com volume errado fica ruim sem o plano mudar
dois planos com rows igual podem diferir no tempo real
o tempo real exige EXPLAIN ANALYZE, e ele nem sempre existe
EXPLAIN nao mostra o que o Node faz antes e depois da consulta
EXPLAIN nao mede o trafego entre a aplicacao e o banco
Índice e a consulta que ele muda
O mesmo SQL, o mesmo dado, dois planos
A aula 1 leu o plano. Esta é a aula em que o plano muda — e a única coisa que muda é o esquema.
CREATE INDEX idx_tb_consulta_indice_nm_cliente ON tb_consulta_indice (nm_cliente);
O exemplo cria quatro tabelas com o mesmo conjunto de 20000 linhas e esquemas diferentes, e roda a mesma consulta nas quatro. É o EXPLAIN depois do índice que separa uma consulta rápida de uma varredura completa, e o SELECT é a mesma string, com o mesmo parâmetro:
sem indice | type=ALL | key=null | rows=19644 com indice | type=ref | key=idx_tb_consulta_indice_nm_cliente | rows=1
type saiu de ALL para ref. rows saiu de um número perto de 20000 para 1. Ninguém mudou a consulta.
O rows é o número que vira texto
O material afirma o plano e o rows estimado, porque é o número que se repete entre máquinas. O tempo real não vai no texto: ele muda a cada execução e vira mentira assim que a pessoa roda em outra máquina. O exemplo mede com process.hrtime.bigint(), que conta em nanossegundos — por isso a divisão por 1e6 para chegar em milissegundos:
const t0 = process.hrtime.bigint(); await conexao.query(sql, parametros); const ms = Number(process.hrtime.bigint() - t0) / 1e6;
Dez voltas por consulta, e o número impresso é o mínimo das dez. A mediana carrega disco, cache e o resto da máquina; o mínimo é o tempo do trabalho que a consulta realmente fez. A razão medida fica em torno de uma ordem de grandeza entre os dois casos, e o valor exato sai na saída embutida.
Três filtros que não aproveitam o índice
O índice existe, a coluna está indexada, e o filtro mesmo assim não filtra. São os três casos que mais confundem, porque a consulta parece razoável.
Função em cima da coluna
WHERE YEAR(dt_cadastro) = 2026 | type=index | key=idx_tb_consulta_indice_data_dt_cadastro | rows=19649 WHERE dt_cadastro >= '2026-01-01' AND dt_cadastro < '2027-01-01' | type=range | key=idx_tb_consulta_indice_data_dt_cadastro | rows=9824 WHERE dt_cadastro = '2026-03-04' | type=ref | key=idx_tb_consulta_indice_data_dt_cadastro | rows=1
As três devolvem 10000 linhas — é a mesma pergunta. O que muda é quantas linhas o banco precisa ler: perto de 20000, metade, 1.
O motivo é uma frase: o índice guarda a coluna, não o resultado da função. YEAR(dt_cadastro) manda o banco calcular por linha, e índice não calcula nada — ele ordena. O intervalo vira "entre o primeiro dia e o primeiro dia do ano seguinte", que é ordenação, e ordenação é a única coisa que uma árvore ordenada sabe fazer.
Vale para DATE(), EXTRACT(YEAR FROM ...), UPPER(). A regra é uma: filtro em coluna indexada não passa por função.
LIKE com o coringa na frente
LIKE '%0777' | type=index | key=idx_tb_consulta_indice_nm_cliente | rows=19649 LIKE 'cliente 0%' | type=range | key=idx_tb_consulta_indice_nm_cliente | rows=999
O mesmo % produz os dois resultados. Com o coringa na frente não existe começo, existe fim — e árvore ordenada localiza por começo. Com o coringa atrás de um texto fixo, o banco desce a árvore até "cliente 0" e lê só o que vem depois.
"Contém em qualquer parte" e "começa com" são consultas de custo diferente, mesmo com o mesmo índice e a mesma tabela. A diferença é um caractere.
Índice em coluna com poucos valores
WHERE cd_status = 'pago' | type=ref | key=idx_tb_consulta_indice_status_data | rows=9824 de 20000 WHERE nm_cliente = cliente 0777 | type=ref | key=idx_tb_consulta_indice_nm_cliente | rows=1 de 20000
Os dois usam índice. Um descarta metade da tabela, o outro descarta 1. Com 3 valores na coluna, o índice gasta espaço e trabalho de escrita para devolver um terço da tabela — é o índice em coluna filtrada, que é o caso em que quase nunca compensa.
A ordem das colunas decide o que o índice serve
Um índice composto é uma árvore ordenada pela primeira coluna e, dentro dela, pela segunda.
as duas colunas | type=ref | key=idx_tb_consulta_indice_status_data | rows=1 so a primeira | type=ref | key=idx_tb_consulta_indice_status_data | rows=9824 so a segunda | type=index | key=idx_tb_consulta_indice_status_data | rows=19649
O terceiro caso é o pior dos três, e é o sinal 3 da aula 1: o key está preenchido, o plano virou index, e o rows subiu para o tamanho da tabela. O índice foi escolhido, usado do começo ao fim, e não filtrou uma linha. É o índice que não é usado — o pior tipo, porque existe, aparece no SHOW INDEX, e não faz nada.
idx_tb_consulta_indice_status_data serve para "pago de 2026-03-04" e para "pago". Não serve para achar "2026-03-04" sozinho, porque essa data aparece em três lugares da árvore — um dentro de cada cd_status. A correção não é trocar o índice: é ter um índice que comece pela coluna que a consulta usa.
SHOW INDEX mostra a ordem e diz se filtra
PRIMARY seq=1 col=id card= 19649 unico=sim idx_tb_consulta_indice_nm_cliente seq=1 col=nm_cliente card= 19649 unico=nao idx_tb_consulta_indice_status_data seq=1 col=cd_status card= 6 unico=nao idx_tb_consulta_indice_status_data seq=2 col=dt_cadastro card= 168 unico=nao
Três leituras. seq=1 e seq=2 no mesmo Key_name é índice composto, e a ordem das colunas está escrita ali. unico=sim marca chave primária e UNIQUE. E card é a estimativa de valores distintos: um índice sobre coluna de 3 valores mostra 6 ali, e é o aviso de que aquele índice filtra pouco.
UNIQUE é índice e é regra ao mesmo tempo
O índice único é o único que tem duas funções ao mesmo tempo: acelera a busca e recusa dado repetido. É por isso que ele não é um CREATE INDEX como os outros — ele muda o que o banco aceita gravar.
por nm_email (coluna com UNIQUE) | type=const | key=uq_tb_consulta_indice_email | rows=1
O plano sai const com rows=1: o banco sabe que existe no máximo uma linha. Mas a função principal do UNIQUE não é o plano — é a recusa:
tentando gravar um e-mail que ja existe: erro: ER_DUP_ENTRY - Duplicate entry '[email protected]' for key 'uq_tb_consulta_indice_email' sqlState: 23000 | errno: 1062
A regra vale mesmo sem nunca existir um SELECT por aquele campo. E ER_DUP_ENTRY é o erro.code que a aplicação compara para responder 409 em vez de 500. PRIMARY KEY é um UNIQUE com o nome trocado: no SHOW INDEX os dois aparecem com unico=sim.
O índice cobra em toda escrita
Cada índice é uma árvore a mais para o banco manter. Um INSERT escreve na tabela e em cada índice; um UPDATE que mexe numa coluna indexada apaga a entrada antiga e escreve a nova. Esse é o custo de escrita, e ele é pago em toda linha gravada, não só nas consultas que usam o índice.
O exemplo grava 20000 linhas em um INSERT só, cinco vezes em cada tabela, e imprime o mínimo das cinco. Na máquina em que o exemplo foi gerado, a razão entre o mínimo com um índice e o mínimo sem índice ficou em torno de 1.3x, e com dois índices em torno de 1.7x. Esses valores mudam a cada execução e a cada máquina — é por isso que o material afirma a direção do efeito (índice custa mais a escrever) e deixa a razão exata na saída embutida.
E o lado do disco, que é estável e por isso pode virar texto:
SHOW TABLE STATUS: sem indice Data_length=16384 Index_length=0 com 2 idx Data_length=16384 Index_length=32768
O dado ocupa o mesmo espaço; o índice é o que cresce. Index_length é o preço fixo do índice em disco, e ele só cresce com mais índice.
Índice em toda coluna filtrada é o erro mais caro de desempenho, porque parece prudente. O critério é cardinalidade: a coluna que a consulta usa precisa ter muitos valores distintos.
PRIMARY KEY, e-mail e telefone passam;cd_status,booleanetiponão passam.SHOW INDEXmostracardde cada coluna, e é por ele que se decide antes de criar.
Índice composto não é "índice de duas colunas": é uma árvore ordenada por uma e depois pela outra. Quando as duas consultas do dia são "por status" e "por status e data", uma árvore em
(cd_status, dt_cadastro)serve as duas. Quando são "por status" e "só por data", são duas árvores — e a segunda não é a mesma, porque precisa começar pela data.
CREATE INDEXfora de migração é o caminho para índice que ninguém remove. A aula de migrações do dia 4 é assunto do aluno, e a regra é a mesma que vale para coluna nova: esquema muda em arquivo versionado, ou ninguém sabe o que existe no banco de produção.
Índice não conserta consulta escrita errada.
WHERE dt_cadastro = ?com índice é rápido;WHERE YEAR(dt_cadastro) = ?com índice continua lento, porque o problema está na forma do filtro. Antes de criar índice, vale reescrever o filtro — é de graça e resolve o caso.
Exemplo
'use strict'; // Exemplo da aula 2 do dia 7: `CREATE INDEX`, e o que o indice muda. // // A aula 1 mostrou o plano. Esta e a aula em que o plano muda: o exemplo cria // o indice, roda `EXPLAIN` de novo na MESMA consulta, e mostra o antes e o // depois lado a lado. // // O que a aula defende, e o que os blocos medem: // // 1. o indice muda o `type` e o `rows` do plano — nao a consulta // 2. nem todo filtro se beneficia: funcao em cima da coluna, `LIKE '%x'` e // coluna com poucos valores NAO aproveitam o indice // 3. indice custa disco em TODA escrita, e um indice mal escolhido e // trabalho de graça // // E o detalhe que decide a honestidade do exemplo: o numero de tempo e // medido NA MAQUINA QUE RODOU e impresso na saida. Nenhum tempo esta escrito // no texto do material, porque um tempo escrito vira mentira na maquina de // quem le. O que o texto afirma e o plano e o `rows` estimado — que e o que o // MySQL decidiu — e o tempo real fica para a saida embutida. // // A conexao vem do harness do material (`await conexao`): este exemplo nao // segura servidor nenhum, e quem fecha o socket e o proprio harness. // // ============================================================== 1. os dados // 20000 linhas e o menor volume em que a diferenca de plano deixa de ser // ruido. Com 600 linhas o `EXPLAIN` ja mostra a mudanca de `type`, mas o // tempo real e o mesmo nos dois casos — porque o tempo de uma tabela pequena // e o tempo de ida e volta da rede, e nao o tempo de leitura. // // O desenho do dado e o que separa os dois tipos de coluna: // nm_cliente 20000 valores distintos (alta cardinalidade) // cd_status 3 valores (baixa cardinalidade) // dt_cadastro duas datas const TOTAL_LINHAS = 20000; const STATUS = ['aberto', 'pago', 'cancelado']; const NOMES = ['tb_consulta_indice_sem', 'tb_consulta_indice', 'tb_consulta_indice_data', 'tb_consulta_indice_email']; // Uma linha do conjunto de demonstracao. As quatro tabelas recebem o MESMO // dado: e o que permite comparar plano sem trocar nada na consulta. // // O e-mail e derivado do `id`, entao ele e unico em toda a tabela — e o que // permite ao `UNIQUE` do bloco 7 recusar uma duplicata de verdade. function linhaDe(i) { const ano = i % 2 === 0 ? 2025 : 2026; const mes = String(1 + (i % 12)).padStart(2, '0'); const dia = String(1 + (i % 28)).padStart(2, '0'); const status = STATUS[i % STATUS.length]; const nome = 'cliente ' + String(i).padStart(4, '0'); return { nome, status, ano, mes, dia, valor: 100 + i, email: 'cliente' + i + '@exemplo.com', }; } // `INSERT` em lotes de 1000: um `INSERT` unico ficaria acima do limite de // pacote que o driver aceita, e 20000 consultas seria lento demais. async function popula(conexao, tabela, colunas, valorDe) { await conexao.query('TRUNCATE TABLE ' + tabela); for (let base = 0; base < TOTAL_LINHAS; base += 1000) { const lote = []; for (let i = base + 1; i <= Math.min(base + 1000, TOTAL_LINHAS); i++) { lote.push(valorDe(i)); } await conexao.query('INSERT INTO ' + tabela + ' (' + colunas + ') VALUES ' + lote.join(',')); } await conexao.query('ANALYZE TABLE ' + tabela); } // ============================================================== 2. o leitor async function planoDe(conexao, sql, parametros) { const [linhas] = await conexao.query('EXPLAIN ' + sql, parametros || []); return linhas; } // O `EXPLAIN` depois do indice, lado a lado com o de antes. Sao as mesmas // tres colunas que a aula 1 leu, em uma linha so — que e como um painel // mostra "antes e depois" de uma mudanca de esquema. function comoLinha(rotulo, plano) { const l = plano[0]; return ' ' + rotulo.padEnd(22) + ' | type=' + String(l.type).padEnd(6) + ' | key=' + String(l.key === null ? 'null' : l.key).padEnd(32) + ' | rows=' + l.rows; } // O tempo real, medido aqui e impresso aqui. `process.hrtime.bigint()` conta // em NANOSSEGUNDOS, e por isso a divisao por 1e6 para chegar em milissegundos. // O numero muda a cada execucao — e o esperado, nao um defeito. async function medeTempo(conexao, rotulo, sql, parametros, voltas = 10) { const tempos = []; for (let i = 0; i < voltas; i++) { const t0 = process.hrtime.bigint(); await conexao.query(sql, parametros || []); tempos.push(Number(process.hrtime.bigint() - t0) / 1e6); } // O MINIMO e o numero que se repete entre execucoes. A mediana e a maxima // carregam o ruido da maquina — outra consulta, disco, cache — e mudam a // cada rodada. O minimo e o tempo do trabalho que a consulta fez de verdade. tempos.sort((a, b) => a - b); const min = tempos[0]; const mediana = tempos[Math.floor(tempos.length / 2)]; console.log(' ' + rotulo.padEnd(30) + 'min=' + min.toFixed(2) + 'ms' + ' mediana=' + mediana.toFixed(2) + 'ms' + ' (' + voltas + ' voltas, o numero muda a cada execucao)'); return min; } async function main() { // A versao e capturada, nunca afirmada. Quem le pode ter outra. const [versao] = await conexao.query('SELECT VERSION() AS versao'); console.log('--- 0. quem responde ---'); console.log('banco em uso:', versao[0].versao); console.log('a versao e lida do servidor de proposito: a da maquina de quem le'); console.log('pode ser outra, e o exemplo precisa funcionar nas duas.'); // ==================================================== 1. quatro esquemas // `DROP TABLE IF EXISTS` + `CREATE TABLE`: a aula precisa mostrar o estado // SEM indice, e `CREATE TABLE IF NOT EXISTS` nao serve para isso — ela nao // mexe em tabela que ja existe, entao um indice que ficou de outra execucao // continuaria la e o exemplo passaria a ensinar o contrario. // // Os indices nascem declarados no proprio `CREATE TABLE`, e nao com // `CREATE INDEX` depois: um `CREATE INDEX` solto falha na segunda execucao // com `ER_DUP_KEYNAME`, porque o nome do indice ja existe. Declarar no // `CREATE TABLE` faz a recriacao do esquema inteiro acontecer junto, e o // exemplo pode rodar duas vezes dando o mesmo resultado. const esquemas = { tb_consulta_indice_sem: '', tb_consulta_indice: ',\n KEY idx_tb_consulta_indice_nm_cliente (nm_cliente),' + '\n KEY idx_tb_consulta_indice_status_data (cd_status, dt_cadastro)', tb_consulta_indice_data: ',\n KEY idx_tb_consulta_indice_data_dt_cadastro (dt_cadastro)', tb_consulta_indice_email: ',\n UNIQUE KEY uq_tb_consulta_indice_email (nm_email),' + '\n KEY idx_tb_consulta_indice_email_cliente (nm_cliente)', }; const COLUNAS = 'id, nm_cliente, cd_status, dt_cadastro, vl_valor, nm_email'; for (const tabela of NOMES) { await conexao.query('DROP TABLE IF EXISTS ' + tabela); await conexao.query('CREATE TABLE ' + tabela + ' (' + ' id INT AUTO_INCREMENT PRIMARY KEY,' + ' nm_cliente VARCHAR(40) NOT NULL,' + ' cd_status VARCHAR(10) NOT NULL,' + ' dt_cadastro DATE NOT NULL,' + ' vl_valor DECIMAL(10,2) NOT NULL,' + ' nm_email VARCHAR(40) NOT NULL' + esquemas[tabela] + '\n' + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'); } // As quatro tabelas recebem exatamente o MESMO dado, e so o esquema muda. // E isso que permite comparar plano sem trocar nada na consulta: o `SELECT` // e a mesma string, com o mesmo parametro, nas quatro. for (const tabela of NOMES) { await popula(conexao, tabela, COLUNAS, (i) => { const d = linhaDe(i); return '(' + i + ", '" + d.nome + "', '" + d.status + "', '" + d.ano + '-' + d.mes + '-' + d.dia + "', " + d.valor + ".50, '" + d.email + "')"; }); } const [contagem] = await conexao.query( 'SELECT COUNT(*) AS total FROM tb_consulta_indice'); console.log('\n--- 1. o mesmo dado em quatro esquemas ---'); console.log('linhas em cada tabela:', contagem[0].total); console.log('nm_cliente: ' + TOTAL_LINHAS + ' valores distintos | cd_status: ' + STATUS.length + ' valores | dt_cadastro: duas datas'); console.log(''); console.log(' tb_consulta_indice_sem nenhum indice secundario'); console.log(' tb_consulta_indice (nm_cliente) e (cd_status, dt_cadastro)'); console.log(' tb_consulta_indice_data (dt_cadastro)'); console.log(' tb_consulta_indice_email UNIQUE (nm_email) e (nm_cliente)'); const ALVO = 'cliente 0777'; const SQL_NM_SEM = 'SELECT id FROM tb_consulta_indice_sem WHERE nm_cliente = ?'; const SQL_NM = 'SELECT id FROM tb_consulta_indice WHERE nm_cliente = ?'; // ============================ 2. EXPLAIN depois do indice: o mesmo SQL console.log('\n--- 2. EXPLAIN depois do indice ---'); console.log('o SQL e a mesma string nas duas tabelas:'); console.log(" SELECT id FROM <tabela> WHERE nm_cliente = '" + ALVO + "'"); const antes = await planoDe(conexao, SQL_NM_SEM, [ALVO]); const depois = await planoDe(conexao, SQL_NM, [ALVO]); console.log(comoLinha('sem indice', antes)); console.log(comoLinha('com indice', depois)); console.log(''); console.log('O `type` saiu de ' + antes[0].type + ' para ' + depois[0].type + ' e o `rows` saiu de ' + antes[0].rows + ' para ' + depois[0].rows + '.'); console.log('O SQL nao mudou em nada. E o `rows` que o MySQL decidiu — e o que o'); console.log('texto do material afirma, porque e o numero que se repete entre'); console.log('maquinas. O tempo real esta no bloco 3, medido nesta maquina e'); console.log('impresso nesta saida.'); // ==================================== 3. medir tempo, e o que ele mede console.log('\n--- 3. o tempo real da mesma consulta ---'); await medeTempo(conexao, 'sem indice (varredura)', SQL_NM_SEM, [ALVO]); await medeTempo(conexao, 'com indice (busca direta)', SQL_NM, [ALVO]); console.log(''); console.log('O numero acima e desta maquina e desta execucao, e muda a cada vez'); console.log('que o exemplo roda. O minimo de dez voltas e o que se repete; a'); console.log('mediana carrega o resto da maquina. O material nao escreve este'); console.log('numero em lugar nenhum do texto — um tempo escrito vira mentira'); console.log('assim que a pessoa roda em outra maquina.'); console.log(''); console.log('O que o indice faz e trocar "ler 20000 linhas e descartar 19999"'); console.log('por "sair da arvore direto na chave". O resto do plano e igual.'); // ============================== 4. tres filtros que NAO ajudam // Cada bloco mede um filtro que nao consegue usar o indice, e mostra o plano // que saiu. Sao os tres casos que mais confundem quem esta aprendendo, // porque a coluna TEM indice e a consulta parece razoavel. console.log('\n--- 4. tres filtros que nao conseguem usar o indice ---'); // (a) funcao em cima da coluna. `YEAR(dt_cadastro) = 2026` calcula uma // funcao por linha: o indice guarda o valor da COLUNA, e nao o resultado da // funcao. No intervalo o mesmo filtro vira uma faixa, e faixa e o que a // arvore sabe ordenar. // // As tres consultas sao medidas em tb_consulta_indice_data, que tem indice // que COMECA por dt_cadastro — sem isso o indice nao serviria nem no // intervalo, e a comparacao nao mostraria nada. console.log('a) funcao em cima da coluna:'); console.log(' (medido em tb_consulta_indice_data, indice (dt_cadastro))'); const comFuncao = await planoDe(conexao, 'SELECT id FROM tb_consulta_indice_data WHERE YEAR(dt_cadastro) = 2026'); const comIntervalo = await planoDe(conexao, 'SELECT id FROM tb_consulta_indice_data ' + "WHERE dt_cadastro >= '2026-01-01' AND dt_cadastro < '2027-01-01'"); const comIgualdade = await planoDe(conexao, "SELECT id FROM tb_consulta_indice_data " + "WHERE dt_cadastro = '2026-03-04'"); console.log(' WHERE YEAR(dt_cadastro) = 2026'); console.log(' ' + comoLinha('', comFuncao).trim()); console.log(" WHERE dt_cadastro >= '2026-01-01' AND dt_cadastro < '2027-01-01'"); console.log(' ' + comoLinha('', comIntervalo).trim()); console.log(" WHERE dt_cadastro = '2026-03-04'"); console.log(' ' + comoLinha('', comIgualdade).trim()); // As tres devolvem o mesmo tipo de resultado, e o plano e que muda. A // contagem real prova que o filtro da funcao e o filtro do intervalo sao a // MESMA pergunta — o que muda e quantas linhas o banco precisa ler. const [nFuncao] = await conexao.query( 'SELECT COUNT(*) AS n FROM tb_consulta_indice_data ' + 'WHERE YEAR(dt_cadastro) = 2026'); const [nIntervalo] = await conexao.query( 'SELECT COUNT(*) AS n FROM tb_consulta_indice_data ' + "WHERE dt_cadastro >= '2026-01-01' AND dt_cadastro < '2027-01-01'"); console.log(''); console.log('As tres linhas devolvem ' + nFuncao[0].n + ' e ' + nIntervalo[0].n + ' linhas: e a MESMA pergunta.'); console.log('O plano e que muda: ' + comFuncao[0].rows + ' -> ' + comIntervalo[0].rows + ' -> ' + comIgualdade[0].rows + ' linhas lidas.'); console.log(''); console.log('`YEAR(dt_cadastro) = 2026` diz para o banco calcular uma funcao'); console.log('por linha. O indice tem as DATAS, e nao "o ano de cada data" —'); console.log('ele nao sabe calcular nada, ele so ordena. O intervalo vira'); console.log('"entre o primeiro dia e o primeiro dia do ano seguinte", que e'); console.log('ordenacao — e a unica coisa que uma arvore ordenada sabe fazer.'); console.log(''); console.log('Por isso a regra e: filtro em coluna indexada nao passa por'); console.log('funcao. `WHERE dt_cadastro >= ? AND dt_cadastro < ?` usa o indice;'); console.log('`WHERE YEAR(dt_cadastro) = ?` nao usa. E o mesmo vale para'); console.log('`DATE(dt_cadastro)`, `EXTRACT(YEAR FROM dt_cadastro)` e'); console.log('`UPPER(nm_cliente)`: todas perdem o indice pelo mesmo motivo.'); console.log(''); // (b) `LIKE` com o coringa na FRENTE. Com `%0777` nao existe comeco, existe // fim — e arvore ordenada localiza por comeco. O mesmo `%` atras de um texto // fixo e outra consulta. const comCoringa = await planoDe(conexao, "SELECT id FROM tb_consulta_indice WHERE nm_cliente LIKE '%0777'"); const semCoringa = await planoDe(conexao, "SELECT id FROM tb_consulta_indice WHERE nm_cliente LIKE 'cliente 0%'"); console.log('b) LIKE com o coringa na frente:'); console.log(" LIKE '%0777' " + comoLinha('', comCoringa).trim()); console.log(" LIKE 'cliente 0%' " + comoLinha('', semCoringa).trim()); console.log(''); console.log('O mesmo `%` produz os dois resultados. Com o coringa na frente o'); console.log('banco varre a tabela; com o coringa atras de um texto fixo, ele'); console.log('desce a arvore ate "cliente 0" e le so o que vem depois. E por isso'); console.log('que "contem em qualquer parte" e "comeca com" sao consultas de'); console.log('custo diferente, mesmo com o mesmo indice.'); // (c) coluna com poucos valores distintos. `cd_status` tem 3 valores, entao // qualquer filtro por ela descarta quase nada. O indice existe, e e usado. const porStatus = await planoDe(conexao, "SELECT id FROM tb_consulta_indice WHERE cd_status = 'pago'"); const porNome = await planoDe(conexao, "SELECT id FROM tb_consulta_indice WHERE nm_cliente = '" + ALVO + "'"); console.log('c) coluna com poucos valores distintos:'); console.log(" WHERE cd_status = 'pago' " + comoLinha('', porStatus).trim() + ' de ' + TOTAL_LINHAS); console.log(' WHERE nm_cliente = ' + ALVO + ' ' + comoLinha('', porNome).trim() + ' de ' + TOTAL_LINHAS); console.log(''); console.log('Os dois estao usando indice. Um descarta ' + porStatus[0].rows + ' linhas e o outro descarta ' + porNome[0].rows + '.'); console.log('Com 3 valores na coluna, o indice gasta espaco e trabalho de'); console.log('escrita para devolver um terco da tabela — e o material trata isso'); console.log('como "indice em coluna filtrada", que e o caso em que quase nunca'); console.log('compensa.'); // ================================ 5. o indice que existe e nao filtra // O sinal 3 da aula 1: um indice sobre a SEGUNDA coluna de um indice // composto, consultado sozinho. O plano usa o indice e nao filtra nada. console.log('\n--- 5. o indice que existe e nao filtra ---'); console.log('tb_consulta_indice tem idx_tb_consulta_indice_status_data'); console.log('(cd_status, dt_cadastro). A arvore e ordenada por cd_status'); console.log('primeiro e, dentro de cada cd_status, por dt_cadastro.'); console.log(''); const pelasDuas = await planoDe(conexao, "SELECT id FROM tb_consulta_indice " + "WHERE cd_status = 'pago' AND dt_cadastro = '2026-03-04'"); const soAPrimeira = await planoDe(conexao, "SELECT id FROM tb_consulta_indice WHERE cd_status = 'pago'"); const soASegunda = await planoDe(conexao, "SELECT id FROM tb_consulta_indice WHERE dt_cadastro = '2026-03-04'"); console.log(comoLinha('as duas colunas', pelasDuas)); console.log(comoLinha('so a primeira ', soAPrimeira)); console.log(comoLinha('so a segunda ', soASegunda)); console.log(''); console.log('O caso da segunda coluna e o sinal 3 da aula 1: o `key` esta'); console.log('preenchido, o plano virou `index`, e o `rows` subiu para ' + soASegunda[0].rows + ' — o tamanho da tabela. O indice foi escolhido,'); console.log('usado do comeco ao fim, e nao filtrou uma linha.'); console.log(''); console.log('A correcao nao e trocar o indice: e ter um indice que COMECE pela'); console.log('coluna que a consulta usa. O bloco 6 mostra a ordem que funciona.'); // ================================ 6. a ordem que funciona, e o SHOW INDEX console.log('\n--- 6. a ordem das colunas no indice composto ---'); console.log('Para filtrar por data sozinha, o indice precisa COMECAR pela data.'); console.log(''); const porDataSem = await planoDe(conexao, "SELECT id FROM tb_consulta_indice_sem WHERE dt_cadastro = '2026-03-04'"); const porData = await planoDe(conexao, "SELECT id FROM tb_consulta_indice_data WHERE dt_cadastro = '2026-03-04'"); console.log(comoLinha('sem indice', porDataSem)); console.log(comoLinha('com indice', porData)); console.log(''); console.log('E a diferenca de `EXPLAIN` para indice e `UNIQUE`, que o SHOW'); console.log('INDEX devolve linha por linha — uma por coluna, na ordem da'); console.log('arvore:'); console.log(''); const [indices] = await conexao.query('SHOW INDEX FROM tb_consulta_indice'); for (const i of indices) { console.log(' ' + String(i.Key_name).padEnd(36) + ' seq=' + i.Seq_in_index + ' col=' + String(i.Column_name).padEnd(11) + ' card=' + String(i.Cardinality).padStart(6) + ' unico=' + (i.Non_unique === 0 ? 'sim' : 'nao')); } console.log(''); console.log('Tres leituras do `SHOW INDEX`:'); console.log(' seq=1 e seq=2 no mesmo Key_name: indice COMPOSTO, e a ordem'); console.log(' das colunas esta escrita ali. Ela e a ordem da arvore.'); console.log(' unico=sim: e a chave primaria ou um UNIQUE. Um indice comum'); console.log(' aceita repetido; um UNIQUE recusa — e essa e a outra funcao.'); console.log(' card: a estimativa de valores distintos da coluna. Um indice'); console.log(' sobre coluna de 3 valores mostra um numero pequeno, e e o aviso'); console.log(' de que aquele indice filtra pouco.'); // ================================ 7. UNIQUE: indice que tambem e regra console.log('\n--- 7. UNIQUE: o indice que tambem recusa dado ---'); const porEmail = await planoDe(conexao, 'SELECT id FROM tb_consulta_indice_email WHERE nm_email = ?', ['[email protected]']); console.log(' por nm_email (coluna com UNIQUE)'); console.log(' ' + comoLinha('', porEmail).trim()); console.log(' `const` com rows=1: o plano sabe que existe no maximo uma'); console.log(' linha, e sai direto nela.'); console.log(''); console.log('tentando gravar um e-mail que ja existe:'); try { await conexao.query( 'INSERT INTO tb_consulta_indice_email ' + '(nm_cliente, cd_status, dt_cadastro, vl_valor, nm_email) ' + "VALUES ('outro', 'pago', '2026-01-01', 1.00, '[email protected]')"); console.log(' entrou — e nao devia'); } catch (erro) { console.error(erro.code + ': ' + erro.message); console.log(' erro:', erro.code, '-', erro.message); console.log(' sqlState:', erro.sqlState, '| errno:', erro.errno); console.log(' `UNIQUE` e regra de dado E indice ao mesmo tempo: a regra'); console.log(' vale mesmo sem nunca existir um `SELECT` por aquele campo, e'); console.log(' o `ER_DUP_ENTRY` e o `erro.code` que a aplicacao compara para'); console.log(' responder 409 em vez de 500.'); console.log(' `PRIMARY KEY` e um `UNIQUE` com o nome trocado: no SHOW INDEX'); console.log(' os dois aparecem com unico=sim.'); } // ================================ 8. o custo de escrever // Cada indice e uma arvore a mais para o banco manter. Um `INSERT` escreve na // tabela E em cada indice; um `UPDATE` que mexe numa coluna indexada apaga a // entrada antiga e escreve a nova. console.log('\n--- 8. o custo de escrever ---'); // POR QUE 20000 LINHAS E 5 VOLTAS // Com lote pequeno e poucas voltas o numero e ruido, e uma rodada ja deu // "1 indice" MAIS BARATO que "sem indice" — o exemplo ensinaria o // contrario do que e verdade. O que separa o trabalho do indice do ruido da // maquina e o volume: 20000 linhas em um `INSERT` so, repetido, e o MINIMO. // // E o MINIMO, e nao a media: a media carrega disco, cache e concorrencia da // maquina que rodou. O minimo e o tempo do trabalho que a consulta fez. const N = TOTAL_LINHAS; const VOLTAS = 5; const medeInsert = async (tabela, rotulo) => { const tempos = []; for (let volta = 0; volta < VOLTAS; volta++) { await conexao.query('TRUNCATE TABLE ' + tabela); const lote = []; for (let i = 1; i <= N; i++) { const d = linhaDe(i); lote.push('(' + i + ", '" + d.nome + "', '" + d.status + "', '" + d.ano + '-' + d.mes + '-' + d.dia + "', " + d.valor + ".50, '" + d.email + "')"); } const t0 = process.hrtime.bigint(); await conexao.query('INSERT INTO ' + tabela + ' (' + COLUNAS + ') VALUES ' + lote.join(',')); tempos.push(Number(process.hrtime.bigint() - t0) / 1e6); } tempos.sort((a, b) => a - b); console.log(' ' + rotulo.padEnd(14) + 'min=' + tempos[0].toFixed(0) + 'ms mediana=' + tempos[Math.floor(VOLTAS / 2)].toFixed(0) + 'ms (' + N + ' linhas em um INSERT so, ' + VOLTAS + ' voltas)'); return tempos[0]; }; const semIdx = await medeInsert('tb_consulta_indice_sem', 'sem indice'); const umIdx = await medeInsert('tb_consulta_indice_data', '1 indice'); const doisIdx = await medeInsert('tb_consulta_indice', '2 indices'); console.log(''); console.log(' razao do minimo, sobre a tabela sem indice:'); console.log(' 1 indice / sem indice = ' + (umIdx / semIdx).toFixed(2) + 'x'); console.log(' 2 indices / sem indice = ' + (doisIdx / semIdx).toFixed(2) + 'x'); console.log(''); console.log('Cada indice e uma arvore a mais para manter, entao cada escrita'); console.log('paga uma vez na tabela e uma vez em cada indice. A razao cresce com'); console.log('a quantidade de indice, e o minimo das voltas e o numero que se'); console.log('repete entre execucoes.'); console.log(''); console.log('O numero e desta maquina e deste volume, e muda a cada execucao.'); console.log('O texto do material nao escreve este valor em lugar nenhum: um'); console.log('"de 800ms para 3ms" no texto vira mentira assim que a pessoa roda'); console.log('em outra maquina. O que o texto afirma e que escrever com indice'); console.log('custa mais, e o plano do `EXPLAIN` — que e o mesmo em qualquer lugar.'); // E o lado do disco, que e estavel e por isso vira texto. const [status] = await conexao.query('SHOW TABLE STATUS LIKE ?', ['tb_consulta_indice']); const [statusSem] = await conexao.query('SHOW TABLE STATUS LIKE ?', ['tb_consulta_indice_sem']); console.log(''); console.log(' SHOW TABLE STATUS:'); console.log(' sem indice Data_length=' + statusSem[0].Data_length + ' Index_length=' + statusSem[0].Index_length); console.log(' com 2 idx Data_length=' + status[0].Data_length + ' Index_length=' + status[0].Index_length); console.log(' o dado ocupa o mesmo espaco; o indice e o que cresce.'); console.log(' `Index_length` e o preco fixo do indice em disco, e ele so'); console.log(' cresce com mais indice, nunca com mais linha.'); console.log('\n--- o que o exemplo nao mede, e por que ---'); console.log(' o custo do indice numa tabela de 1 bilhao de linhas'); console.log(' o ganho real de leitura em producao, com cache quente'); console.log(' o efeito de indice sobre `UPDATE` e `DELETE`'); console.log(' o que o indice custa quando o dado cabe inteiro em memoria'); console.log(' tudo isso depende do volume, do hardware e do dado — e por isso'); console.log(' que o material mede o PLANO, que e o mesmo em qualquer maquina,'); console.log(' e deixa o TEMPO para a saida que sai da maquina de quem roda.'); } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
--- 0. quem responde ---
banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1
a versao e lida do servidor de proposito: a da maquina de quem le
pode ser outra, e o exemplo precisa funcionar nas duas.
--- 1. o mesmo dado em quatro esquemas ---
linhas em cada tabela: 20000
nm_cliente: 20000 valores distintos | cd_status: 3 valores | dt_cadastro: duas datas
tb_consulta_indice_sem nenhum indice secundario
tb_consulta_indice (nm_cliente) e (cd_status, dt_cadastro)
tb_consulta_indice_data (dt_cadastro)
tb_consulta_indice_email UNIQUE (nm_email) e (nm_cliente)
--- 2. EXPLAIN depois do indice ---
o SQL e a mesma string nas duas tabelas:
SELECT id FROM <tabela> WHERE nm_cliente = 'cliente 0777'
sem indice | type=ALL | key=null | rows=20164
com indice | type=ref | key=idx_tb_consulta_indice_nm_cliente | rows=1
O `type` saiu de ALL para ref e o `rows` saiu de 20164 para 1.
O SQL nao mudou em nada. E o `rows` que o MySQL decidiu — e o que o
texto do material afirma, porque e o numero que se repete entre
maquinas. O tempo real esta no bloco 3, medido nesta maquina e
impresso nesta saida.
--- 3. o tempo real da mesma consulta ---
sem indice (varredura) min=6.05ms mediana=9.30ms (10 voltas, o numero muda a cada execucao)
com indice (busca direta) min=0.64ms mediana=1.40ms (10 voltas, o numero muda a cada execucao)
O numero acima e desta maquina e desta execucao, e muda a cada vez
que o exemplo roda. O minimo de dez voltas e o que se repete; a
mediana carrega o resto da maquina. O material nao escreve este
numero em lugar nenhum do texto — um tempo escrito vira mentira
assim que a pessoa roda em outra maquina.
O que o indice faz e trocar "ler 20000 linhas e descartar 19999"
por "sair da arvore direto na chave". O resto do plano e igual.
--- 4. tres filtros que nao conseguem usar o indice ---
a) funcao em cima da coluna:
(medido em tb_consulta_indice_data, indice (dt_cadastro))
WHERE YEAR(dt_cadastro) = 2026
| type=index | key=idx_tb_consulta_indice_data_dt_cadastro | rows=19649
WHERE dt_cadastro >= '2026-01-01' AND dt_cadastro < '2027-01-01'
| type=range | key=idx_tb_consulta_indice_data_dt_cadastro | rows=9824
WHERE dt_cadastro = '2026-03-04'
| type=ref | key=idx_tb_consulta_indice_data_dt_cadastro | rows=1
As tres linhas devolvem 10000 e 10000 linhas: e a MESMA pergunta.
O plano e que muda: 19649 -> 9824 -> 1 linhas lidas.
`YEAR(dt_cadastro) = 2026` diz para o banco calcular uma funcao
por linha. O indice tem as DATAS, e nao "o ano de cada data" —
ele nao sabe calcular nada, ele so ordena. O intervalo vira
"entre o primeiro dia e o primeiro dia do ano seguinte", que e
ordenacao — e a unica coisa que uma arvore ordenada sabe fazer.
Por isso a regra e: filtro em coluna indexada nao passa por
funcao. `WHERE dt_cadastro >= ? AND dt_cadastro < ?` usa o indice;
`WHERE YEAR(dt_cadastro) = ?` nao usa. E o mesmo vale para
`DATE(dt_cadastro)`, `EXTRACT(YEAR FROM dt_cadastro)` e
`UPPER(nm_cliente)`: todas perdem o indice pelo mesmo motivo.
b) LIKE com o coringa na frente:
LIKE '%0777' | type=index | key=idx_tb_consulta_indice_nm_cliente | rows=20164
LIKE 'cliente 0%' | type=range | key=idx_tb_consulta_indice_nm_cliente | rows=999
O mesmo `%` produz os dois resultados. Com o coringa na frente o
banco varre a tabela; com o coringa atras de um texto fixo, ele
desce a arvore ate "cliente 0" e le so o que vem depois. E por isso
que "contem em qualquer parte" e "comeca com" sao consultas de
custo diferente, mesmo com o mesmo indice.
c) coluna com poucos valores distintos:
WHERE cd_status = 'pago' | type=ref | key=idx_tb_consulta_indice_status_data | rows=10082 de 20000
WHERE nm_cliente = cliente 0777 | type=ref | key=idx_tb_consulta_indice_nm_cliente | rows=1 de 20000
Os dois estao usando indice. Um descarta 10082 linhas e o outro descarta 1.
Com 3 valores na coluna, o indice gasta espaco e trabalho de
escrita para devolver um terco da tabela — e o material trata isso
como "indice em coluna filtrada", que e o caso em que quase nunca
compensa.
--- 5. o indice que existe e nao filtra ---
tb_consulta_indice tem idx_tb_consulta_indice_status_data
(cd_status, dt_cadastro). A arvore e ordenada por cd_status
primeiro e, dentro de cada cd_status, por dt_cadastro.
as duas colunas | type=ref | key=idx_tb_consulta_indice_status_data | rows=1
so a primeira | type=ref | key=idx_tb_consulta_indice_status_data | rows=10082
so a segunda | type=index | key=idx_tb_consulta_indice_status_data | rows=20164
O caso da segunda coluna e o sinal 3 da aula 1: o `key` esta
preenchido, o plano virou `index`, e o `rows` subiu para 20164 — o tamanho da tabela. O indice foi escolhido,
usado do comeco ao fim, e nao filtrou uma linha.
A correcao nao e trocar o indice: e ter um indice que COMECE pela
coluna que a consulta usa. O bloco 6 mostra a ordem que funciona.
--- 6. a ordem das colunas no indice composto ---
Para filtrar por data sozinha, o indice precisa COMECAR pela data.
sem indice | type=ALL | key=null | rows=20164
com indice | type=ref | key=idx_tb_consulta_indice_data_dt_cadastro | rows=1
E a diferenca de `EXPLAIN` para indice e `UNIQUE`, que o SHOW
INDEX devolve linha por linha — uma por coluna, na ordem da
arvore:
PRIMARY seq=1 col=id card= 20164 unico=sim
idx_tb_consulta_indice_nm_cliente seq=1 col=nm_cliente card= 20164 unico=nao
idx_tb_consulta_indice_status_data seq=1 col=cd_status card= 6 unico=nao
idx_tb_consulta_indice_status_data seq=2 col=dt_cadastro card= 168 unico=nao
Tres leituras do `SHOW INDEX`:
seq=1 e seq=2 no mesmo Key_name: indice COMPOSTO, e a ordem
das colunas esta escrita ali. Ela e a ordem da arvore.
unico=sim: e a chave primaria ou um UNIQUE. Um indice comum
aceita repetido; um UNIQUE recusa — e essa e a outra funcao.
card: a estimativa de valores distintos da coluna. Um indice
sobre coluna de 3 valores mostra um numero pequeno, e e o aviso
de que aquele indice filtra pouco.
--- 7. UNIQUE: o indice que tambem recusa dado ---
por nm_email (coluna com UNIQUE)
| type=const | key=uq_tb_consulta_indice_email | rows=1
`const` com rows=1: o plano sabe que existe no maximo uma
linha, e sai direto nela.
tentando gravar um e-mail que ja existe:
erro: ER_DUP_ENTRY - Duplicate entry '[email protected]' for key 'uq_tb_consulta_indice_email'
sqlState: 23000 | errno: 1062
`UNIQUE` e regra de dado E indice ao mesmo tempo: a regra
vale mesmo sem nunca existir um `SELECT` por aquele campo, e
o `ER_DUP_ENTRY` e o `erro.code` que a aplicacao compara para
responder 409 em vez de 500.
`PRIMARY KEY` e um `UNIQUE` com o nome trocado: no SHOW INDEX
os dois aparecem com unico=sim.
--- 8. o custo de escrever ---
sem indice min=338ms mediana=385ms (20000 linhas em um INSERT so, 5 voltas)
1 indice min=398ms mediana=455ms (20000 linhas em um INSERT so, 5 voltas)
2 indices min=481ms mediana=529ms (20000 linhas em um INSERT so, 5 voltas)
razao do minimo, sobre a tabela sem indice:
1 indice / sem indice = 1.18x
2 indices / sem indice = 1.42x
Cada indice e uma arvore a mais para manter, entao cada escrita
paga uma vez na tabela e uma vez em cada indice. A razao cresce com
a quantidade de indice, e o minimo das voltas e o numero que se
repete entre execucoes.
O numero e desta maquina e deste volume, e muda a cada execucao.
O texto do material nao escreve este valor em lugar nenhum: um
"de 800ms para 3ms" no texto vira mentira assim que a pessoa roda
em outra maquina. O que o texto afirma e que escrever com indice
custa mais, e o plano do `EXPLAIN` — que e o mesmo em qualquer lugar.
SHOW TABLE STATUS:
sem indice Data_length=2637824 Index_length=0
com 2 idx Data_length=16384 Index_length=32768
o dado ocupa o mesmo espaco; o indice e o que cresce.
`Index_length` e o preco fixo do indice em disco, e ele so
cresce com mais indice, nunca com mais linha.
--- o que o exemplo nao mede, e por que ---
o custo do indice numa tabela de 1 bilhao de linhas
o ganho real de leitura em producao, com cache quente
o efeito de indice sobre `UPDATE` e `DELETE`
o que o indice custa quando o dado cabe inteiro em memoria
tudo isso depende do volume, do hardware e do dado — e por isso
que o material mede o PLANO, que e o mesmo em qualquer maquina,
e deixa o TEMPO para a saida que sai da maquina de quem roda.