Eine langsame Query zu identifizieren ist einfach – man sieht sie im
Profiler oder in Application-Logs. Aber warum ist sie langsam? Nutzt sie
keinen Index? Nutzt sie den falschen Index? Scannt sie zu viele
Dokumente? Ist die Aggregation-Pipeline ineffizient? Diese Fragen
beantworten erfordert tiefere Analysis-Tools. MongoDB bietet mehrere –
explain() für detaillierte Execution-Plans,
hint() für Index-Selection-Control, benchRun()
für Load-Testing, und spezielle Techniques wie Covering-Indexes für
Maximum-Performance.
Query-Analyse ist nicht nur reaktiv – “Diese Query ist langsam, fix sie” – sondern auch proaktiv. Bevor man Code deployed, sollte man kritische Queries analysieren: Nutzen sie Indexes optimal? Wie performen sie unter Load? Wie skalieren sie mit wachsenden Daten? Diese Fragen zu beantworten vor Production-Deployment verhindert Performance-Probleme statt sie später zu debuggen.
Dieses Kapitel durchläuft fortgeschrittene Query-Analysis-Techniques systematisch – von explain-Deep-Dives über Index-Comparison mit hint() bis zu Synthetic-Load-Testing mit benchRun(). Der Fokus ist auf praktischen Workflows für Production-Query-Optimization.
Wir haben explain() bereits behandelt, aber es gibt
tiefere Details, die für Advanced-Optimization wichtig sind.
Die drei explain-Modi:
// queryPlanner: Plan ohne Execution
db.orders.find({ status: "pending" }).explain("queryPlanner")
// executionStats: Plan + Execution mit Stats
db.orders.find({ status: "pending" }).explain("executionStats")
// allPlansExecution: Alle getesteten Plans mit Stats
db.orders.find({ status: "pending" }).explain("allPlansExecution")queryPlanner vs. executionStats:
queryPlanner führt die Query NICHT aus – es zeigt nur,
welchen Plan MongoDB chosen würde. Dies ist nützlich für Quick-Checks
ohne Production-Impact. executionStats führt die Query
tatsächlich aus und misst Real-Performance. Für Performance-Tuning ist
executionStats essentiell – man braucht Real-Numbers, nicht
hypothetische Plans.
allPlansExecution für Plan-Comparison:
MongoDB’s Query-Planner evaluiert oft multiple possible Plans. Mit
allPlansExecution sieht man alle:
db.orders.find({
status: "pending",
customerId: "CUST-123"
}).explain("allPlansExecution")Output:
{
queryPlanner: {
winningPlan: {
stage: "FETCH",
inputStage: {
stage: "IXSCAN",
indexName: "status_1_customerId_1"
}
},
rejectedPlans: [
{
stage: "FETCH",
inputStage: {
stage: "IXSCAN",
indexName: "status_1"
}
},
{
stage: "FETCH",
inputStage: {
stage: "IXSCAN",
indexName: "customerId_1"
}
}
]
}
}MongoDB testete drei Indexes: 1. Winning:
{ status: 1, customerId: 1 } – Compound-Index (optimal) 2.
Rejected: { status: 1 } – Single-Field 3.
Rejected: { customerId: 1 } –
Single-Field
Der Query-Planner cached den Winner – nachfolgende identische Queries nutzen automatisch denselben Plan ohne Re-Evaluation.
executionStages Deep-Dive:
Die executionStages ist eine nested Structure, die den
Execution-Flow zeigt:
{
executionStages: {
stage: "FETCH", // Dokument-Fetch
nReturned: 10,
executionTimeMillis: 5,
docsExamined: 10,
inputStage: {
stage: "IXSCAN", // Index-Scan
indexName: "status_1_customerId_1",
keysExamined: 10,
seeks: 1,
dupsTested: 0,
dupsDropped: 0,
indexBounds: {
status: ["pending", "pending"],
customerId: ["CUST-123", "CUST-123"]
}
}
}
}Stage-Types:
Multi-Stage-Pipelines:
Komplexe Queries haben multiple Stages:
{
executionStages: {
stage: "LIMIT", // Stage 3: Limit
limitAmount: 10,
inputStage: {
stage: "SORT", // Stage 2: Sort
sortPattern: { orderDate: -1 },
inputStage: {
stage: "IXSCAN", // Stage 1: Index-Scan
indexName: "status_1"
}
}
}
}Der Flow ist: IXSCAN → SORT → LIMIT. MongoDB evaluated bottom-up – erst Index-Scan, dann Sort, dann Limit.
Performance-Bottleneck-Identification:
Schaue nach Stages mit hohen executionTimeMillis:
{
executionStages: {
stage: "SORT",
executionTimeMillis: 2300, // 2.3 Sekunden in Sort!
inputStage: {
stage: "COLLSCAN",
executionTimeMillis: 200
}
}
}Das Problem ist nicht der COLLSCAN (200ms), sondern der In-Memory-Sort (2.3s). Die Lösung: Index auf Sort-Field, um den SORT-Stage zu eliminieren.
MongoDB’s Query-Planner wählt automatisch Indexes, aber manchmal
wählt er sub-optimal. hint() erlaubt, einen spezifischen
Index zu forcieren.
Use-Case: Index-Comparison
Man hat zwei Indexes und will testen, welcher besser performed:
// Index 1: Simple auf status
db.orders.createIndex({ status: 1 })
// Index 2: Compound auf status + customerId
db.orders.createIndex({ status: 1, customerId: 1 })
// Query
const query = { status: "pending", customerId: "CUST-123" }
// Test Index 1
const result1 = db.orders.find(query)
.hint({ status: 1 })
.explain("executionStats")
// Test Index 2
const result2 = db.orders.find(query)
.hint({ status: 1, customerId: 1 })
.explain("executionStats")
// Compare executionTimeMillis
print(`Index 1: ${result1.executionStats.executionTimeMillis}ms`)
print(`Index 2: ${result2.executionStats.executionTimeMillis}ms`)Dies gibt empirische Data, welcher Index für diese Query optimal ist.
Use-Case: Planner-Override
Manchmal wählt der Planner den falschen Index (rare, aber passiert):
// Query nutzt status_1, aber customerId_1 wäre besser
db.orders.find({
status: { $in: ["pending", "processing", "completed"] }, // Viele Values
customerId: "CUST-123" // Sehr selective
}).hint({ customerId: 1 })Ohne Hint nutzt MongoDB möglicherweise status_1, weil
status im Query-Filter zuerst steht. Aber customerId ist
viel selectiver (nur ein Customer vs. viele Status-Values). Der Hint
forced den besseren Index.
Caveat: Hints sind fragil
Hints sind Index-Name-basiert. Wenn man den Index re-creates mit anderem Namen, brechen Hints:
// Index mit Name
db.orders.createIndex({ customerId: 1 }, { name: "customerId_idx" })
// Hint by Name
db.orders.find({ customerId: "CUST-123" }).hint("customerId_idx")
// Wenn Index dropped und recreated wird mit Default-Name:
db.orders.createIndex({ customerId: 1 }) // Name: customerId_1
// Hint bricht: Error: bad hintSafer: Hint by Key-Pattern statt Name:
db.orders.find({ customerId: "CUST-123" }).hint({ customerId: 1 })Eine Covered-Query ist, wo alle returned Felder im Index sind – MongoDB muss das Dokument nicht fetchen. Dies eliminiert den FETCH-Stage komplett.
Example:
// Index
db.users.createIndex({ username: 1, email: 1, status: 1 })
// Query
db.users.find(
{ username: "alice" },
{ username: 1, email: 1, status: 1, _id: 0 } // Nur indexierte Felder
)Das explain():
{
executionStages: {
stage: "PROJECTION_COVERED", // Covered!
inputStage: {
stage: "IXSCAN",
indexName: "username_1_email_1_status_1"
}
},
totalDocsExamined: 0 // Kein Dokument accessed
}totalDocsExamined: 0 ist das Signature – die Query wurde
komplett aus dem Index beantwortet.
**Warum _id: 0 kritisch ist:**
Per Default inkludiert MongoDB _id in Results. Wenn
_id nicht im Index ist, muss MongoDB das Dokument fetchen,
breaking die Covered-Query:
// Nicht Covered (weil _id inkludiert ist)
db.users.find(
{ username: "alice" },
{ username: 1, email: 1 } // Implizit: _id: 1
)
// totalDocsExamined: 1 (nicht 0!)
// Covered
db.users.find(
{ username: "alice" },
{ username: 1, email: 1, _id: 0 }
)
// totalDocsExamined: 0Performance-Impact:
Covered-Queries sind 2-10x schneller als Regular-Queries, weil: 1. Kein Disk-I/O für Dokument-Fetch 2. Weniger Memory-Usage (Index-Entries sind kleiner als Full-Documents) 3. Weniger CPU für Document-Parsing
Use-Case: APIs mit Partial-Data
Für APIs, die nur wenige Felder returnen:
// API: GET /users/{username}/summary
// Returns: { username, email, status }
// Index für Covered-Query
db.users.createIndex({ username: 1, email: 1, status: 1 })
// API-Handler
app.get('/users/:username/summary', async (req, res) => {
const user = await db.users.findOne(
{ username: req.params.username },
{ username: 1, email: 1, status: 1, _id: 0 }
)
res.json(user)
})Jede Request ist eine Covered-Query – ultra-fast.
Aggregation-Pipelines können komplex sein – dutzende Stages.
explain() zeigt Performance per Stage:
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
}},
{ $unwind: "$customer" },
{ $group: {
_id: "$customer.region",
revenue: { $sum: "$amount" }
}},
{ $sort: { revenue: -1 } }
], { explain: true })Output:
{
stages: [
{
$cursor: {
stage: "IXSCAN",
nReturned: 10000,
executionTimeMillis: 15
}
},
{
$lookup: {
nReturned: 10000,
executionTimeMillis: 2500 // Bottleneck!
}
},
{
$unwind: {
nReturned: 10000,
executionTimeMillis: 5
}
},
{
$group: {
nReturned: 50,
executionTimeMillis: 10
}
},
{
$sort: {
nReturned: 50,
executionTimeMillis: 1
}
}
]
}Der Bottleneck ist $lookup – 2.5 Sekunden. Dies ist
expected – $lookup performed N Queries (N = 10k Orders). Die
Optimization: Reduce N via earlier Filtering, oder Denormalize Data.
Optimization-Strategy:
// Vorher: $lookup auf 10k Orders
db.orders.aggregate([
{ $match: { status: "completed" } }, // 10k Orders
{ $lookup: { from: "customers", ... } } // 10k Lookups
])
// Nachher: Filter vor $lookup
db.orders.aggregate([
{ $match: {
status: "completed",
amount: { $gte: 1000 } // Nur High-Value
}}, // 1k Orders
{ $lookup: { from: "customers", ... } } // 1k Lookups
])10x weniger Lookups → 10x schneller.
benchRun() ist MongoDB’s built-in Load-Testing-Tool. Es
simuliert concurrent Clients, die Queries ausführen, und misst
Throughput/Latency.
benchRun({
ops: [
{
op: "find",
ns: "mydb.orders",
query: { status: "pending" }
}
],
parallel: 10, // 10 concurrent Clients
seconds: 30, // Run for 30 seconds
host: "localhost:27017"
})Output:
{
note: "values per second",
errCount: 0,
trapped: "error: not implemented",
insertLatencyAverageMicros: 0,
queryLatencyAverageMicros: 1234,
commandsPerSecond: 810,
findOneLatencyAverageMicros: 1234
}Key-Metrics:
Use-Case: Index-Performance-Comparison
Teste Performance mit vs. ohne Index:
// Test 1: Ohne Index
const result1 = benchRun({
ops: [{ op: "find", ns: "mydb.orders", query: { customerId: "CUST-123" } }],
parallel: 10,
seconds: 10
})
// Create Index
db.orders.createIndex({ customerId: 1 })
// Test 2: Mit Index
const result2 = benchRun({
ops: [{ op: "find", ns: "mydb.orders", query: { customerId: "CUST-123" } }],
parallel: 10,
seconds: 10
})
// Compare
print(`Without Index: ${result1.commandsPerSecond} ops/sec`)
print(`With Index: ${result2.commandsPerSecond} ops/sec`)
print(`Speedup: ${(result2.commandsPerSecond / result1.commandsPerSecond).toFixed(2)}x`)Multi-Operation-Workloads:
Realistische Workloads haben Mix aus Reads/Writes:
benchRun({
ops: [
{
op: "find",
ns: "mydb.orders",
query: { status: "pending" },
weight: 70 // 70% of operations
},
{
op: "insert",
ns: "mydb.orders",
doc: {
customerId: { "#RAND_STRING": [10] },
amount: { "#RAND_INT": [10, 1000] },
status: "pending"
},
weight: 20 // 20% of operations
},
{
op: "update",
ns: "mydb.orders",
query: { status: "pending" },
update: { $set: { status: "processing" } },
multi: false,
weight: 10 // 10% of operations
}
],
parallel: 20,
seconds: 60
})Dies simuliert ein Read-Heavy-Workload (70% Reads, 20% Inserts, 10% Updates).
Caveat: benchRun ist Synthetic
benchRun simuliert Load, aber es ist nicht Real-Application-Traffic.
Die Query-Patterns, Data-Distribution, und Concurrency-Characteristics
können unterschiedlich sein. Für Production-Performance-Validation
sollte man Real-Traffic-Replay nutzen (etwa mit
mongoreplay).
Die system.profile-Collection ist queryable via
Aggregation – man kann komplexe Analysis durchführen:
Top-10-Slow-Queries by Operation-Type:
db.system.profile.aggregate([
{ $match: { millis: { $gte: 100 } } },
{ $group: {
_id: "$op",
avgDuration: { $avg: "$millis" },
maxDuration: { $max: "$millis" },
count: { $sum: 1 }
}},
{ $sort: { avgDuration: -1 } },
{ $limit: 10 }
])Output:
[
{ _id: "aggregate", avgDuration: 2350, maxDuration: 5000, count: 45 },
{ _id: "query", avgDuration: 450, maxDuration: 2000, count: 523 },
{ _id: "update", avgDuration: 120, maxDuration: 500, count: 234 }
]Aggregations sind im Durchschnitt am langsamsten – Focus-Area für Optimization.
Queries ohne Index (COLLSCAN) identifizieren:
db.system.profile.aggregate([
{ $match: {
"command.filter": { $exists: true },
planSummary: /COLLSCAN/
}},
{ $group: {
_id: {
ns: "$ns",
filter: "$command.filter"
},
count: { $sum: 1 },
avgDuration: { $avg: "$millis" }
}},
{ $sort: { count: -1 } },
{ $limit: 10 }
])Dies gibt die Top-10-häufigsten COLLSCAN-Queries – Prime-Candidates für neue Indexes.
Query-Duration-Distribution:
db.system.profile.aggregate([
{ $bucket: {
groupBy: "$millis",
boundaries: [0, 10, 50, 100, 500, 1000, 5000, Infinity],
default: "Over 5s",
output: {
count: { $sum: 1 },
queries: { $push: "$command" }
}
}}
])Output:
[
{ _id: 0, count: 5234 }, // 0-10ms: 5234 queries
{ _id: 10, count: 1523 }, // 10-50ms: 1523 queries
{ _id: 50, count: 456 }, // 50-100ms: 456 queries
{ _id: 100, count: 123 }, // 100-500ms: 123 queries
{ _id: 500, count: 45 }, // 500-1000ms: 45 queries
{ _id: 1000, count: 12 } // 1000-5000ms: 12 queries
]Die meisten Queries sind < 10ms (gut), aber 12 Queries sind > 1 second (problematisch).
Ein systematischer Query-Optimization-Workflow kombiniert alle Tools:
Phase 1: Detection
// 1. Enable Profiler für Slow-Queries
db.setProfilingLevel(1, { slowms: 100 })
// 2. Wait für Production-Traffic (Stunden/Tage)
// 3. Identify häufigste Slow-Queries
const slowQueries = db.system.profile.aggregate([
{ $match: { millis: { $gte: 100 } } },
{ $group: {
_id: { ns: "$ns", filter: "$command.filter" },
count: { $sum: 1 },
avgDuration: { $avg: "$millis" }
}},
{ $sort: { count: -1 } },
{ $limit: 5 }
]).toArray()Phase 2: Analysis
// Für jede Slow-Query: Run explain
slowQueries.forEach(query => {
const explainOutput = db[getCollectionFromNs(query._id.ns)]
.find(query._id.filter)
.explain("executionStats")
print(`Query: ${JSON.stringify(query._id.filter)}`)
print(`Stage: ${explainOutput.executionStats.executionStages.stage}`)
print(`DocsExamined: ${explainOutput.executionStats.totalDocsExamined}`)
print(`Duration: ${explainOutput.executionStats.executionTimeMillis}ms`)
print("---")
})Phase 3: Hypothesis
Basierend auf explain: - COLLSCAN → Create Index - Inefficient Index (high docsExamined) → Better Compound-Index - SORT-Stage → Index mit Sort-Field
Phase 4: Implementation
// Create Index
db.orders.createIndex({ status: 1, customerId: 1 }, { background: true })
// Validate mit explain
const afterExplain = db.orders.find({ status: "pending", customerId: "CUST-123" })
.explain("executionStats")
print(`Before: COLLSCAN, ${beforeExplain.executionStats.executionTimeMillis}ms`)
print(`After: IXSCAN, ${afterExplain.executionStats.executionTimeMillis}ms`)Phase 5: Load-Testing
// Benchmark Query under Load
const benchmark = benchRun({
ops: [{
op: "find",
ns: "mydb.orders",
query: { status: "pending", customerId: "CUST-123" }
}],
parallel: 50, // Heavy Load
seconds: 30
})
print(`Throughput: ${benchmark.commandsPerSecond} ops/sec`)
print(`Latency: ${benchmark.queryLatencyAverageMicros / 1000}ms`)Phase 6: Monitoring
// Monitor Index-Usage über Zeit
setInterval(() => {
const usage = db.orders.aggregate([{ $indexStats: {} }])
.toArray()
.find(idx => idx.name === "status_1_customerId_1")
print(`Index Usage: ${usage.accesses.ops} operations`)
}, 3600000) // Every hourDie folgende Tabelle fasst Analysis-Tools zusammen:
| Tool | Use-Case | Output | Best-For |
|---|---|---|---|
explain() |
Single-Query-Analysis | Execution-Plan, Stats | Query-Optimization |
hint() |
Index-Selection-Control | Forced-Index-Usage | Index-Comparison |
| Covering-Index | Zero-Fetch-Queries | Fastest-Possible-Query | Read-Heavy-APIs |
benchRun() |
Load-Testing | Throughput, Latency | Capacity-Planning |
system.profile Aggregations |
Pattern-Analysis | Aggregate-Stats | Trend-Identification |
Query-Analyse ist nicht einmalig – “Optimiere diese Query” – sondern kontinuierlich. New-Features deployed, Query-Patterns ändern sich, Data-Volumes wachsen. Was heute optimal ist, kann morgen sub-optimal sein. Der Best-Practice-Approach ist kontinuierliches Monitoring (Profiler always-on), regelmäßige Analysis (weekly Reviews der Slow-Queries), datengetriebene Optimization (explain + benchmarks), und Validation nach Changes. Mit systematischem Query-Analysis wird Performance von reactive debugging zu proactive optimization – kontinuierlich messbar, systematisch verbesserbar, und empirisch validiert.