41 Indexierung: Von Sekunden zu Millisekunden

Eine Query ohne Index ist ein Full-Collection-Scan – MongoDB muss jedes Dokument öffnen, evaluieren und entscheiden, ob es matched. Bei 100 Dokumenten ist dies trivial. Bei 1 Million Dokumenten dauert es Sekunden. Bei 100 Millionen ist es praktisch unbenutzbar. Ein Index ändert alles – dieselbe Query, die Sekunden dauerte, completed in Millisekunden. Dies ist nicht inkrementelle Verbesserung, sondern transformativ. Indexes sind der Unterschied zwischen einer langsamen, frustrierenden Datenbank und einer schnellen, responsiven.

Aber Indexes sind nicht kostenlos. Sie konsumieren Storage – bei großen Collections Gigabytes. Sie verlangsamen Writes – jedes Insert/Update muss alle Indexes aktualisieren. Sie erfordern RAM – MongoDB cached hot Indexes in Memory. Für Production-Systeme ist Index-Design eine der kritischsten Performance-Entscheidungen. Zu wenige Indexes → langsame Queries. Zu viele Indexes → langsame Writes, excessive RAM-Usage. Die Kunst ist, genau die richtigen Indexes zu haben – nicht mehr, nicht weniger.

Dieses Kapitel behandelt Indexierung systematisch – von Basics (wie Indexes funktionieren) über Index-Typen (Single-Field, Compound, Text, Geospatial) bis zu fortgeschrittenen Strategien (Covered-Queries, Index-Intersection, Partial-Indexes). Der Fokus ist auf praktischem Index-Design für Production-Workloads.

41.1 Wie Indexes funktionieren: B-Tree-Grundlagen

MongoDB nutzt B-Tree-Indexes (genauer: B+ Trees) – eine Datenstruktur, die schnelle Lookups, Range-Queries und Sorted-Access erlaubt. Ein B-Tree ist ein Self-Balancing-Tree, wo jeder Node multiple Keys und Pointers hat.

Ohne Index:

// Query ohne Index
db.users.find({ email: "alice@example.com" })

// MongoDB muss:
// 1. Jedes Dokument in users lesen
// 2. email-Field extrahieren
// 3. Mit "alice@example.com" vergleichen
// 4. Matching Dokumente returnieren
// Bei 1M Dokumenten: ~1M Checks, mehrere Sekunden

Mit Index:

// Index erstellen
db.users.createIndex({ email: 1 })

// Selbe Query
db.users.find({ email: "alice@example.com" })

// MongoDB nutzt jetzt:
// 1. Index-Lookup: O(log N) statt O(N)
// 2. Direkt zu Dokument springen
// Bei 1M Dokumenten: ~20 Checks (log₂ 1M ≈ 20), Millisekunden

Der Index ist eine separate Datenstruktur – ein sorted B-Tree von email → _id-Mappings. MongoDB traversiert den Tree, findet “alice@example.com” schnell (logarithmisch statt linear), und nutzt die _id um das Dokument zu fetchen.

Index-Overhead:

Indexes sind nicht magisch – sie haben Costs:

  1. Storage: Der Index speichert jedes indizierte Value + Pointer. Bei 1M Users mit email-Index: ~30-50 MB zusätzlicher Storage.
  2. Write-Performance: Jedes Insert/Update muss den Index updaten. Ein Insert auf einer Collection mit 5 Indexes muss 5 Index-Structures modifizieren.
  3. RAM-Requirements: Hot Indexes sollten in RAM sein. Wenn der Working-Set-Index nicht in RAM passt, werden Disk-Seeks nötig – Performance degrades dramatisch.

Die Frage ist nicht “sollte ich Indexes haben” (ja, definitiv), sondern “welche Indexes brauche ich wirklich”.

41.2 Single-Field-Indexes: Die Basics

Die einfachste Index-Form ist ein Single-Field-Index:

db.products.createIndex({ category: 1 })

Das 1 bedeutet ascending-Order. -1 wäre descending. Für Single-Field-Indexes ist die Direction meist irrelevant (MongoDB kann den Index in beide Directions traversieren).

Use-Cases:

// Profitiert von Index auf price
db.products.find({ price: { $gte: 100, $lte: 500 } }).sort({ price: 1 })

**Index auf _id:**

MongoDB erstellt automatisch einen Unique-Index auf _id. Dies ist der einzige Index, der immer existiert. Man kann ihn nicht droppen (außer man droppt die Collection).

Unique-Indexes:

Für Uniqueness-Constraints:

db.users.createIndex({ email: 1 }, { unique: true })

// Duplicate insert fails
db.users.insertOne({ email: "alice@example.com" })  // OK
db.users.insertOne({ email: "alice@example.com" })  // Error: E11000

Unique-Indexes sind nicht nur Performance-Tool, sondern auch Constraint-Enforcement.

41.3 Compound-Indexes: Multi-Field-Queries

Für Queries, die multiple Felder filtern oder sortieren, sind Compound-Indexes essentiell.

db.orders.createIndex({ customerId: 1, orderDate: -1 })

// Nutzt Index optimal
db.orders.find({ customerId: "CUST-123" }).sort({ orderDate: -1 })

Index-Prefix-Rule:

Ein Compound-Index { a: 1, b: 1, c: 1 } kann folgende Queries supporten:

Aber NICHT:

Dies ist die “Left-Prefix-Rule” – der Index ist nur nutzbar wenn die leftmost Felder im Query sind.

Beispiel:

db.users.createIndex({ country: 1, city: 1, age: 1 })

// Nutzt Index
db.users.find({ country: "US", city: "New York" })

// Nutzt Index NICHT effizient (missing country)
db.users.find({ city: "New York", age: 25 })

Für die zweite Query bräuchte man einen separaten Index auf { city: 1, age: 1 }.

Equality, Range, Sort (ESR) Rule:

Für Compound-Indexes: Equality-Felder zuerst, dann Range-Felder, dann Sort-Felder.

// Query: status = "active", amount > 100, sort by createdAt
db.orders.find({ 
  status: "active", 
  amount: { $gt: 100 } 
}).sort({ createdAt: -1 })

// Optimaler Index: ESR-Rule
db.orders.createIndex({ 
  status: 1,      // E: Equality
  amount: 1,      // R: Range
  createdAt: -1   // S: Sort
})

Wenn man die Order vertauscht (etwa amount vor status), ist der Index weniger effizient – MongoDB kann nicht optimal filtern.

Direction-Matters für Compound-Indexes:

Bei Compound-Indexes mit Sorts ist Direction wichtig:

// Index
db.products.createIndex({ category: 1, price: -1 })

// Nutzt Index optimal
db.products.find({ category: "Electronics" }).sort({ price: -1 })

// Nutzt Index SUB-optimal (reverse direction)
db.products.find({ category: "Electronics" }).sort({ price: 1 })

MongoDB kann den Index in reverse traversieren, aber es ist weniger effizient. Für Production sollte die Index-Direction dem Sort matchen.

41.4 Covered Queries: Index-Only-Performance

Eine Covered-Query ist eine Query, wo alle returned Felder im Index sind – MongoDB muss das Dokument nicht fetchen.

// Index
db.users.createIndex({ username: 1, email: 1 })

// Covered Query
db.users.find(
  { username: "alice" },
  { username: 1, email: 1, _id: 0 }  // Projection: nur indexierte Felder
)

MongoDB kann diese Query komplett aus dem Index beantworten – kein Dokument-Access nötig. Dies ist die schnellste mögliche Query.

**Warum _id: 0?**

Per Default returned MongoDB _id. Wenn _id nicht im Index ist, muss MongoDB das Dokument fetchen. Durch _id: 0 suppressed man dies, erlaubt Covered-Query.

Explain zeigt Covered Queries:

db.users.find(
  { username: "alice" },
  { username: 1, email: 1, _id: 0 }
).explain("executionStats")

// Output enthält:
// totalDocsExamined: 0  <- Kein Dokument accessed!
// totalKeysExamined: 1  <- Nur Index

totalDocsExamined: 0 ist das Signature eines Covered-Query.

41.5 Sparse und Partial Indexes: Selective Indexing

Sparse-Indexes:

Per Default indexiert MongoDB auch Dokumente, wo das Field fehlt (als null). Sparse-Indexes überspringen diese:

db.users.createIndex({ phoneNumber: 1 }, { sparse: true })

Dies spart Storage – wenn nur 10% der Users ein Phone-Number haben, ist der Index 90% kleiner.

Caveat:

Sparse-Indexes können zu unerwarteten Results führen:

// Sparse Index
db.users.createIndex({ phoneNumber: 1 }, { sparse: true })

// Query für missing phone numbers
db.users.find({ phoneNumber: { $exists: false } })
// Nutzt NICHT den Index (weil der Index diese Dokumente nicht hat)

Partial-Indexes:

Mächtiger als Sparse – man kann beliebige Conditions definieren:

db.orders.createIndex(
  { orderDate: 1 },
  { 
    partialFilterExpression: { 
      status: "completed",
      amount: { $gte: 100 }
    }
  }
)

Dieser Index enthält nur completed Orders >= $100. Für Queries auf diese Subset ist der Index viel kleiner und effizienter.

Use-Case:

Für Collections, wo 90% der Dokumente “archived” sind und Queries fast immer auf “active” filtern:

db.documents.createIndex(
  { createdAt: -1 },
  { partialFilterExpression: { status: "active" } }
)

Der Index ist 10x kleiner (nur active Dokumente), Queries auf active sind 10x schneller.

Für Full-Text-Search auf String-Feldern:

db.articles.createIndex({ content: "text" })

// Search
db.articles.find({ $text: { $search: "mongodb indexing" } })

Text-Indexes tokenizen Strings, removen Stopwords, und supporten Ranking:

db.articles.find(
  { $text: { $search: "mongodb performance" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })

Dies returned Articles sortiert nach Relevanz (wie viele Query-Terms matchen).

Text-Index-Limitations:

Multi-Field-Text-Indexes:

db.articles.createIndex({ 
  title: "text", 
  content: "text",
  tags: "text"
})

Die Search durchsucht alle drei Felder.

Use-Case:

E-Commerce-Product-Search, Blog-Content-Search, Document-Management-Systems.

Alternative:

Für komplexere Text-Search (Faceting, Fuzzy-Search, Autocomplete) sollte man dedicated Search-Engines wie Elasticsearch nutzen. MongoDB’s Text-Indexes sind für Basic-Full-Text ausreichend, aber nicht für Advanced-Search-Features.

41.7 Geospatial-Indexes: Location-Based-Queries

Für geografische Daten (Coordinates) gibt es spezielle Indexes:

db.restaurants.createIndex({ location: "2dsphere" })

// Find restaurants within 5km
db.restaurants.find({
  location: {
    $near: {
      $geometry: {
        type: "Point",
        coordinates: [13.405, 52.520]  // Berlin
      },
      $maxDistance: 5000  // Meters
    }
  }
})

Der 2dsphere-Index unterstützt Queries wie: - $near: Find nearest - $geoWithin: Inside Polygon/Circle - $geoIntersects: Intersecting with Shape

GeoJSON-Format:

Location-Data muss GeoJSON-Format haben:

{
  location: {
    type: "Point",
    coordinates: [longitude, latitude]  // Wichtig: lon first!
  }
}

Use-Case:

Store-Locator, Delivery-Radius-Queries, Geo-Fencing.

41.8 TTL-Indexes: Auto-Expire-Documents

Für temporäre Daten (Sessions, Logs, Cache):

db.sessions.createIndex(
  { createdAt: 1 },
  { expireAfterSeconds: 3600 }  // 1 hour
)

db.sessions.insertOne({
  userId: "USER-123",
  createdAt: new Date()
})

MongoDB’s Background-Task deleted automatisch Dokumente, wo createdAt + 3600 seconds < now. Dies läuft alle 60 Sekunden.

Caveats:

Use-Case:

Session-Management, Temporary-Token-Storage, Log-Retention-Policies.

41.9 Hashed-Indexes: Für Sharding

Hashed-Indexes hashen den Field-Value vor Indexing:

db.users.createIndex({ userId: "hashed" })

Primary-Use-Case:

Shard-Keys. Hashed-Shard-Keys distributed Daten gleichmäßig, auch wenn die Original-Values sequential sind.

Limitation:

Hashed-Indexes unterstützen nur Equality-Queries, keine Range-Queries:

// Funktioniert
db.users.find({ userId: "USER-123" })

// Funktioniert NICHT
db.users.find({ userId: { $gt: "USER-100" } })

41.10 Explain: Index-Usage analysieren

Die explain()-Method ist essentiell für Index-Tuning:

db.users.find({ email: "alice@example.com" }).explain("executionStats")

Wichtige Output-Felder:

{
  executionStats: {
    executionTimeMillis: 5,
    totalKeysExamined: 1,
    totalDocsExamined: 1,
    executionStages: {
      stage: "IXSCAN",      // Index-Scan (gut)
      indexName: "email_1"
    }
  }
}

Key-Metrics:

Metric Bedeutung Ideal
executionTimeMillis Query-Duration < 100ms
totalKeysExamined Index-Keys gescannt Nah an returned Docs
totalDocsExamined Dokumente gescannt Gleich returned Docs (Covered Query: 0)
stage Execution-Strategy IXSCAN (nicht COLLSCAN)

Bad Signs:

Example-Diagnosis:

// Query
db.orders.find({ 
  status: "pending",
  amount: { $gte: 100 }
}).sort({ createdAt: -1 })

// Explain zeigt:
// stage: COLLSCAN
// totalDocsExamined: 1000000

// Lösung: Index erstellen
db.orders.createIndex({ status: 1, amount: 1, createdAt: -1 })

// Nach Index:
// stage: IXSCAN
// totalDocsExamined: 523 (nur relevante)

41.11 Index-Selection: MongoDB’s Query-Planner

Wenn multiple Indexes existieren, die eine Query supporten könnten, wählt MongoDB’s Query-Planner den “besten”:

db.orders.createIndex({ status: 1 })
db.orders.createIndex({ customerId: 1 })
db.orders.createIndex({ status: 1, customerId: 1 })

// Query
db.orders.find({ status: "pending", customerId: "CUST-123" })

MongoDB evaluiert alle drei Indexes, führt Trial-Runs durch, und cached den Winner. Dies passiert automatisch.

Index-Hints (Override):

Man kann den Planner overriden:

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

Dies forced den Compound-Index. Hints sollten nur genutzt werden wenn man sicher ist, dass der Planner falsch wählt (rare).

41.12 Index-Maintenance: Listing, Dropping, Rebuilding

List Indexes:

db.users.getIndexes()

Output zeigt alle Indexes mit Names, Keys, Options.

Drop Index:

// By Name
db.users.dropIndex("email_1")

// By Key-Pattern
db.users.dropIndex({ email: 1 })

Rebuild Indexes:

Bei Index-Corruption oder nach Major-MongoDB-Upgrades:

db.users.reIndex()

Dies droppt und recreated alle Indexes. WARNING: Dies locked die Collection während Rebuild – für Production nur in Maintenance-Windows.

41.13 Index-Build-Performance: Background vs. Foreground

Bei Index-Creation kann man Background-Mode wählen:

db.users.createIndex({ email: 1 }, { background: true })

Foreground (Default): - Schneller Build - Aber: Collection ist locked (keine Reads/Writes während Build)

Background: - Langsamer Build - Aber: Collection bleibt accessible

Für Production immer background: true, außer während Maintenance-Windows.

Hinweis:

MongoDB 4.2+ baut alle Indexes im Background (der Parameter ist deprecated). Dies war ein großer Usability-Win – keine versehentlichen Production-Lockouts mehr.

41.14 Index-Limits und Best Practices

MongoDB-Limits:

Best Practices:

  1. Nur nötige Indexes: Start mit keinen Indexes (außer _id), füge hinzu basierend auf Slow-Query-Logs.

  2. Monitor Index-Usage:

db.users.aggregate([{ $indexStats: {} }])

Dies zeigt Usage-Stats – welche Indexes wie oft genutzt werden. Unused Indexes sollten dropped werden.

  1. ESR-Rule für Compound-Indexes: Equality, Range, Sort.

  2. Covered-Queries wo möglich: Projection auf nur indexierte Felder.

  3. Partial-Indexes für Subset-Queries: Spart Storage und RAM.

  4. Index-Size im RAM: Prüfe db.collection.stats().indexSizes. Wenn Indexes nicht in RAM passen, wird Performance leiden.

  5. Avoid Index-Bloat: Bei Updates, die Indexed-Fields ändern, kann es zu Index-Bloat kommen. Periodisch reIndex() in Maintenance-Windows.

Die folgende Tabelle fasst Index-Typen zusammen:

Index-Type Use-Case Supports Limitations
Single-Field Simple Queries Equality, Range, Sort -
Compound Multi-Field-Queries Complex Filters + Sort Left-Prefix-Rule
Unique Uniqueness-Constraint Same as Single/Compound -
Text Full-Text-Search Tokenized-Search, Ranking One per Collection (legacy)
Geospatial Location-Queries $near, $geoWithin GeoJSON-Format required
Hashed Even-Distribution (Sharding) Equality only No Range-Queries
TTL Auto-Expiration Date-Based-Deletion 60s-Delay
Partial Subset-Indexing Condition-Based Query muss Condition matchen
Sparse Skip-Nulls Memory-Saving Unexpected Results bei null-Queries

Indexierung ist die wichtigste Performance-Optimierung für MongoDB. Ein gut-designtes Index-Set kann eine Datenbank von unusably-slow zu production-ready transformieren. Aber Indexes sind nicht “mehr ist besser” – jeder Index kostet Storage, RAM und Write-Performance. Die Kunst ist, exactly die richtigen Indexes zu haben. Der Process: Start minimal (nur _id), enable Slow-Query-Logs, monitor welche Queries slow sind, erstelle Indexes für diese Queries, validate mit Explain, iterate. Mit systematischem Index-Design und regelmäßigem Monitoring wird MongoDB von decent zu excellent Performance – capable of sub-100ms Queries auf Millionen-Dokument-Collections.