MongoDB Indexing Playbook (Profiler + Slow Query Analysis)¶
This document explains how we identify slow MongoDB queries, group similar query shapes, and turn findings into index improvements using system.profile.
Be careful in production
The MongoDB profiler adds overhead and can grow the system.profile collection.
Keep it enabled only for short, targeted windows and disable it when done.
Purpose and Scope¶
- Enable/disable MongoDB Profiler to capture slow operations.
-
Inspect slow operations in
system.profile: -
List individual worst offenders.
- Group similar query shapes (filter/sort/limit/plan) to prioritize work.
- Convert grouped findings into actionable index work.
Prerequisites¶
- Access to
mongoshwith appropriate permissions. - List of databases to inspect.
- A slow threshold (example:
slowms=1000for 1 second).
1) Enable Profiler to Capture Slow Queries¶
1.1 Single database: enable/disable¶
// Enable profiling for slow operations (>= 1s)
db.setProfilingLevel(1, { slowms: 1000 });
// Disable profiling and show the current status
db.setProfilingLevel(0) && db.getProfilingStatus();
Profiler levels
setProfilingLevel(1, { slowms: X }): records only slow operations.setProfilingLevel(2): records all operations (generally not recommended for production).
1.2 Multiple databases: print status + apply a change¶
The script below:
- Prints current profiler status per DB.
- Applies a change (example shown: disable profiler for each DB).
const dbs = [
"buildStore",
"notificationStore",
"distributionStore",
"licenseStore",
"reportingStore",
"storeSubmitStore",
"resourceStore",
"enterpriseStore",
"publishStore",
"signingIdentityStore",
"webhookStore",
"resignStore",
"agentCacheStore"
];
// 1) Print current profiler settings
print("=== BEFORE ===");
for (const name of dbs) {
const d = db.getSiblingDB(name);
const st = d.getProfilingStatus();
print(name + " => was: " + st.was + " slowms: " + st.slowms + " sampleRate: " + st.sampleRate);
}
// 2) Apply profiler changes (example: disable)
print("=== APPLY ===");
for (const name of dbs) {
const d = db.getSiblingDB(name);
d.setProfilingLevel(0); // change to (1, { slowms: 1000 }) when you want to enable
const st = d.getProfilingStatus();
print(name + " => now: " + st.was + " slowms: " + st.slowms + " sampleRate: " + st.sampleRate);
}
Enabling across DBs
To enable profiling across DBs for slow ops, replace setProfilingLevel(0) with:
d.setProfilingLevel(1, { slowms: 1000 });
2) List Individual Slow Operations¶
Use this query to see the slowest operations (example: >= 3000ms), including planSummary and the full command payload.
db.system.profile.find(
{ millis: { $gte: 3000 }, op: { $in: ["query","command"] } },
{ ts:1, ns:1, millis:1, planSummary:1, keysExamined:1, docsExamined:1, nreturned:1, client:1, user:1, command:1 }
).sort({ millis: -1 }).limit(10)
What to look at first
planSummary:COLLSCANvsIXSCAN(and which index)keysExaminedvsdocsExamined: indicates scan/selectivity issuesnreturned: large result sets may needlimit/ paginationcommand: filter + sort + limit shape
3) Group Similar Query Shapes (Aggregation)¶
Instead of reading slow queries one by one, group similar operations to prioritize index work.
This pipeline:
- Extracts filter keys, sort keys,
limit, and some operator info (logTimeOpsexample). - Builds a compact
cmdStrrepresentation. -
Groups by a “query shape” tuple:
-
ns,planSummary,find/aggregate,limit,filterKeys,sortKeys,logTimeOps - Outputs frequency and timing statistics per group.
db.system.profile.aggregate([
{ $match: { millis: { $gte: 1000 }, op: { $in: ["query","command"] } } },
{ $project: {
ns: 1, millis: 1, planSummary: 1, ts: 1, client: 1, user: 1,
find: "$command.find",
limit: "$command.limit",
command: 1,
// filter keys array
filterKeys: {
$cond: [
{ $ne: ["$command.filter", null] },
{ $map: { input: { $objectToArray: "$command.filter" }, as: "kv", in: "$$kv.k" } },
[]
]
},
// logTime ops array ($lt, $gte, ...)
logTimeOps: {
$cond: [
{ $ne: ["$command.filter.logTime", null] },
{ $map: { input: { $objectToArray: "$command.filter.logTime" }, as: "kv", in: "$$kv.k" } },
[]
]
},
// sort keys array
sortKeys: {
$cond: [
{ $ne: ["$command.sort", null] },
{ $map: { input: { $objectToArray: "$command.sort" }, as: "kv", in: "$$kv.k" } },
[]
]
},
// ---- Build a command string (join arrays) ----
cmdStr: {
$let: {
vars: {
dbn: "$command.$db",
cmdName: { $ifNull: ["$command.find", { $ifNull: ["$command.aggregate", "?"] }] },
fkStr: {
$reduce: {
input: {
$cond: [
{ $ne: ["$command.filter", null] },
{ $map: { input: { $objectToArray: "$command.filter" }, as: "kv", in: "$$kv.k" } },
[]
]
},
initialValue: "",
in: { $concat: ["$$value", { $cond: [{ $eq: ["$$value", ""] }, "", ","] }, "$$this"] }
}
},
skStr: {
$reduce: {
input: {
$cond: [
{ $ne: ["$command.sort", null] },
{ $map: { input: { $objectToArray: "$command.sort" }, as: "kv", in: "$$kv.k" } },
[]
]
},
initialValue: "",
in: { $concat: ["$$value", { $cond: [{ $eq: ["$$value", ""] }, "", ","] }, "$$this"] }
}
},
ltOpsStr: {
$reduce: {
input: {
$cond: [
{ $ne: ["$command.filter.logTime", null] },
{ $map: { input: { $objectToArray: "$command.filter.logTime" }, as: "kv", in: "$$kv.k" } },
[]
]
},
initialValue: "",
in: { $concat: ["$$value", { $cond: [{ $eq: ["$$value", ""] }, "", ","] }, "$$this"] }
}
},
limStr: { $toString: { $ifNull: ["$command.limit", 0] } }
},
in: {
$concat: [
"db=", { $ifNull: ["$$dbn", "?"] },
" cmd=", "$$cmdName",
" filterKeys=[", "$$fkStr", "]",
" logTimeOps=[", "$$ltOpsStr", "]",
" sortKeys=[", "$$skStr", "]",
" limit=", "$$limStr"
]
}
}
}
}},
{ $group: {
_id: {
ns: "$ns",
planSummary: "$planSummary",
find: "$find",
limit: "$limit",
filterKeys: "$filterKeys",
logTimeOps: "$logTimeOps",
sortKeys: "$sortKeys"
},
count: { $sum: 1 },
avgMs: { $avg: "$millis" },
maxMs: { $max: "$millis" },
commandSample: { $first: "$cmdStr" },
commandObjSample: { $first: "$command" },
sample: { $first: "$$ROOT" }
}},
{ $sort: { maxMs: -1 } },
{ $limit: 20 }
]).forEach(d => print(JSON.stringify(d)))
How to read the grouped output
_id.ns: DB collection (db.collection)_id.planSummary: plan summary (COLLSCAN/IXSCAN ...)_id.filterKeys: filter fields (index candidates)_id.sortKeys: sort fields (compound index order)count: frequency (helps prioritization)avgMs,maxMs: performance impactcommandObjSample: one raw command example (most important)
4) Example Output and Interpretation¶
Example group result (trimmed):
{"_id":{"ns":"notificationStore.ClientNotifications","planSummary":"IXSCAN { organizationId: 1, clientMessageType: 1, deletedBy: 1 }","filterKeys":null,"logTimeOps":null,"sortKeys":null},"count":14,"avgMs":2064.5,"maxMs":6574,"commandSample":null,"commandObjSample":{"aggregate":"ClientNotifications","pipeline":[{"$match":{"$or":[{"organizationId":"...","userId":"..."},{"organizationId":"...","isBroadcastToOrganization":true,"userId":{"$ne":"..."}}],"deletedBy":{"$ne":"..."},"clientMessageType":1}}, ...],"$db":"notificationStore"}}
What this suggests:
- An index is being used (
IXSCAN), but the query group still has high latency (avg ~2s, max ~6.5s). -
The first
$matchstage in the pipeline drives index selection. In this example, fields like: -
organizationId userIdisBroadcastToOrganizationdeletedBy(note$ne)clientMessageTypeappear in the match stage.
Aggregate pipelines and index decisions
- The highest ROI is usually improving the
$matchstage selectivity. $orqueries often require indexes that support each branch.- Negative predicates like
$nemay reduce index efficiency and sometimes require a query/model adjustment.
5) Turning Findings into Index Work¶
5.1 Confirm with explain()¶
Profiler data shows symptoms. Always confirm with explain("executionStats") before committing index changes.
db.getCollection("ClientNotifications")
.explain("executionStats")
.aggregate([
{ $match: { /* match from commandObjSample */ } },
{ $project: { /* ... */ } },
{ $group: { /* ... */ } }
])
Key signals:
totalKeysExaminedvstotalDocsExaminedexecutionTimeMillis- presence of
COLLSCAN, largeFETCH, or poor selectivity
5.2 Practical index heuristics¶
- Equality filters (
field: value) usually come first in a compound index. - Sort fields should be included in the index in the right order if sorting is required.
- Range predicates (
$gt/$lt/$gte/$lte) typically come later. - With
$or, consider each branch separately. $ne/$nincan limit index usefulness.
5.3 Operational safety¶
- Create/modify indexes during low-traffic windows.
- Observe the query plans after rollout.
- Keep profiler on for a short period to measure impact.
- Disable profiling afterwards.
6) Disable Profiler and Wrap Up¶
db.setProfilingLevel(0) && db.getProfilingStatus();
Retention of profiler data
If profiling is enabled for extended periods, system.profile can grow significantly.
Keep profiling time-boxed and consider an operational retention strategy.
Appendix: Quick Command Set¶
Enable profiling for slow ops (>= 1s)¶
db.setProfilingLevel(1, { slowms: 1000 });
Disable profiling + status¶
db.setProfilingLevel(0) && db.getProfilingStatus();
Top 10 slow operations (>= 3s)¶
db.system.profile.find(
{ millis: { $gte: 3000 }, op: { $in: ["query","command"] } },
{ ts:1, ns:1, millis:1, planSummary:1, keysExamined:1, docsExamined:1, nreturned:1, client:1, user:1, command:1 }
).sort({ millis: -1 }).limit(10)
Group query shapes (>= 1s)¶
Use the aggregation pipeline in Section 3.