34 CRUD: Read – Die Kunst des Abfragens

Read-Operationen sind das Herzstück fast jeder MongoDB-Anwendung. Während Writes wichtig sind, sind Reads typischerweise 90-95% aller Datenbankoperationen. Eine Social-Media-App schreibt einmal einen Post, aber er wird hunderte oder tausende Male gelesen. Eine E-Commerce-Site updated Inventory selten, aber Produkt-Queries laufen ständig. Die Performance und Effizienz von Read-Operationen bestimmt oft die User Experience der gesamten Anwendung.

MongoDB’s Query-Sprache ist mächtig und flexibel. Von simplen Equality-Checks bis zu komplexen Multi-Stage-Aggregations, von Full-Text-Search bis zu Geospatial-Queries – die Möglichkeiten sind umfangreich. Aber mit Macht kommt Verantwortung: Schlecht geschriebene Queries können Performance katastrophal beeinträchtigen. Ein Full-Collection-Scan auf einer Million-Dokument-Collection kann Sekunden dauern und das System belasten. Die richtige Query mit dem richtigen Index dauert Millisekunden.

Dieses Kapitel behandelt Read-Operationen systematisch – von grundlegenden Finds über Query-Operatoren bis zu Performance-Optimierung. Der Fokus ist nicht nur auf “wie funktioniert es”, sondern “wie nutzt man es richtig in Production”.

34.1 find(): Die fundamentale Query-Methode

Die find()-Methode ist der Einstiegspunkt für fast alle Read-Operationen. Im simpelsten Fall gibt sie alle Dokumente einer Collection zurück:

db.users.find()

Dies ist äquivalent zu SELECT * FROM users in SQL. In der Shell zeigt MongoDB die ersten 20 Dokumente und wartet auf weitere Input (tippe “it” für “iterate” um die nächsten 20 zu sehen). Programmatisch gibt find() einen Cursor zurück, der lazy iteriert – Dokumente werden on-demand fetched, nicht alle auf einmal in Memory geladen.

Mit Filter:

Die meisten Queries filtern nach spezifischen Kriterien:

db.users.find({ status: "active" })

Dies findet alle Dokumente, wo das status-Feld exakt “active” ist. Der Filter ist ein JavaScript-Objekt, wo Keys Field-Namen sind und Values die erwarteten Werte. Multiple Kriterien sind implicit AND:

db.users.find({ status: "active", age: 28 })

Findet Users, die sowohl status: "active" als auch age: 28 haben. MongoDB scannt die Collection (oder nutzt einen Index) und returned nur matchende Dokumente.

Case Sensitivity und Type Awareness:

MongoDB ist case-sensitive und type-aware. { status: "active" } matched nicht { status: "Active" } oder { status: "ACTIVE" }. { age: 28 } matched nicht { age: "28" } (String vs. Number). Dies ist fundamental anders als manche SQL-Datenbanken mit loser Type-Coercion. Für case-insensitive Queries muss man Collations oder RegEx nutzen.

34.2 Comparison Operators: Beyond Equality

Equality-Checks sind nur der Anfang. Für Ranges, Inequalities und komplexere Bedingungen gibt es Comparison-Operatoren:

// Alter größer als 25
db.users.find({ age: { $gt: 25 } })

// Alter zwischen 25 und 35 (inklusive)
db.users.find({ age: { $gte: 25, $lte: 35 } })

// Status ist nicht "banned"
db.users.find({ status: { $ne: "banned" } })

// Username in spezifischer Liste
db.users.find({ username: { $in: ["alice", "bob", "charlie"] } })

// Created date in letzten 7 Tagen
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
db.users.find({ createdAt: { $gte: weekAgo } })

Die Syntax { field: { $operator: value } } ist konsistent über alle Operatoren. Multiple Operatoren auf demselben Feld werden kombiniert:

// Alter >= 18 UND < 65
db.users.find({ age: { $gte: 18, $lt: 65 } })

Performance-Implikation:

Range-Queries auf indizierten Feldern sind effizient. Ein Index auf age erlaubt MongoDB, direkt zu den Dokumenten im gewünschten Range zu springen. Ohne Index muss MongoDB jedes Dokument scannen und checken. Bei großen Collections ist der Unterschied dramatisch – Millisekunden vs. Sekunden.

// Ohne Index: Scannt alle Dokumente
db.users.find({ age: { $gt: 25 } }).explain("executionStats")
// -> COLLSCAN, totalDocsExamined: 1000000

// Mit Index
db.users.createIndex({ age: 1 })
db.users.find({ age: { $gt: 25 } }).explain("executionStats")
// -> IXSCAN, totalKeysExamined: 500000 (nur relevante Dokumente)

34.3 Logical Operators: Komplexe Boolean-Logik

Für Queries, die mehr als simple AND benötigen, gibt es logische Operatoren:

$or – Mindestens eine Bedingung:

// User ist entweder admin ODER moderator
db.users.find({
  $or: [
    { role: "admin" },
    { role: "moderator" }
  ]
})

Die $or-Syntax nimmt ein Array von Query-Objekten. MongoDB evaluated jedes und returned Dokumente, die mindestens eines matchen. Wichtig: $or ist teurer als AND, besonders ohne Indexes auf allen beteiligten Feldern.

$and – Explizites AND:

Normalerweise ist AND implizit, aber für Queries mit demselben Feld mehrmals braucht man explizites $and:

// Alter > 18 UND age < 65 (könnten wir auch mit Range machen)
db.users.find({
  $and: [
    { age: { $gt: 18 } },
    { age: { $lt: 65 } }
  ]
})

// Oder komplexere Logik
db.users.find({
  $and: [
    { status: "active" },
    { $or: [{ role: "admin" }, { permissions: { $in: ["write"] } }] }
  ]
})

$not – Negation:

// Alle Users NICHT in Berlin
db.users.find({ "address.city": { $not: { $eq: "Berlin" } } })

// Äquivalent zu
db.users.find({ "address.city": { $ne: "Berlin" } })

$not ist meist weniger intuitiv als $ne oder andere Negations-Operatoren. Es ist nützlich für komplexere Negationen, etwa von RegEx:

// Emails, die NICHT mit .com enden
db.users.find({ email: { $not: /\.com$/ } })

34.4 Array Queries: Suchen in Listen

MongoDB hat spezielle Semantiken für Array-Felder. Ein Equality-Check matched, wenn das Array das Element enthält:

// Users mit "reading" in hobbies
db.users.find({ hobbies: "reading" })

Dies matched { hobbies: ["reading", "gaming"] } genauso wie { hobbies: ["reading"] }. MongoDB prüft, ob “reading” irgendwo im Array ist.

$all – Alle Elemente müssen vorhanden sein:

// Users mit SOWOHL "reading" ALS AUCH "gaming" in hobbies
db.users.find({ hobbies: { $all: ["reading", "gaming"] } })

Die Reihenfolge ist egal. ["gaming", "reading", "cooking"] würde matchen.

$size – Array-Länge:

// Users mit genau 3 hobbies
db.users.find({ hobbies: { $size: 3 } })

Wichtig: $size matched nur exakte Längen, keine Ranges. Für “mindestens 3” oder “höchstens 5” braucht man andere Approaches – etwa ein separates hobbyCount-Feld, das bei Updates inkrementiert wird.

$elemMatch – Komplexe Array-Queries:

Für Arrays von Objects ist $elemMatch essentiell:

// Orders mit mindestens einem item, das > $100 kostet UND quantity >= 2 hat
db.orders.find({
  items: {
    $elemMatch: {
      price: { $gt: 100 },
      quantity: { $gte: 2 }
    }
  }
})

Ohne $elemMatch würde MongoDB separate Array-Elemente matchen können – ein item mit price > 100 und ein anderes mit quantity >= 2. $elemMatch stellt sicher, dass dasselbe Element beide Conditions erfüllt.

34.5 Nested Document Queries: Dot Notation

MongoDB unterstützt Queries auf nested Fields mit Dot-Notation:

// Users in Berlin
db.users.find({ "address.city": "Berlin" })

// Users in Berlin UND PLZ 10115
db.users.find({ 
  "address.city": "Berlin",
  "address.postalCode": "10115"
})

Die Quotes um den Key sind nötig wegen des Punkts. Dies funktioniert beliebig tief:

db.users.find({ "profile.settings.notifications.email": true })

Für komplexere nested Queries kann man das gesamte nested Object matchen:

// Exaktes Match des address-Objects
db.users.find({
  address: {
    street: "Main St",
    city: "Berlin",
    postalCode: "10115"
  }
})

Dies matched nur, wenn das address-Objekt exakt diese Felder in exakt dieser Reihenfolge hat. Meist ist Dot-Notation flexibler.

34.6 Projection: Nur benötigte Felder fetchen

Per Default returned MongoDB alle Felder eines Dokuments. Für Performance sollte man nur benötigte Felder fetchen:

// Nur username und email
db.users.find(
  { status: "active" },
  { username: 1, email: 1 }
)

Das zweite Argument zu find() ist die Projection. 1 bedeutet “include”, 0 bedeutet “exclude”. Man kann nicht mischen (außer für _id, das man explizit excluden darf):

// Alle Felder außer password
db.users.find(
  { status: "active" },
  { password: 0 }
)

// Username und email, aber ohne _id
db.users.find(
  { status: "active" },
  { username: 1, email: 1, _id: 0 }
)

Performance-Impact:

Projection reduziert Netzwerk-Traffic und Memory-Nutzung. Wenn Dokumente 10 KB groß sind, aber man nur 1 KB braucht, spart Projection 90%. Bei tausenden Dokumenten ist dies signifikant. Für Queries, die nur Document-IDs oder wenige Felder brauchen, ist Projection essentiell.

Computed Fields in Projection:

MongoDB 4.4+ erlaubt computed Fields in Projections:

db.users.find({}, {
  username: 1,
  fullName: { $concat: ["$firstName", " ", "$lastName"] }
})

Dies ist syntactic Sugar über Aggregation und weniger common in einfachen Finds.

34.7 Sorting: Reihenfolge kontrollieren

Die .sort()-Methode auf Cursors sortiert Results:

// Alphabetisch nach username (aufsteigend)
db.users.find().sort({ username: 1 })

// Nach age absteigend (älteste zuerst)
db.users.find().sort({ age: -1 })

// Multi-Field-Sort: Erst nach status, dann nach age
db.users.find().sort({ status: 1, age: -1 })

1 ist ascending, -1 ist descending. Multi-Field-Sort wird left-to-right angewendet – erst status, bei gleichem status dann age.

Performance:

Sorting ist teuer ohne Index. MongoDB muss alle Dokumente in Memory laden, sortieren und returnen. Für große Result-Sets (tausende Dokumente) kann dies Seconds dauern oder Out-of-Memory-Errors werfen.

Mit Index ist Sorting fast kostenlos – MongoDB iteriert den Index in der gewünschten Reihenfolge:

// Ohne Index: Memory-intensive sort
db.users.find().sort({ createdAt: -1 }).explain("executionStats")
// -> SORT stage in memory

// Mit Index
db.users.createIndex({ createdAt: -1 })
db.users.find().sort({ createdAt: -1 }).explain("executionStats")
// -> IXSCAN, kein extra sort nötig

Für Queries mit sort sollte man fast immer einen passenden Index haben.

34.8 Limiting und Skipping: Pagination implementieren

Die .limit()-Methode beschränkt die Anzahl returned Dokumente:

// Nur 10 Users
db.users.find().limit(10)

Die .skip()-Methode überspringt die ersten N Dokumente:

// Skip ersten 10, return nächste 10 (Seite 2)
db.users.find().skip(10).limit(10)

Dies ist klassische Offset-Based-Pagination. Für Seite N mit Seiten-Größe M: skip((N-1) * M).limit(M).

Performance-Problem:

skip() ist ineffizient für große Offsets. MongoDB muss die geskippten Dokumente scannen (auch wenn es sie nicht returned). skip(10000) scannt 10.000 Dokumente:

// Sehr langsam für große N
db.users.find().skip(10000).limit(10)

Besserer Approach – Cursor-Based Pagination:

Statt Offset nutzt man die _id oder einen anderen sortierenden Key:

// Seite 1
const page1 = db.users.find().sort({ _id: 1 }).limit(10).toArray()
const lastId = page1[page1.length - 1]._id

// Seite 2 - nutze lastId als Cursor
const page2 = db.users.find({ _id: { $gt: lastId } })
  .sort({ _id: 1 })
  .limit(10)
  .toArray()

Dies ist konstant schnell, egal wie tief man paginiert, weil MongoDB direkt zum richtigen Index-Position springen kann.

34.9 Cursor Methods: Flexibles Result-Processing

find() returned einen Cursor – ein Iterator-ähnliches Objekt. Cursors haben nützliche Methoden:

toArray() – Alle Results in Array:

const users = db.users.find({ status: "active" }).toArray()
print(users.length)

Dies lädt alle Results in Memory. Für große Result-Sets gefährlich (Out-of-Memory), aber praktisch für kleine Sets.

forEach() – Iterieren ohne Array:

db.users.find({ status: "active" }).forEach(user => {
  print(user.username)
  // Verarbeitung...
})

Dies fetched Dokumente batch-weise (Default 101 Dokumente per Batch), verarbeitet sie und fetched den nächsten Batch. Memory-efficient für große Result-Sets.

count() – Anzahl Dokumente:

const count = db.users.find({ status: "active" }).count()

Deprecated! Besser: countDocuments():

const count = db.users.countDocuments({ status: "active" })

countDocuments() ist genauer und konsistenter mit Transactions.

hasNext() und next() – Manuelle Iteration:

const cursor = db.users.find()
while (cursor.hasNext()) {
  const user = cursor.next()
  print(user.username)
}

Dies gibt maximale Kontrolle – man kann Iterieren nach Bedarf pausieren, Conditions checken, etc.

34.10 distinct(): Unique Values finden

Für eindeutige Werte eines Feldes nutzt man distinct():

// Alle unterschiedlichen Status-Werte
db.users.distinct("status")
// ["active", "inactive", "banned"]

// Mit Filter
db.users.distinct("status", { age: { $gte: 18 } })

Dies ist effizienter als find() + manual deduplication, besonders wenn ein Index auf dem Feld existiert.

34.11 findOne(): Ein Dokument genügt

Wenn man nur ein Dokument braucht, ist findOne() effizienter als find().limit(1):

const user = db.users.findOne({ username: "alice" })

if (user) {
  print(user.email)
} else {
  print("User not found")
}

findOne() returned das Dokument direkt (oder null wenn nichts matched), keinen Cursor. Es stoppt nach dem ersten Match – self-limitierend.

34.12 Performance-Optimierung: Explain und Indexes

Die wichtigste Performance-Tool: .explain():

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

Die Output zeigt: - executionStages: Welche Strategie (COLLSCAN, IXSCAN, etc.) - totalDocsExamined: Wie viele Dokumente gescannt - totalKeysExamined: Wie viele Index-Keys gescannt - executionTimeMillis: Wie lange dauerte es

Ideale Query: - executionStages.stage: “IXSCAN” (Index Scan) - totalDocsExamined: Gleich oder nahe der Anzahl returned Documents - totalKeysExamined: Minimal

Problematische Query: - executionStages.stage: “COLLSCAN” (Collection Scan) - totalDocsExamined: Millionen, obwohl nur 10 returned - executionTimeMillis: Seconds

Die Lösung: Index auf das gefilterte Feld:

db.users.createIndex({ email: 1 })

Danach: Query nutzt IXSCAN, scannt nur relevante Dokumente, ist 100-1000x schneller.

Compound Indexes für Multi-Field-Queries:

Für Queries auf mehreren Feldern:

db.users.find({ status: "active", age: { $gte: 18 } }).sort({ createdAt: -1 })

Ein Compound-Index deckt alle ab:

db.users.createIndex({ status: 1, age: 1, createdAt: -1 })

Die Index-Reihenfolge ist kritisch: Equality-Felder zuerst (status), dann Range-Felder (age), dann Sort-Fields (createdAt).

Die folgende Tabelle fasst Query-Performance-Patterns zusammen:

Pattern Ohne Index Mit Index Speedup
Equality (field: value) COLLSCAN IXSCAN 100-1000x
Range (field: {$gt: val}) COLLSCAN IXSCAN 50-500x
Sort In-Memory Sort Index Order 10-100x
Count Scan alle Docs Index Count 100x+
Skip(N) mit großem N Scan N Docs Ineffizient auch mit Index Use Cursor-Pagination

Read-Operationen sind das Rückgrat von MongoDB-Anwendungen. Die Query-Sprache ist mächtig und expressiv, aber Performance hängt fundamental von Indexes ab. Eine Query ohne passenden Index kann Production-Systeme zum Stillstand bringen. Die Best Practice: Für jede häufige Query einen passenden Index haben, regelmäßig Slow-Query-Logs analysieren, und .explain() für Performance-kritische Queries nutzen. Mit dem richtigen Index-Design sind selbst Millionen-Dokument-Collections blitzschnell query-bar. Ohne Indexes ist selbst eine kleine Collection frustrierend langsam.