The slow query workflow: profiler, logs, explain, fix
A repeatable process for finding the queries that matter, diagnosing them, fixing them and proving the fix, as practised by DBAs on production systems.
- Find slow operations with the profiler, logs and currentOp
- Group by query shape and rank by total cost
- Diagnose with explain and fix with indexes or query changes
- Verify and protect against regressions (maxTimeMS, hidden indexes)
conceptThe concept
- Find: slow query log lines (operations above
slowms, default 100 ms), the database profiler (system.profile),db.currentOp()for what is running now, and monitoring tools (mongostat, FTDC charts). - Prioritise: rank query shapes by total time (count x average), not by the single slowest execution. A 20 ms query run 50,000 times a minute matters more than a 5 s report run nightly.
- Diagnose:
planSummary(COLLSCAN? which index?),keysExamined/docsExaminedversusnreturned,hasSortStage,planningTimeMicros, lock and storage waits. Reproduce withexplain("executionStats"). - Fix: add or reorder an index (ESR), change the query (projection, avoid unanchored regex, avoid
$ne), change the schema (computed/subset), or move the workload (secondary, analytics node). - Verify: explain again, watch latency percentiles, check write impact of new indexes, add
maxTimeMSto protect the system.
whyWhy it matters
Performance work without a process turns into guessing. This loop is what interviewers want to hear for "the application is slow, what do you do?", and it is what keeps production stable.
Use caseReal-world scenario
CPU on the primary jumps to 90% after a release. The slow log shows thousands of find on orders with planSummary: COLLSCAN and docsExamined: 5000 for nreturned: 3, filtered on payment.status and sorted by createdAt. A new index { "payment.status": 1, createdAt: -1 } built during low traffic brings CPU back to 15%.
Try itExamples
Run and edit these in the interactive lesson.
db.setProfilingLevel(1, { slowms: 5 })
db.orders.find({ "payment.status": "awaiting" }).sort({ createdAt: -1 }).limit(10).toArray().length
db.orders.find({ "shipping.city": "Pune", total: { $gt: 1000 } }).toArray().length
db.system.profile.find({}, { op: 1, ns: 1, command: 1, planSummary: 1, keysExamined: 1, docsExamined: 1, nreturned: 1, millis: 1 }).sort({ ts: -1 }).limit(2)db.system.profile.aggregate([
{ $group: { _id: { ns: "$ns", plan: "$planSummary" }, count: { $sum: 1 }, totalMs: { $sum: "$millis" }, avgDocs: { $avg: "$docsExamined" } } },
{ $sort: { totalMs: -1 } }
])db.orders.find({ "payment.status": "awaiting" }).sort({ createdAt: -1 }).limit(10).explain("executionStats")
db.orders.createIndex({ "payment.status": 1, createdAt: -1 })
db.orders.find({ "payment.status": "awaiting" }).sort({ createdAt: -1 }).limit(10).explain("executionStats")db.orders.find({ "shipping.city": "Pune" }).maxTimeMS(2000).limit(3).toArray().length
db.currentOp({ active: true, secs_running: { $gt: 5 } })
db.setProfilingLevel(0)internalsUnder the hood
The profiler writes to a capped collection system.profile on each node (level 1: slow ops only, level 2: everything; level 2 is expensive). Slow operations are also logged as JSON lines with attr.queryHash/planCacheKey, so you can aggregate logs by shape. On Atlas (7.1+), $queryStats exposes aggregated query statistics per shape; it is not a self-managed feature, so on your own clusters you get the same view from the slow-query log grouped by queryHash, or from PMM Query Analytics.
Avoid / preferDo this, not that
db.setProfilingLevel(2) // and leave it on
db.setProfilingLevel(1, { slowms: 100, sampleRate: 0.5 })Level 2 records every operation, adds a write per operation and churns the capped system.profile collection. Level 1 with a threshold and sampling captures what matters.
db.system.profile.find().sort({ millis: -1 }).limit(1)db.system.profile.aggregate([
{ $group: { _id: { ns: "$ns", plan: "$planSummary" }, n: { $sum: 1 }, totalMs: { $sum: "$millis" } } },
{ $sort: { totalMs: -1 } }, { $limit: 5 }
])The single slowest query is rarely the biggest cost. A 40 ms query run a million times a day outweighs one 2-second report; rank by total time.
PitfallsCommon mistakes
- Leaving profiler level 2 on in production.
- Optimizing the slowest single query instead of the biggest total consumer.
- Adding an index for every slow query without checking existing indexes that could be extended (prefix reuse).
- Not testing index builds and their write overhead on a production-like dataset.
- Fixing a query on the primary while forgetting that secondaries serve reads with different plan caches and index usage counters.
LimitsWhen not to use it
The profiler is per database and per node, and writes to the node it profiles. On a busy production system rely on the slow query log and a tool that aggregates it (PMM Query Analytics, for example) rather than leaving the profiler on.
SecuritySecurity notes
Profiler entries and slow-query log lines include query filters with their literal values, so they can contain personal data. Restrict who can read system.profile and the log files, and consider log redaction where available.
ChallengeTry it yourself
Turn on the profiler for every operation (slowms 0), run two different queries, then summarize the profile by namespace and plan with docs examined and returned.
Show one solution
db.setProfilingLevel(1, { slowms: 0 })
db.orders.find({ status: "paid" }).toArray().length
db.products.find({ category: "books" }).toArray().length
db.system.profile.aggregate([
{ $group: { _id: { ns: "$ns", plan: "$planSummary" }, ops: { $sum: 1 }, docsExamined: { $sum: "$docsExamined" }, returned: { $sum: "$nreturned" } } },
{ $sort: { docsExamined: -1 } }
])InterviewQuestions you should be able to answer
The application is slow. Walk me through how you investigate.
Confirm the symptom (latency percentiles, which endpoints). Check server health (CPU, disk latency, cache dirty/used %, tickets/queues, replication lag, connections). Find heavy operations: slow query log grouped by query shape, profiler, currentOp. Rank by total time. For the top shapes, read planSummary and examined/returned ratios, reproduce with explain, then fix with ESR indexes, query rewrites or schema changes. Verify after deploy and add maxTimeMS and alerts so it does not regress silently.
What does a high docsExamined/nreturned ratio indicate?
The server reads many documents to return few: either no suitable index (COLLSCAN), an index on a weakly selective field followed by a FETCH filter, or a missing field in a compound index. Aim for a ratio close to 1.
What is the plan cache and when can it hurt?
It caches the winning plan per query shape so planning is not repeated. It can hurt when data distribution differs by parameter value: a plan that was great for a rare value is reused for a common one. MongoDB replans when a cached plan performs much worse than expected; you can also clear it (db.coll.getPlanCache().clear()), use an index filter, or fix the index design so one plan is good for all values.
QuizCheck your understanding
4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.