Was ist MongoDB?
MongoDB ist eine Dokumentdatenbank. Anstatt Zeilen in Tabellen speichert sie Dokumente — JSON-ähnliche Objekte, die als BSON kodiert sind — innerhalb von Collections. Ein Dokument kann verschachtelte Objekte und Arrays enthalten, sodass ein einziger Datensatz ein ganzes Aggregat beschreiben kann: beispielsweise eine Bestellung mit all ihren Positionen oder einen Benutzer mit seinen Adressen.
MongoDB erschien im Jahr 2009, als das relationale Modell für Anwendungen, die schnell wuchsen und ihre Struktur änderten, zu schwerfällig wirkte. Das Versprechen war simpel: Speichere das Objekt, das dein Code bereits verwendet, und skaliere horizontal, ohne eine mühsame Migration durchführen zu müssen. Dieses Versprechen machte MongoDB zur Standard-NoSQL-Datenbank für eine ganze Generation von Node.js- und JavaScript-Teams.
Der Kompromiss ist jedoch real. MongoDB verzichtet standardmäßig auf Joins und verlagert die Schema-Validierung in deine Anwendung. Wenn deine Daten dokumentenförmig sind, ist das ein hervorragender Deal. Wenn sie jedoch tief relational sind, ist eine relationale Datenbank oft das bessere Werkzeug.
Dokumente, Collections und BSON
Ein Dokument ist eine geordnete Menge von Feld-Wert-Paaren. Eine Collection ist eine Gruppe von Dokumenten, die normalerweise eine ähnliche Struktur aufweisen, obwohl dies nicht zwingend erforderlich ist. Collections werden in dem Moment erstellt, in dem Sie zum ersten Mal Daten in sie einfügen.
db.users.insertOne({
email: "[email protected]",
name: "Ada",
address: { city: "London", country: "GB" },
tags: ["admin", "beta"],
});
Werte haben BSON-Typen, nicht nur JSON-Typen. BSON ist eine binäre Kodierung, die ObjectId, Date, Decimal128, BinData und mehr hinzufügt, sodass Sie echte Datumsangaben und präzise Dezimalzahlen anstelle von Strings speichern können. Die Reihenfolge der Felder bleibt erhalten und Feldnamen sind case-sensitive.
Eine nützliche Gewohnheit ist es, jedem Dokument in einer Collection eine konsistente Struktur zu geben, auch wenn der Server Variationen zulässt. Dadurch erhalten Sie vorhersagbare Queries und Indizes, während die Flexibilität erhalten bleibt, falls Sie ein Feld zunächst nur in einem einzelnen Dokument hinzufügen müssen.
_id und ObjectId
Jedes Dokument benötigt eine eindeutige _id. Wenn Sie keine angeben, generiert der Driver eine ObjectId – einen 12-Byte-Wert, der aus einem Zeitstempel, einem zufälligen prozessspezifischen Wert und einem inkrementierenden Zähler besteht. Da der Zeitstempel an erster Stelle steht, lassen sich ObjectId-Werte in etwa nach ihrer Erstellungszeit sortieren, was für die Pagination sehr praktisch ist.
const { ObjectId } = require("mongodb");
const id = new ObjectId("64f1c2a9e13b4a7d8c9e0011");
id.getTimestamp(); // 2023-09-01T...
Das _id-Feld wird automatisch indiziert und ist immer eindeutig. Sie können einen eigenen Wert angeben – einen natürlichen Schlüssel, einen UUID-String oder einen zusammengesetzten Schlüssel –, wenn dies die Abfragen effizienter macht oder wenn Sie idempotente Inserts benötigen.
CRUD in der Praxis
Create, Read, Update und Delete lassen sich auf eine kleine Gruppe von Methoden abbilden, die in mongosh und im Node driver identisch funktionieren.
// Create
db.users.insertOne({ email: "[email protected]", plan: "pro" });
db.users.insertMany([
{ email: "[email protected]", plan: "free" },
{ email: "[email protected]", plan: "team" },
]);
// Read
db.users.findOne({ email: "[email protected]" });
db.users.find({ plan: "pro" }).toArray();
// Update
db.users.updateOne(
{ email: "[email protected]" },
{ $set: { plan: "team" } },
);
// Delete
db.users.deleteOne({ email: "[email protected]" });
updateOne und updateMany nehmen einen Filter und ein Update-Dokument entgegen. Das Update-Dokument verwendet Operatoren: $set ersetzt oder fügt Felder hinzu, $unset entfernt sie, $inc addiert zu einer Zahl, $push fügt einem Array Elemente hinzu, $addToSet fügt nur hinzu, wenn das Element nicht vorhanden ist, und $pull entfernt passende Array-Elemente.
db.users.updateOne(
{ email: "[email protected]" },
{
$set: { lastSeenAt: new Date() },
$inc: { logins: 1 },
$addToSet: { tags: "beta" },
},
);
Es gibt zudem replaceOne, welches das gesamte Dokument außer _id ersetzt. Bevorzuge Feld-Operatoren, damit sich konkurrierende Schreiber nicht gegenseitig die Änderungen überschreiben.
Abfragen mit Operatoren
Ein Filter ist selbst ein Dokument. Standardmäßig wird auf Gleichheit geprüft; Operatoren beginnen mit $.
db.orders.find({ status: "paid" }); // equality
db.orders.find({ total: { $gt: 5000, $lte: 20000 } }); // range
db.orders.find({ status: { $in: ["paid", "shipped"] } });
db.users.find({ email: { $regex: /@example\.com$/i } });
db.orders.find({ "items.sku": "KB-01" }); // nested field
db.orders.find({
items: { $elemMatch: { qty: { $gte: 2 }, price: { $lt: 3000 } } },
});
$elemMatch ist wichtig, wenn mehrere Bedingungen auf dasselbe Element eines Arrays zutreffen müssen. Ohne diesen Operator könnte { "items.qty": { $gte: 2 }, "items.price": { $lt: 3000 } } verschiedene Elemente matchen.
Kombinieren Sie Filter mit $and, $or und $not:
db.orders.find({
$or: [
{ status: "paid" },
{ status: "pending", total: { $lt: 1000 } },
],
});
Die Abfragesprache ist komponierbar, da sie aus Daten und nicht aus einem String besteht. Deshalb fühlt sich das Erstellen von Filtern im Anwendungscode natürlich an – Sie setzen ein Objekt zusammen und übergeben es an find.
Projektion, Sortierung und Pagination
Eine Projektion legt fest, welche Felder zurückgegeben werden sollen. Felder werden mit 1 eingeschlossen oder mit 0 ausgeschlossen, wobei diese beiden Stile niemals gemischt werden dürfen, außer bei _id.
db.users.find(
{ plan: "pro" },
{ email: 1, name: 1, _id: 0 },
);
Sortierung und Pagination werden mit .sort(), .skip() und .limit() realisiert. Bei großen Offsets wird skip langsamer, da der Server die übersprungenen Dokumente dennoch durchläuft. Bevorzugen Sie Keyset-Pagination, bei der Sie nach dem letzten gesehenen Wert filtern.
db.orders
.find({ customerId, createdAt: { $lt: lastSeen } })
.sort({ createdAt: -1 })
.limit(20);
Indizes auf den Sortierfeldern machen sowohl den Filter als auch die Sortierung effizient – und genau hier setzt der nächste Abschnitt an.
Indexe: der Unterschied zwischen schnell und unbrauchbar
Ohne einen Index liest MongoDB jedes Dokument in der Collection – ein sogenannter Collection Scan. Mit einem Index springt die Datenbank direkt zum passenden Bereich. Fast jedes Performance-Problem in MongoDB ist auf einen fehlenden oder falsch angeordneten Index zurückzuführen.
db.users.createIndex({ email: 1 }, { unique: true });
db.orders.createIndex({ customerId: 1, createdAt: -1 });
db.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
db.products.createIndex({ name: "text", description: "text" });
- Single-field Indexe beschleunigen Equality- und Range-Queries auf einem einzelnen Feld.
- Compound Indexe folgen der ESR-Regel: zuerst Equality-Felder, dann Sortierung, dann Range.
{ customerId: 1, createdAt: -1 }bedient sowohl den Filter als auch die Sortierung. - Unique Indexe verhindern Duplikate und machen Upserts sicher.
- TTL Indexe löschen Dokumente nach einem bestimmten Datum, was ideal für Sessions und Einmal-Token ist.
- Text Indexe unterstützen
$textSuche mit Stemming und Scoring.
Nutzen Sie explain(), um zu sehen, was der Planner getan hat. Achten Sie auf IXSCAN anstelle von COLLSCAN und prüfen Sie, ob die Anzahl der untersuchten Dokumente nahe an der Anzahl der zurückgegebenen Dokumente liegt.
db.orders.find({ customerId, status: "paid" }).explain("executionStats");
Die Aggregation Pipeline
Wenn eine einfache Abfrage nicht ausreicht, führt das Aggregation Framework eine Pipeline von Stages aus. Jede Stage transformiert einen Dokumentenstrom und gibt diesen an die nächste Stage weiter. Die gängigsten Stages sind $match, $group, $sort, $project, $lookup, $unwind und $limit.
Hier ist ein praxisnahes Beispiel: die fünf umsatzstärksten Produkte bei bezahlten Bestellungen.
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $unwind: "$items" },
{
$group: {
_id: "$items.sku",
units: { $sum: "$items.qty" },
revenue: {
$sum: { $multiply: ["$items.qty", "$items.price"] },
},
},
},
{ $sort: { revenue: -1 } },
{ $limit: 5 },
{
$project: {
_id: 0,
sku: "$_id",
units: 1,
revenue: 1,
},
},
]);
Lies die Pipeline von oben nach unten. $match filtert bereits früh, damit nachfolgende Stages weniger Arbeit verrichten müssen. $unwind wandelt jedes Array-Element in ein eigenes Dokument um. $group summiert die Einheiten und den Umsatz pro SKU. $sort und $limit behalten die Top fünf. $project benennt _id in sku um und verwirft den Rest.
$lookup führt einen Left Outer Join mit einer anderen Collection durch – so kombinierst du referenzierte Daten:
db.orders.aggregate([
{ $match: { status: "paid" } },
{
$lookup: {
from: "users",
localField: "customerId",
foreignField: "_id",
as: "customer",
},
},
{ $unwind: "$customer" },
{ $project: { total: 1, "customer.email": 1 } },
]);
Setze $match immer an den Anfang, damit ein Index genutzt werden kann, und platziere $limit so früh wie möglich, sofern es die Logik erlaubt. Aggregation ist mächtig, aber eine Pipeline, die in jeder Stage jedes einzelne Dokument scannt, führt unweigerlich zu langsamen Abfragen.
Embedding vs. Referencing
Dies ist die zentrale Entscheidung beim Datenmodelling. Überlegen Sie hierzu, wie die Daten gelesen werden.
Embedden Sie Daten, wenn die Child-Daten zusammen mit dem Parent gelesen und geschrieben werden und ihre Größe begrenzt ist. Die Positionen einer Bestellung sind der klassische Fall: Ein einziger Lesezugriff liefert alles zurück, und es ist kein Join erforderlich.
{
_id: ObjectId("..."),
customerId: ObjectId("..."),
items: [
{ sku: "KB-01", qty: 1, price: 8900 },
{ sku: "MS-02", qty: 2, price: 2900 },
],
total: 14700,
}
Referenzieren Sie Daten, wenn diese groß sind, geteilt werden oder in ihrem eigenen Zyklus aktualisiert werden. Benutzer, Produkte und Kategorien werden durch _id referenziert, und $lookup oder eine zweite Abfrage führt den Join durch.
{
_id: ObjectId("..."),
customerId: ObjectId("64f1c2a9e13b4a7d8c9e0011"),
items: [{ productId: ObjectId("..."), qty: 1, price: 8900 }],
}
Die Faustregel lautet: Daten, auf die gemeinsam zugegriffen wird, sollten gemeinsam gespeichert werden. Duplizieren Sie gegebenenfalls geringfügig Daten, wenn dies den häufigsten Lesezugriff zu einem einzigen Lookup macht, aber bedenken Sie, dass Kopien an jeder Stelle aktualisiert werden müssen, an der sie existieren. Und embedden Sie niemals ein Array, das unbegrenzt wächst – Dokumente sind auf 16 MB begrenzt.
Transaktionen
Schreibvorgänge in einem einzelnen Dokument sind atomar. Wenn Sie mehrere Dokumente oder Collections als eine Einheit ändern müssen, verwenden Sie eine Multi-Document-Transaktion. Sessions sind hierfür der Mechanismus.
const session = client.startSession();
try {
await session.withTransaction(async () => {
await accounts.updateOne(
{ _id: from },
{ $inc: { balance: -100 } },
{ session },
);
await accounts.updateOne(
{ _id: to },
{ $inc: { balance: 100 } },
{ session },
);
});
} finally {
await session.endSession();
}
Transaktionen erfordern ein Replica Set oder einen sharded Cluster und bringen einen gewissen Overhead mit sich: Locks, ein längeres Zeitfenster und Retries bei Konflikten. Der optimale Einsatz von Transaktionen ist selten. Wenn Sie feststellen, dass Sie jeden Schreibvorgang in eine Transaktion einschließen, sollte Ihr Datenmodell wahrscheinlich mehr Embedding nutzen.
Replica Sets und Sharding
Ein Replica Set ist eine Gruppe von Nodes, die dieselben Daten halten. Einer davon ist der Primary und übernimmt alle Schreibvorgänge; die anderen replizieren das oplog des Primarys und können Lesezugriffe bedienen. Wenn der Primary ausfällt, wählt das Set automatisch einen neuen aus. Dies ist das Standard-Deployment für die Produktion und ermöglicht zudem Change Streams und Transaktionen.
Sharding partitioniert eine Collection über viele Replica Sets hinweg mithilfe eines Shard Keys. Jeder Shard besitzt einen bestimmten Bereich von Key-Werten. Ein guter Shard Key weist eine hohe Kardinalität auf, verteilt Schreibvorgänge gleichmäßig und taucht in den meisten Queries auf, sodass der Router einen einzelnen Shard ansteuern kann. Ein schlechter Key erzeugt Hotspots oder zwingt jede Query dazu, sich auf alle Shards zu verteilen (Fan-out).
Wählen Sie den Shard Key aus, bevor Sie Daten haben, da eine spätere Änderung die Migration der gesamten Collection bedeutet. Für die meisten Anwendungen ist es ratsam, mit einem Replica Set zu starten und erst dann auf Sharding umzustellen, wenn ein einzelner Primary nicht mehr mithalten kann.
Mongoose in Node
Der offizielle mongodb Driver reicht für einfache Abfragen völlig aus, aber die meisten Node-Teams setzen auf Mongoose aufgrund der Schemas, Validierungen und Modelle. Ein Schema beschreibt die Struktur eines Dokuments, und ein Modell ist die daraus resultierende, abfragbare Klasse.
import mongoose from "mongoose";
const orderSchema = new mongoose.Schema({
customerId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
status: {
type: String,
enum: ["pending", "paid", "shipped"],
default: "pending",
},
items: [
{
sku: String,
qty: { type: Number, min: 1 },
price: { type: Number, min: 0 },
},
],
total: Number,
createdAt: { type: Date, default: Date.now, index: true },
});
export const Order = mongoose.model("Order", orderSchema);
const paid = await Order.find({ status: "paid" })
.sort({ createdAt: -1 })
.limit(20)
.lean();
Mongoose validiert beim Speichern, führt Typumwandlungen (Casting) durch und bietet populate() für Referenzen. Zwei Gewohnheiten halten die Performance hoch: Deklarieren Sie die Indexe, die Ihre Abfragen benötigen, und nutzen Sie .lean(), wenn Sie Daten nur lesen möchten – so wird das Erstellen vollständiger Mongoose-Dokumente übersprungen.
Bulk-Writes und Upserts
Ein Befehl pro Dokument verschwendet Roundtrips. Wenn Sie viele Änderungen anwenden müssen, sendet bulkWrite einen Batch in einem einzigen Aufruf und berichtet genau, was passiert ist.
await db.users.bulkWrite([
{
updateOne: {
filter: { email: "[email protected]" },
update: { $set: { plan: "team" } },
upsert: true,
},
},
{
insertOne: {
document: { email: "[email protected]", plan: "pro" },
},
},
{
deleteOne: { filter: { email: "[email protected]" } },
},
]);
Die upsert-Option ist das andere Arbeitstier. Sie aktualisiert ein passendes Dokument oder fügt eines ein, falls keines existiert, was idempotente Importe und Counter vereinfacht.
db.stats.updateOne(
{ day: "2026-09-16" },
{ $inc: { visits: 1 } },
{ upsert: true },
);
Bulk-Writes sind standardmäßig keine Transaktionen. Übergeben Sie { ordered: false }, um nach einem Fehler fortzufahren und jeden Fehlschlag zu sammeln, oder bleiben Sie beim standardmäßigen ordered mode, wenn jeder Schreibvorgang vom vorherigen abhängt. Überprüfen Sie in jedem Fall das Result-Objekt: Es zählt die gematchten, modifizierten, eingefügten und gelöschten Dokumente; ein Upsert, das keine Übereinstimmung fand, ist kein Fehler.
Change Streams
Ein Replica Set zeichnet jeden Schreibvorgang in einem oplog auf. Change Streams stellen dieses Log als wiederaufnehmbaren Feed bereit, sodass eine Anwendung auf Inserts, Updates und Deletes reagieren kann, ohne Polling nutzen zu müssen.
const changeStream = db.orders.watch([
{ $match: { "fullDocument.status": "paid" } },
]);
for await (const change of changeStream) {
console.log(change.operationType, change.fullDocument._id);
}
Der Feed ist wiederaufnehmbar: Speichern Sie den _id des letzten verarbeiteten Events als Resume Token und übergeben Sie diesen nach einem Neustart mit resumeAfter, damit kein Event verloren geht. Da Change Streams auf dem oplog basieren, setzen sie ein Replica Set voraus und melden nur Änderungen, die noch nicht aus dem Log gelöscht wurden.
Zwei Regeln sorgen für die Zuverlässigkeit: Machen Sie den Consumer idempotent, da ein Event nach einem Resume erneut zugestellt werden kann. Und halten Sie die Verarbeitung schnell oder lagern Sie die Arbeit in eine Queue aus, da ein langsamer Consumer dazu führt, dass das oplog an seiner Position vorbeiläuft.
Best Practices
- Modellierung für den Lesezugriff: Bette Daten ein, die gemeinsam gelesen werden; referenziere Daten, die geteilt oder unbegrenzt wachsen.
- Indexiere die Felder, nach denen gefiltert und sortiert wird, und befolge die ESR-Regel für zusammengesetzte Indexe.
- Projiziere immer nur die Felder, die der Client tatsächlich benötigt; gib niemals Secrets zurück.
- Setze
$matchan den Anfang einer Aggregation Pipeline, damit ein Index genutzt werden kann. - Begrenze das Wachstum von Arrays und achte auf das 16 MB Dokumenten-Limit.
- Validiere Schreibvorgänge in der Applikation oder mit einem JSON Schema Validator der Collection.
- Nutze
explain(), bevor du davon ausgehst, dass eine Query performant ist, und teste diese mit produktionsnahen Datenmengen. - Bevorzuge Keyset-Pagination gegenüber großen
skipOffsets.
Häufige Fehler
- MongoDB als komplett schemalos zu betrachten und Dokumente in inkompatible Formen driften zu lassen.
- Abfragen ohne Index auszuführen und sich zu wundern, warum die Latenz mit der Größe der Collection steigt.
- Compound-Indizes in der falschen Reihenfolge zu erstellen, sodass die Sortierung diese nicht nutzen kann.
- Arrays einzubetten, die unendlich wachsen, bis die Dokumente das Größenlimit erreichen.
$lookupbei jeder Anfrage zu verwenden, anstatt Daten einzubetten, die gemeinsam gelesen werden.- Zu Multi-Dokument-Transaktionen zu greifen, wenn ein Update eines einzelnen Dokuments ausreichen würde.
- Ganze Dokumente zurückzugeben und dabei Passwort-Hashes oder Tokens preiszugeben.
- Einen Shard-Key mit geringer Kardinalität zu wählen und so einen Write-Hotspot zu erzeugen.
Wie geht es weiter?
MongoDB vermittelt Dokumentmodellierung, Index-Design und Aggregation – Fähigkeiten, die auf jeden Datenspeicher übertragbar sind. Wenn Ihre Daten stark relational sind und Joins sowie Constraints auf Datenbankebene benötigen, lesen Sie den PostgreSQL-Guide. Für Lesezugriffe im Mikrosekundenbereich, TTL-gesteuertes Ablaufdatum und Counter kombinieren Sie MongoDB mit Redis. Wenn Sie MongoDB mit einem Node-Service verbinden möchten, schauen Sie sich die Node.js basics noch einmal an und vergleichen Sie anschließend das Query-Modell mit SQL.