Tecnologias/02 - Bancos de Dados/02 - Não Relacionais/01 - MongoDB7 min
Query Planner e Índices
Conceito
O MongoDB escolhe planos de forma empírica, não por modelo de custo. É uma diferença conceitual grande em relação ao Postgres e ao Oracle.
Como o plano é escolhido: corrida, não cálculo
1. Query nova (shape nunca vista) chega
2. O planner gera os planos CANDIDATOS (um por índice utilizável)
3. Roda TODOS em paralelo, por um período de teste (~101 documentos ou fim dos dados)
4. Declara vencedor o que retornou mais rápido / com menos trabalho
5. GUARDA o vencedor no PLAN CACHE, indexado pelo "query shape"
6. Reavalia se o desempenho degradar, se o cache for limpo, ou se índices mudarem
Consequências práticas:
- A primeira execução de uma consulta nova é mais lenta (a corrida acontece).
- O plano depende dos dados daquele momento: uma corrida feita quando a coleção tinha outra distribuição pode ter escolhido mal.
- O plan cache é por
mongode é perdido em restart ou mudança de índice.
A regra ESR, com precisão
Para um índice composto, a ordem dos campos deve ser:
Equality → Sort → Range
Por quê: o índice é uma estrutura ordenada. Igualdade "fixa" um ponto de entrada; a partir dele, a ordenação já vem de graça; uma faixa consome a ordem restante.
// Consulta:
db.pedidos.find({ status: "pago", total: { $gt: 100 } }).sort({ criado_em: -1 })
// ❌ E-R-S: a faixa vem antes do sort → ordenação EM MEMÓRIA
db.pedidos.createIndex({ status: 1, total: 1, criado_em: -1 })
// ✅ E-S-R
db.pedidos.createIndex({ status: 1, criado_em: -1, total: 1 })
No plano, a diferença aparece como a presença ou ausência de um estágio SORT — e um
SORT em memória tem limite de 100 MB (sem allowDiskUse), o que faz a query
falhar em volume, não só ficar lenta.
Ler o explain
db.pedidos.find({ status: "pago" }).sort({ criado_em: -1 }).limit(20)
.explain("executionStats")
Os estágios que você precisa reconhecer:
| Estágio | Significa |
|---|---|
COLLSCAN |
varredura da coleção. Quase sempre um problema |
IXSCAN |
usou índice 🎉 |
FETCH |
foi buscar o documento após o índice |
sem FETCH após IXSCAN |
covered query: respondeu só com o índice. O ideal |
SORT |
ordenou em memória (o índice não deu a ordem) |
SORT_KEY_GENERATOR |
acompanha o SORT |
PROJECTION_COVERED |
projeção atendida pelo índice |
LIMIT / SKIP |
conta com o SKIP grande ser lento |
E os números:
{
nReturned: 20, // o que a query devolveu
totalKeysExamined: 20, // entradas de índice lidas
totalDocsExamined: 20, // DOCUMENTOS lidos
executionTimeMillis: 1
}
totalDocsExamined / nReturned. Igual a 1 é perfeito. 20000/20 = 1000 significa
que o banco leu mil documentos para devolver um: falta índice, ou o índice está na ordem
errada. É o equivalente do rows_examined/rows_sent do
MySQL.
E totalDocsExamined: 0 com nReturned > 0 é o selo de covered query — o melhor
resultado possível.
Os tipos de índice, e as pegadinhas de cada um
| Tipo | Criação | Pegadinha |
|---|---|---|
| Composto | {a:1, b:-1} |
a ordem importa (ESR); prefixo é reutilizável |
| Multikey | automático ao indexar array | não pode haver dois campos array no mesmo índice composto |
| Parcial | partialFilterExpression |
a query precisa garantir o mesmo filtro para usar o índice |
| Esparso | sparse: true |
não indexa documentos sem o campo; ordenação por ele pode perder documentos |
| TTL | expireAfterSeconds |
remoção roda a cada ~60 s; não é exata; só em campo Date |
| Único | unique: true |
em coleção sharded, precisa conter a shard key |
| Texto | text |
um índice de texto por coleção |
| Wildcard | {"attrs.$**": 1} |
útil em schema imprevisível; menos eficiente que índice específico |
| Vetorial | Atlas Search | busca semântica / RAG |
// índice parcial: só o subconjunto que a fila consulta (o análogo do índice parcial do Postgres)
db.jobs.createIndex(
{ disponivel_em: 1 },
{ partialFilterExpression: { status: "pendente" } }
)
// ⚠️ a query SÓ usa este índice se incluir status: "pendente" explicitamente
db.jobs.find({ status: "pendente", disponivel_em: { $lte: new Date() } }) // ✅ usa
db.jobs.find({ disponivel_em: { $lte: new Date() } }) // ❌ não usa
Pipeline de agregação: as duas regras
db.pedidos.aggregate([
{ $match: { criado_em: { $gte: inicio } } }, // 1. FILTRE PRIMEIRO (usa índice!)
{ $sort: { criado_em: 1 } }, // 2. ordene enquanto ainda dá índice
{ $group: { _id: "$cliente_id", total: { $sum: "$total" } } },
{ $sort: { total: -1 } }, // este sort é em memória, inevitável
{ $limit: 20 }
], { allowDiskUse: true })
$matche$sortno começo — só os primeiros estágios podem usar índice. Depois de um$groupou$project, o dado já é intermediário e não há índice.- Cada estágio tem limite de 100 MB de memória.
allowDiskUse: truepermite spill para disco (mais lento, mas não falha).
Trade-offs
Plano empírico se adapta e é imprevisível. Não há como "congelar" um plano como no
SPM do Oracle — existe apenas hint() na
query e index filters no plan cache. Em compensação, o planner não depende de
estatísticas desatualizadas: ele mede.
Índice acelera leitura e cobra em escrita e em RAM. Cada índice é atualizado em toda escrita e precisa caber na memória (4. WiredTiger e Armazenamento). Uma coleção de escrita intensa com 12 índices é uma coleção com problema de escrita.
Wildcard index é tentador. Ele resolve "meu schema é imprevisível" e é consistentemente pior que índices específicos: maior, menos seletivo e sem suporte a ordenação eficiente. Use como ponte, não como destino.
skip grande não escala. skip(100000) percorre e descarta 100 mil entradas — o
mesmo problema do OFFSET no SQL (2. Dialetos SQL na Prática). Use paginação por
cursor: find({_id: {$gt: ultimoId}}).limit(20).
O trade-off central: o MongoDB entrega desempenho excelente para consultas previstas pelo índice e desempenho ruim e súbito para as não previstas — porque
COLLSCANnão degrada, ele despenca. Ter índice para cada consulta da aplicação não é otimização; é requisito de funcionamento.
Exemplo prático
Do COLLSCAN ao covered query
// 1) medir
db.pedidos.find({ cliente_id: 4711, status: "pago" }, { _id: 0, total: 1, status: 1 })
.sort({ criado_em: -1 }).limit(20).explain("executionStats")
// → COLLSCAN, totalDocsExamined: 2_400_000, executionTimeMillis: 3180
// 2) índice seguindo ESR + campos de projeção para cobrir
db.pedidos.createIndex(
{ cliente_id: 1, status: 1, criado_em: -1, total: 1 },
{ name: "cli_status_criado_total", background: true }
)
// 3) medir de novo
// → IXSCAN, sem FETCH, totalDocsExamined: 0, nReturned: 20, executionTimeMillis: 1
// (covered query: o índice contém tudo que a projeção pediu)
Manutenção de índices
// quem usa cada índice (desde a 3.2): 'accesses.ops' é a contagem de uso
db.pedidos.aggregate([{ $indexStats: {} }])
.forEach(i => print(i.name, "→", i.accesses.ops))
// ops: 0 há semanas = candidato a remoção
// o plan cache: quais shapes existem e qual plano venceu
db.pedidos.getPlanCache().list()
db.pedidos.getPlanCache().clear() // força nova corrida (útil após criar índice)
// forçar um índice quando o planner escolhe mal
db.pedidos.find({...}).hint("cli_status_criado_total")
explain depois de criar índiceCriar o índice não garante que o planner vai usá-lo: um plano vencedor já em cache
continua sendo usado até ser reavaliado. Rode getPlanCache().clear() na coleção (ou
aguarde a reavaliação) e confirme com explain que o IXSCAN esperado apareceu.
Como isso falha em produção
| Sintoma | Causa |
|---|---|
Query com COLLSCAN em coleção grande |
índice ausente, ou filtro em campo não indexado |
SORT no plano com LIMIT pequeno |
índice na ordem E-R-S em vez de E-S-R |
Sort exceeded memory limit of 104857600 bytes |
ordenação de 100 MB+ sem índice e sem allowDiskUse |
| Índice parcial "ignorado" | a query não repetiu o partialFilterExpression |
cannot index parallel arrays |
tentativa de índice composto com dois campos array |
| Plano ruim depois de crescer | corrida decidida quando a distribuição era outra; limpe o plan cache |
| Escrita lenta | índices demais na coleção |
| Paginação lenta na página 500 | skip grande; migre para paginação por cursor |
Aprofundamento aplicado
Retornar pouco depois de examinar muito
Suponha que uma consulta devolva 20 documentos, examine 80 mil chaves e carregue 30 mil
documentos. A presença de IXSCAN não demonstra um acesso seletivo. Verifique onde o
filtro foi aplicado e se a ordenação exigiu trabalho adicional.
// mongosh; pressupõe uma coleção pedidos com esses campos
db.pedidos.find({ clienteId: 42, status: "aberto" })
.sort({ criadoEm: -1 })
.limit(20)
.explain("executionStats");
Compare nReturned, totalKeysExamined e totalDocsExamined, além dos estágios de
execução. Um resultado vazio exige interpretar os números absolutos, evitando divisões
por zero em métricas de amplificação.
Referência: interpretar explain.
Exercício: altere a distribuição para que quase todos os pedidos estejam abertos. Resultado esperado: explique como igualdade pouco seletiva, ordenação e limite interagem; a regra de composição do índice precisa refletir o conjunto real de consultas.
Relacionado
- 1. MongoDB
- 2. Modelagem de Documentos
- 4. WiredTiger e Armazenamento
- 6. Sharding por Dentro
- 5. Índices
- 4. Índices e o Otimizador
- 7. Problema N+1
Parte de MOC MongoDB · Query Plans (docs)