trilha

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 mongod e é 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
}
A razão que resume tudo

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 })
  1. $match e $sort no começo — só os primeiros estágios podem usar índice. Depois de um $group ou $project, o dado já é intermediário e não há índice.
  2. Cada estágio tem limite de 100 MB de memória. allowDiskUse: true permite 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 COLLSCAN nã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")
Sempre explain depois de criar índice

Criar 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


Parte de MOC MongoDB · Query Plans (docs)

Buscar

Busca por título, seção e texto das notas