44 Query-Analyse: Von Symptomen zu Root-Causes

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.

44.1 explain() Deep-Dive: Execution-Plan-Details verstehen

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.

44.2 hint(): Index-Selection forcieren

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 hint

Safer: Hint by Key-Pattern statt Name:

db.orders.find({ customerId: "CUST-123" }).hint({ customerId: 1 })

44.3 Covering Indexes: Die schnellste Query ist keine Query

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: 0

Performance-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.

44.4 Aggregation-Pipeline-Analysis: Stage-by-Stage-Performance

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.

44.5 benchRun(): Synthetic-Load-Testing

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).

44.6 system.profile-Aggregations: Pattern-Analysis

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).

44.7 Practical Workflow: Query-Optimization-Process

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 hour

Die 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.