Índices no MongoDB: medir COLLSCAN e IXSCAN
Crie índices simples, compostos e únicos no MongoDB e compare a mesma consulta antes e depois com explain, documentos e chaves examinados.
Um índice no MongoDB é uma estrutura ordenada que ajuda o banco a encontrar
documentos sem testar toda a coleção. Nesta lição, você vai medir a mesma
consulta em 80 mil pedidos: primeiro com COLLSCAN, depois com IXSCAN.
O método de prova é explain("executionStats"). Ele mostra plano escolhido,
quantos resultados voltaram, quantos documentos foram examinados e quantas
chaves do índice foram percorridas.
O índice no fim do catálogo não guarda os livros
Pense no índice alfabético de um catálogo. Para encontrar pedidos do cliente 42,
você consulta a lista ordenada e segue os apontamentos, em vez de abrir 80 mil
fichas. No MongoDB, valores indexados e referências aos registros cumprem essa
função; o estágio IXSCAN percorre o índice.
A comparação tem um limite importante: índice não é uma cópia gratuita. Ele ocupa disco e memória, e toda inserção, remoção ou mudança nos campos indexados precisa mantê-lo. Um catálogo com índice para qualquer detalhe ficaria caro de atualizar. No banco, o problema é o mesmo em outra escala.
Também não basta ver IXSCAN e declarar vitória. O plano pode percorrer muitas
chaves, buscar muitos documentos ou ordenar depois. Por isso vamos comparar
contagens, não apenas o nome do estágio.
Se filtros e projeções ainda forem novos, revise o CRUD MongoDB com Node.js.
Prepare um conjunto grande o bastante para enxergar
Instale o driver oficial em uma pasta vazia:
npm init -y
npm install mongodb@7.5.0Crie indices.mjs, conecte e limpe somente o database do laboratório:
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017";
const client = new MongoClient(uri);
await client.connect();
const db = client.db("devclub_indices");
await db.dropDatabase();
const pedidos = db.collection("pedidos");
console.log({ database: db.databaseName, estado: "limpo" });Gere 80 mil pedidos em lotes de 4 mil. O padrão é determinístico: mil clientes se repetem; o status muda a cada bloco; a data avança um minuto por documento. Isso não simula toda distribuição de uma loja real, mas permite repetir a comparação sob a mesma forma de dados.
const total = 80_000;
const tamanhoLote = 4_000;
const inicio = Date.parse("2025-01-01T00:00:00.000Z");
for (let primeiro = 0; primeiro < total; primeiro += tamanhoLote) {
const lote = [];
for (let i = primeiro; i < Math.min(primeiro + tamanhoLote, total); i += 1) {
lote.push({
_id: i,
clienteId: `cliente-${String(i % 1000).padStart(3, "0")}`,
status: ["pago", "pendente", "enviado", "cancelado"]
[Math.floor(i / 1000) % 4],
criadoEm: new Date(inicio + i * 60_000),
total: (i % 200) + 20,
});
}
await pedidos.insertMany(lote, { ordered: false });
}
console.log({ documentos: await pedidos.countDocuments() });O laboratório foi executado em Apple Silicon dentro de Docker. Tempos e tamanho de índice variam por máquina, cache e armazenamento; as contagens examinadas abaixo são a evidência comparável para este conjunto controlado.
Defina uma consulta antes de inventar um índice
A tela quer pedidos pagos do cliente 42, do mais recente para o mais antigo, e mostra somente cliente, status e data. Escreva primeiro o padrão real:
const filtro = {
clienteId: "cliente-042",
status: "pago",
};
const projecao = {
_id: 0,
clienteId: 1,
status: 1,
criadoEm: 1,
};
const amostra = await pedidos
.find(filtro, { projection: projecao })
.sort({ criadoEm: -1 })
.limit(2)
.toArray();
console.log({ encontrados: await pedidos.countDocuments(filtro), amostra: amostra.length });Criar índice antes de conhecer filtro, ordenação e projeção é como produzir um mapa sem saber qual caminho alguém percorre. O índice deve apoiar uma forma de consulta, não apenas um campo que parece importante.
Meça o plano sem índice adequado
O plano completo é grande. Esta função percorre a árvore e coleta os nomes de estágio, sem alterar as estatísticas que vieram do servidor:
function etapasDoPlano(no, etapas = []) {
if (!no || typeof no !== "object") return etapas;
if (typeof no.stage === "string") etapas.push(no.stage);
for (const valor of Object.values(no)) {
if (valor && typeof valor === "object") etapasDoPlano(valor, etapas);
}
return [...new Set(etapas)];
}
console.log(etapasDoPlano({ stage: "COLLSCAN" }));Agora rode explain("executionStats") sobre a consulta inteira, sem limit, e
extraia as contagens:
const antes = await pedidos
.find(filtro, { projection: projecao })
.sort({ criadoEm: -1 })
.explain("executionStats");
console.log({
momento: "antes",
etapas: etapasDoPlano(antes.queryPlanner.winningPlan),
retornados: antes.executionStats.nReturned,
documentosExaminados: antes.executionStats.totalDocsExamined,
chavesExaminadas: antes.executionStats.totalKeysExamined,
});O banco devolveu 20 documentos depois de examinar 80 mil. SORT também mostra
que a ordenação não veio pronta de uma estrutura adequada. Isso não prova que
toda COLLSCAN é um bug: ler grande parte de uma coleção pequena ou gerar um
relatório eventual pode justificar varredura. Aqui, a diferença entre lidos e
retornados é o sinal concreto.
Crie um índice composto na ordem da consulta
O filtro usa igualdade em clienteId e status; a saída ordena por criadoEm
decrescente. Crie um índice composto nessa sequência e dê um nome legível:
const nomeDoIndice = await pedidos.createIndex(
{ clienteId: 1, status: 1, criadoEm: -1 },
{ name: "cliente_status_data" },
);
console.log({ indiceCriado: nomeDoIndice });Os campos de igualdade vêm antes; a ordenação vem depois. Essa decisão segue o padrão Equality, Sort, Range como ponto de partida. Não copie a ordem para toda consulta: se houver intervalo, outra ordenação ou collation, confirme com o plano real.
Um índice composto também tem prefixos. Este começa por clienteId, então
pode apoiar filtros por clienteId e por clienteId + status. Ele não é
equivalente a um índice que começa apenas por status ou criadoEm.
Meça novamente a mesma consulta
Sem alterar filtro, projeção nem ordenação, execute outro explain:
const depois = await pedidos
.find(filtro, { projection: projecao })
.sort({ criadoEm: -1 })
.explain("executionStats");
console.log({
momento: "depois",
indice: nomeDoIndice,
etapas: etapasDoPlano(depois.queryPlanner.winningPlan),
retornados: depois.executionStats.nReturned,
documentosExaminados: depois.executionStats.totalDocsExamined,
chavesExaminadas: depois.executionStats.totalKeysExamined,
});IXSCAN percorreu 20 chaves. PROJECTION_COVERED e zero documentos examinados
indicam consulta coberta: todos os campos necessários ao filtro, à ordenação
e à resposta estavam no índice. Se a projeção incluísse total, que não está
nele, o banco precisaria buscar documentos; isso pode continuar sendo um bom
plano, apenas deixa de ser coberto.
O ganho observado pertence a esta consulta e a esta massa. Índices apoiam
também $match, $sort e combinações dentro de pipelines; veja o relatório em
aggregation pipeline.
Índice único transforma duplicação em erro
Índice também pode expressar unicidade. Para login, queremos um e-mail por cliente. Crie o índice antes dos dados duplicados:
const clientes = db.collection("clientes");
console.log(await clientes.createIndex(
{ email: 1 },
{ unique: true, name: "email_unico" },
));
await clientes.insertOne({ nome: "Ana", email: "ana@exemplo.com" });O índice garante unicidade do valor armazenado. Ele não normaliza letras maiúsculas, espaços ou acentos segundo a regra do produto. Decida se a aplicação guarda um campo normalizado e considere collation quando a comparação exigir.
Tente agora repetir o e-mail:
try {
await clientes.insertOne({
nome: "Outra Ana",
email: "ana@exemplo.com",
});
} catch (erro) {
console.log({
erro: "email duplicado",
codigo: erro.code,
indice: "email_unico",
});
}O código 11000 é a evidência do conflito. Faça a API traduzir isso para uma
mensagem útil, mas preserve o índice: uma verificação findOne() antes da
inserção sofre corrida quando duas requisições chegam juntas.
Missão: quebre a cobertura e explique o novo plano
Repita o explain de “depois”, acrescentando total: 1 à projeção. Colete as
mesmas estatísticas:
const comTotal = await pedidos
.find(filtro, {
projection: { ...projecao, total: 1 },
})
.sort({ criadoEm: -1 })
.explain("executionStats");
console.log({
etapas: etapasDoPlano(comTotal.queryPlanner.winningPlan),
retornados: comTotal.executionStats.nReturned,
documentosExaminados: comTotal.executionStats.totalDocsExamined,
chavesExaminadas: comTotal.executionStats.totalKeysExamined,
});
await client.close();A missão termina quando você explica a mudança sem chamar o plano de “ruim”:
IXSCAN ainda encontra as 20 entradas, FETCH busca os 20 documentos para ler
total, e a consulta deixa de ser coberta. Incluir total no índice só faz
sentido se a economia de leitura compensar espaço e manutenção nas escritas.
Compare essa medição com índices no PostgreSQL: os nomes do plano mudam, mas a disciplina é a mesma — medir a consulta real antes e depois, e não colecionar índices por intuição.
Perguntas frequentes
O que significa COLLSCAN no MongoDB?
O que significa IXSCAN?
Quanto mais índices, melhor?
A ordem dos campos em um índice composto importa?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em MongoDB 8.0.28, MongoDB Node.js Driver 7.5.0, Node 26.3.0 e Docker 29.5.3 em Apple Silicon, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MongoDB Manual — Indexes — mongodb.com
- MongoDB Manual — Create Indexes to Support Queries — mongodb.com
- MongoDB Manual — Explain Results — mongodb.com
- MongoDB Node.js Driver — Indexes — mongodb.com


