Dia 7 — Desempenho de consulta

Informatica · Conteudo · publicado em 30/09/2026
Dia 7 de 15

Desempenho de consulta

Aula 1

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:

ColunaPergunta que ela responde
typecomo o banco vai chegar na linha
keyqual índice ele escolheu
rowsquantas 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:

typeO que o plano faz
ALLlê a tabela inteira, linha por linha
indexlê o índice inteiro, sem filtrar
rangelê um intervalo e para no fim dele
refacha as linhas por um valor não-único e compara o resto
constachou 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 vazionão existe índice para esta coluna
possible_keys cheio, key vazioo índice existe e foi recusado
possible_keys cheio, key preenchidoo 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.

EXPLAIN não diz quanto tempo a consulta leva. rows é estimativa, e dois planos com o mesmo rows podem 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.

EXPLAIN també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: EXPLAIN de um WHERE sobre 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
Aula 2

Í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, boolean e tipo não passam. SHOW INDEX mostra card de 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 INDEX fora 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.