Nota
Capire i query plan di MongoDB
Il ragionamento dietro mongoose-lens: come MongoDB costruisce un query plan, cosa mostra davvero explain() e dove un'euristica basata sulla lettura del piano smette di bastare.
Context
mongoose-lens does something deliberately narrow: it intercepts slow Mongoose
queries, runs explain() on them, flags two stages — COLLSCAN and a blocking
SORT — and proposes a compound index ordered by the ESR rule. Building that
meant deciding which signals in a query plan are safe to act on automatically and
which need a human. This note is the reasoning behind those choices, and where
the approach runs out of road. The index-selection mechanics are in the
companion note; here the focus is the plan itself and the limits of reading it.
Scope: single-collection reads, WiredTiger, MongoDB 6.x/7.x (core mechanisms
re-checked on 8.0.16, classic engine — unchanged). Not sharding, not the
aggregation framework beyond $match / $sort. Everything here is plan-reading;
the parts that need a benchmark to confirm are marked as such.
Question
What can you actually conclude from a single explain() output, and what are you
only guessing at?
- How does MongoDB arrive at the plan it runs, and how stable is that choice?
- Which stages are unambiguous problems, and which are "it depends"?
- If a heuristic suggests an index from one plan, what could that suggestion break elsewhere?
Investigation
The method here was to read plans, not to benchmark — so most of what follows is mechanism, with the empirical parts called out explicitly.
- Getting a plan.
db.coll.find(query).explain("executionStats")returnsqueryPlanner(candidate + winning plans) andexecutionStats(what actually happened for that run).mongoose-lenskeys offexecutionStatsbecause the stage tree alone doesn't tell you how bad a scan was. - Reading the winning plan. It's a tree of stages executed leaves-first. The
ones that matter:
IXSCAN(an index was used;indexBoundsshows how tight),FETCH(documents pulled by_idafter anIXSCAN),COLLSCAN(every document examined),SORT(a blocking, in-memory sort — no index could yield thesort()order in one directional scan, so the rows are buffered and ordered in memory, spilling to disk past the sort memory limit; alimitbounds it to a top-k, and if the sort field is in the scanned index it sorts index keys rather than whole documents). - Numbers to compare.
nReturnedvstotalKeysExaminedvstotalDocsExamined.keysExamined≫nReturnedusually means the index scan is wide — but discount a multi-interval$in(one boundary probe per interval) and a multikey index (one key per matching array element), where the gap is normal, not waste.docsExamined≫nReturnedis the sharper signal: theFETCHis discarding rows a residual filter could not push down.executionTimeMillisis the headline and the most variable number.
Done (companion lab, Experiment 06): swept a predicate from very selective to matching the whole collection and recorded
indexBounds/keysExaminedat each step.keysExaminedtracks the interval width; the switch toCOLLSCANnever happens on selectivity grounds alone — see the note below.
Observations
Working model — the trial/cache mechanics, the COLLSCAN behaviour and the
blocking-SORT behaviour are all confirmed by testing:
- MongoDB enumerates candidate plans from indexes whose key pattern is compatible with the query, runs them in a short trial, and keeps the one that made progress with the least work. The winner is cached per query shape (the query with its values abstracted away).
explain()is cache-free: it re-runs the planner on every call and reports the plan it would pick now, ignoring any cached entry (isCachedstays false). So its plan is stable for fixed data and indexes — but it can still mislead two ways. It re-plans as the data or the specific values change, so a different parameter can yield a differentexplain()plan; and a real query may run a cached plan — chosen for an earlier data distribution and reused value-blind — thatexplain()will never show you. (Checked againstsystem.profilein the companion lab, Experiments 04–09.)COLLSCANand blockingSORTare the two stages almost always worth acting on: the first means no index was usable at all, the second means no index yields the requested order in one directional scan.COLLSCANis structural — aCOLLSCANat 1k rows is aCOLLSCANat 1M. A blockingSORTis mostly structural but not fully data-independent: for a query that is{range} + sort, the planner keeps a tight-filter +SORTplan only while the range is very selective (~2% in testing) and switches to a wider sort-supplying scan — with noSORT— once it isn't. Flagging the stage is still safe; predicting whether it appears from the query text alone is not.IXSCANis not automatically fine. An index scan withkeysExaminedfar abovenReturned, or followed by a largeFETCH, is still a bad plan.
Done (companion lab, Experiments 04–05): cold vs warm cache and
planCacheClearbehaviour — a plan is chosen by a trial, cached per shape, and then reused value-blind until the accumulated work overruns adecisionWorks × ratiobudget and the shape is replanned.explain()never reads the cache, so "data vs cache" is only visible insystem.profile(fromPlanCache) or the mongod log. Done (companion lab, Experiment 09): a query sorting{ a: 1, b: 1 }against an index{ a: 1, b: -1 }does force a blockingSORT— but{ a: -1, b: 1 }(the index's exact mirror) does not: the b-tree is walked backward. A single reversed sort key is always free; only a partial direction conflict on a compound sort needs theSORT.SORTcost isrows_in × row_sizeand scales linearly with the feeding scan'skeysExamined.
Technical details
Query shape → index eligibility. An index is a candidate when its key prefix matches the query's equality and range predicates and, ideally, its later keys match the sort. Equality predicates collapse the scan to a contiguous stretch of the b-tree; a range predicate opens it; keys after a range key can't be used to narrow further or to provide order.
ESR. Order compound index keys as Equality, then Sort, then
Range: equality fields first pin the scan to one contiguous stretch; sort
fields next let the index return rows already ordered, removing the blocking
SORT; range fields last, because everything after a range is unusable for
equality or ordering. It's a heuristic, not a law — it degrades with two
independent range predicates, with $in (which sits between equality and range),
and when a covered query changes the trade-off.
Why COLLSCAN / SORT are the automatable signals. A COLLSCAN is a
COLLSCAN at 1k or 1M documents — fully data-independent. A blocking SORT
buffers its input (a limit bounds it to a top-k; the input is index keys if
the sort field is in the scanned index, otherwise whole documents) and its
severity scales with the row count. Its appearance is mostly structural, with
one data-dependent exception — a {range} + sort query loses the SORT once the
range stops being selective. Either way, once the stage is in the plan a
heuristic can name it and propose an ESR-shaped index without a benchmark.
Recognising a potentially problematic query, from the plan alone:
COLLSCANon a collection that isn't tiny;- a blocking
SORTstage (especiallySORTabove aFETCH— it is ordering whole documents, not index keys); totalKeysExamined≫nReturned(wide index scan — after discounting a multi-interval$inand multikey de-duplication, where the gap is expected);totalDocsExamined≫nReturned(wastefulFETCH— rows dropped by a residual filter);$orwhere one branch has no usable index (can force aCOLLSCANfor the whole query);- a compound sort whose per-key directions are a partial mismatch with the
index (e.g. sort
{a:1, b:1}on index{a:1, b:-1}) — a single reversed key, or a whole-pattern reversal, is served by a backward scan and is fine.
Practical implications
- A tool can safely flag a
COLLSCANor a blockingSORTthat is in a plan and propose an ESR index — the diagnosis is reliable once the stage is there. It should not silently trustIXSCAN(check the examined-vs-returned ratio), nor promise that a{range} + sortquery will keep itsSORT— that flips with the range's selectivity. - An index suggestion is a starting point for a human, not a migration. It has a write cost on every insert/update, it takes cache RAM, it may duplicate the prefix of an existing index, and it can shift the planner onto a worse plan for a different query shape.
- Because the plan cache exists, "it was fast before" is not evidence. Re-run
explain()now, and again after the data grows or its distribution shifts. - A slow-query interceptor never sees the queries just under the threshold, or the rare-but-catastrophic ones. Sampling only the slow queries is a biased view of the workload.
Limitations
A heuristic that reads one explain() and suggests an ESR index has real blind
spots:
- One sample, one parameter set.
explain()is a single execution with specific values against the current data. A different parameter, date range, or skew you didn't hit can produce a completely different plan. The suggestion is fitted to one point. - No write-side view. It sees reads. It can't weigh the suggested index against write amplification, index build time on a live primary, or cache pressure.
- Redundancy blindness. Without inspecting the existing indexes it may propose something already covered by an existing compound index's prefix.
- Global effects. An index that fixes query A can make the planner choose a worse plan for query B — same shape, different selectivity.
- Plan-cache coupling. The slow execution that triggered the tool may have
run on a stale cached plan, but the
explain()the tool then runs is cache-free and shows a freshly planned one — so the two can disagree, and the real cause (a cache entry that outlived its data) never appears in theexplain()output.planCacheClearplus a re-run is what exposes it. - Multikey and nested paths. Arrays change index-bounds semantics: a
two-sided range (
{$gte, $lte}) on an array field is not intersected into one interval,$allis not an index intersection, and a compound index turns multikey the moment one key is an array. A suggestion derived from a scalar reading of the query can be wrong for a multikey field. (Companion lab, Experiment 08.) $or,$in, negation. ESR ordering and "just add the index" both get shakier here, and a simple reader can miss that one$orbranch is unindexed — which forces aCOLLSCANfor the whole query. (Companion lab, Experiment 07.)
TODO: verify with benchmark — for a query with an obvious
COLLSCAN, apply the ESR-suggested index and measureexecutionTimeMillisandtotalDocsExaminedbefore and after at ~1e4 / 1e5 / 1e6 documents. This is the one number that would justify the suggestion. TODO: verify with benchmark — construct a case where an index that fixes query A regresses query B (same shape, different selectivity), to quantify the "global effects" risk.
Takeaways
- A query plan is chosen by trial and cached per shape.
explain()itself is cache-free and re-plans every call, so it is stable for fixed data and indexes — but a real query may run a different, cached plan it will not reveal. COLLSCANand blockingSORTare safe to flag automatically once they are in the plan — but aSORTcan appear or vanish with the range selectivity of a{range} + sortquery, so don't predict it from the query text.IXSCANneeds the examined-vs-returned check before you trust it.- ESR is a reasonable default order for a suggested compound index, and a heuristic, not a rule.
- A plan-reading tool like
mongoose-lensis a detector, not a fixer: it narrows where to look. The index decision still needs a human who can see the write cost, the existing indexes, and the other queries. - Every claim in this note with a number attached is marked
TODO: verify with benchmarkuntil it's been run. The companion lab below has since discharged several; the ones still open are still marked.
Evidence — companion lab
The claims here were later checked in a small, reproducible lab:
mongo-query-lab — numbered
experiments on a deterministic seeded dataset (same seed → same data), each one
running explain("executionStats") with no hint() and committing the raw
output under results/. MongoDB 8.0.16, classic engine, single node. It is
plan- and counter-based, not a latency benchmark, and still in progress (the
aggregation experiments are not done yet).
Relevant so far:
- 01 — COLLSCAN vs IXSCAN, 06 — Index bounds and the cost of a predicate:
keysExaminedis index-bound-traversal work; equality → point bound, range → interval. The switch toCOLLSCANis binary on "does a usable index exist", not a selectivity threshold — a range matching the whole collection still used the index. - 04 — Plan cache and query shapes, 05 — Selectivity and cached-plan
tolerance: per-shape caching, value-blind reuse, and the
decisionWorks × ratioreplan frontier.explain()is cache-free and matches a real cache-free execution's counters. - 07 —
$or/ SUBPLAN:$orof equalities on one field is canonicalised to$in; across fields it isSUBPLAN → ORwith per-branchIXSCANand record-id dedup; one unindexed branch forces aCOLLSCANfor the whole query, even if that branch matches nothing. - 08 — Multikey indexes and array bounds: multikey de-duplication
(
keysExamined > docsExaminedis normal), the un-intersected two-sided range,$elemMatchvs dotted paths,$allis not an index intersection, and covering the scalar prefix of a multikey index. - 09 —
SORT, range bounds, and the plan cache: an ESR index removes the blockingSORTand the planner prefers it at equalkeysExamined; a single reversed sort key (or a whole-pattern reversal) is served by a backward scan, a partial compound-direction conflict is not;SORTcost is linear in rows fed in, and sits below theFETCHwhen the sort field is in the index; for a{range} + sortquery the planner drops theSORTfor a wide streaming scan at ~2% selectivity, accepting several times morekeysExamined.
- mongodb
- mongoose
- query-planner
- indexes
- explain
- query-optimization
- esr-indexing-rule
Correlati
- Capire la selezione degli indici in MongoDBCome il query planner di MongoDB sceglie un indice, cosa leggere nell'output di explain e perché un indice 'corretto' viene comunque ignorato.
- Capire gli index bounds di MongoDBCosa contiene indexBounds per predicati di uguaglianza, range, $in, $or e multikey, perché keysExamined è il vero costo di un filtro, e l'unico caso in cui un COLLSCAN è inevitabile.