Dokumentendatenbanken
Kurz gesagt: Eine Dokumentendatenbank wie MongoDB speichert jeden Datensatz als Dokument mit verschachtelten Feldern und Arrays, ohne ein einheitliches Schema für alle Datensätze.
Problem
Produkte, die sich nicht gleichen
In der Tabelle products hat jedes Produkt dieselben Spalten: Name, Kategorie, Preis, Bestand. Der Shop will mehr zeigen. Das Gewicht und die Allergene beim Brot, die Herkunft und die Röstung beim Kaffee, das Volumen beim Spülmittel. Die Tabelle lässt zwei schlechte Wahlmöglichkeiten. Die erste sind dutzende Spalten, die bei den meisten Produkten NULL sind. Die zweite ist eine eigene Tabelle „Eigenschaft, Wert“, in der alles Text ist und jedes Lesen ein weiteres JOIN bedeutet.
Eine Bestellung hat ein ähnliches Problem. Um sie dem Kunden zu zeigen, setzt du sie aus vier Tabellen zusammen:
Die Seite zeigt die Bestellung immer als Ganzes: den Kunden, die Positionen, die Preise. Und die Datenbank setzt sie jedes Mal neu zusammen.
Die Idee
Das Dokument
Eine Dokumentendatenbank hält jeden Datensatz als Dokument: ein Objekt mit Feldern, wie JSON. Die Dokumente liegen in einer Collection, die einer Tabelle ähnelt, aber die Felder nicht vorgibt. Jedes Dokument hat seine eigenen. Anders als eine Key-Value-Datenbank sieht eine Dokumentendatenbank in den Wert hinein und sucht nach seinen Feldern.
Das Feld ist ein Simulator mit den MongoDB-Befehlen. Ihre Konsole heißt mongosh, und die Befehle beginnen mit db. und dem Namen der Collection:
Das Brot hat weight_g und allergens, der Kaffee origin und roast, das Spülmittel volume_ml. Ein Wert kann ein Array (allergens) oder ein anderes Dokument sein. _id ist der Primärschlüssel: Jedes Dokument hat einen, und er wiederholt sich nicht.
Genauer, für Neugierige
Gibst du keine _id an, fügt MongoDB selbst eine ObjectId('…') hinzu: 12 Bytes, die den Zeitpunkt der Erstellung und einen zufälligen Teil enthalten. Der Simulator vergibt sie der Reihe nach (ObjectId('6720a0000000000000000001'), dann …0002), damit die Ausgabe jedes Mal gleich ist. Echte laufen nicht so. Ohne sort() liefert der Simulator die Dokumente in der Reihenfolge, in der sie hineingekommen sind. MongoDB verspricht ohne sort() überhaupt keine Reihenfolge. Die Ausgabe ist wie in mongosh 2: Text steht in einfachen Anführungszeichen, und lange Dokumente werden mit einem Feld pro Zeile umbrochen.
Probier es aus
In den Dokumenten suchen
find(filter, projection) sucht. Der Filter ist ein Dokument mit den Feldern, die passen müssen. { price: { $lt: 3 } } heißt „Preis unter 3“: $lt ist „kleiner als“, $lte „kleiner oder gleich“, $gt und $gte „größer“, $ne „ungleich“, $in „eins der aufgezählten“. $exists fragt, ob das Feld überhaupt da ist. Die Projektion sagt, welche Felder zurückkommen: 1 heißt „ja“, und _id: 0 lässt _id weg, die sonst immer mitkommt. .sort({ price: 1 }) sortiert aufsteigend (-1 ist absteigend), und countDocuments zählt:
Der Filter { allergens: 'Milch' } findet die Dokumente, deren Array 'Milch' enthält. Vorsicht: { allergens: ['Milch'] } sucht genau dieses Array, mit einem Element und sonst nichts.
Zeige die Namen der Produkte, die Gluten enthalten, ohne _id.
Lösung
db.products.find({ allergens: 'Gluten' }, { name: 1, _id: 0 })
db.products.find({ allergens: 'Gluten' }, { name: 1, _id: 0 }) findet das Brot und das Brötchen. Mit ['Gluten'] fällt das Brötchen heraus, weil sein Array ['Gluten', 'Milch'] ist. Und mit allergen statt allergens kommt nichts heraus, und die Datenbank sagt nicht, warum.
Sieh es dir an
Ein Tippfehler im Feldnamen
Die Freiheit hat ihren Preis. Eine Tabelle kennt die Namen ihrer Spalten: Ein UPDATE einer Spalte prise, die es nicht gibt, liefert einen Fehler. Eine Collection kennt keine Namen. Der Kaffee wird billiger, 12,90 €, aber im Namen des Feldes steckt ein Tippfehler:
Die Datenbank antwortet modifiedCount: 1 und meldet keinen Fehler. Sie hat ein neues Feld prise hinzugefügt, und price ist weiterhin 14.9. Deshalb taucht der Kaffee nicht unter den Produkten unter 13 € auf. Ein solcher Fehler zeigt sich erst, wenn jemand den alten Preis auf der Seite bemerkt.
Deshalb bewacht das Programm, das in eine Dokumentendatenbank schreibt, die Form selbst: eine Klasse oder ein Schema im Code für jede Art von Dokument. MongoDB kann die Dokumente beim Schreiben auch gegen Regeln für die Collection prüfen.
Genauer, für Neugierige
Die Regeln werden mit db.createCollection('products', { validator: { $jsonSchema: … } }) oder später mit dem Befehl collMod festgelegt. Sie sagen, welche Felder Pflicht sind und von welchem Typ. Der Simulator hat keine solche Prüfung.
Die Idee
Einbetten oder Referenzieren
Die Bestellung vom Anfang kann ein einziges Dokument sein, mit dem Kunden und den Positionen darin. Das heißt Einbetten:
findOne liefert es ganz in einem Lesevorgang, ohne JOIN. Ein Feld im eingebetteten Dokument erreichst du mit einem Punkt: 'customer.city'. Der Pfad steht in Anführungszeichen, weil er einen Punkt hat. Dasselbe funktioniert durch ein Array hindurch. In diesem Feld hat die Collection orders schon die Bestellungen 53–60 des Shops, und 'items.product_id': 1 findet die, in denen irgendeine Position das Weißbrot ist:
Der Name und der Preis in den Positionen sind Kopien, und das ist Absicht: Die Bestellung behält den Preis vom Tag des Kaufs, wie unit_price in order_items. Der Name des Kunden ist auch eine Kopie. Ändert Maria ihren Namen, behalten die alten Bestellungen den alten. Für eine Bestellung ist das richtig, aber das Profil des Kunden lebt in einer eigenen Collection, und die Bestellung trägt nur customer.id.
Du bettest ein, wenn der Teil zum Dokument gehört, mit ihm gelesen wird und klein ist: Eine Bestellung hat ein paar Positionen, nicht tausende. Du nutzt eine Referenz, wenn etwas vielen Dokumenten gemeinsam gehört oder ohne Ende wächst. So sind die Bewertungen des Kaffees: Es gibt viele, sie werden zu beliebiger Zeit geschrieben, und jede hat ihren eigenen Autor. Sie liegen in einer eigenen Collection, und product_id zeigt auf das Produkt, wie ein Fremdschlüssel:
Einbetten ist Denormalisierung: schnelleres Lesen im Tausch gegen Kopien, die auseinanderlaufen können.
Genauer, für Neugierige
Ein Dokument in MongoDB ist höchstens 16 MB groß, ein Array, das ohne Ende wächst, passt also früher oder später nicht mehr hinein. Referenzen werden in aggregate mit der Stufe $lookup verbunden (mit from, localField, foreignField und as), die so etwas wie ein LEFT JOIN macht. Der Simulator kennt sie nur in dieser Form. Wenn du in jeder Abfrage $lookup brauchst, sind deine Daten wahrscheinlich relational.
Probier es aus
Änderungen im Dokument
updateOne(filter, change) ändert das erste Dokument, das es findet, und updateMany alle. Die Änderung ist ein Operator: $set setzt einen Wert (und legt das Feld an, wenn es fehlt), $inc addiert zu einer Zahl, $push fügt einem Array etwas hinzu, $unset entfernt ein Feld. Dieses Feld hat wieder die Bestellungen 53–60. Bestellung 60 ist geliefert, Butter kommt dazu, und das Weißbrot wird drei:
'items.$.qty' heißt „die qty der Position, die der Filter gefunden hat“. Der Filter 'items.product_id': 1 findet die Position des Weißbrots, und $ ist ihr Platz im Array. Das Dokument ändert sich ganz auf einmal: Niemand kann die Bestellung halb geändert lesen.
Ohne Operator weigert sich mongosh: updateOne({ _id: 60 }, { status: 'delivered' }) liefert den Fehler Update document requires atomic operators. Und $set auf das ganze Array items ersetzt es, und die alten Positionen sind weg.
Viktoria (Bestellung 58) will noch ein Vollkornbrot (Produkt 2) und zwei Joghurts: { product_id: 6, name: 'Joghurt', qty: 2, price: 1.1 }.
Lösung
db.orders.updateOne({ _id: 58, 'items.product_id': 2 }, { $inc: { 'items.$.qty': 1 } })
db.orders.updateOne({ _id: 58 }, { $push: { items: { product_id: 6, name: 'Joghurt', qty: 2, price: 1.1 } } })
Zwei Änderungen. Zuerst $inc auf 'items.$.qty' mit dem Filter 'items.product_id': 2, dann $push der neuen Position. Mit 'items.0.qty' steigt die erste Position, und das ist das Weißbrot, nicht das Vollkornbrot. Und $set auf items ersetzt das ganze Array durch eine Position, und die Brote sind weg.
Die Idee
Aggregation
Fragen nach „wie viel“ und „wie viel pro“ (Umsatz pro Stadt, meistverkaufte Produkte) schreibt man mit aggregate: einem Array von Stufen, die die Dokumente nacheinander durchlaufen. Das ist eine Pipeline:
$matchfiltert, wie WHERE;$unwindzerlegt die Bestellung in ein Dokument für jede Position initems;$groupsammelt die Dokumente nach_idin Gruppen, wie GROUP BY, mit$sum,$avg,$minund$max;$projectbehält oder berechnet Felder, und$sortund$limitordnen und schneiden ab.
Der Umsatz pro Stadt ohne die stornierten Bestellungen, und danach die Anzahl der Bestellungen:
{ $sum: 1 } zählt die Dokumente in der Gruppe, wie COUNT(*). Ohne $unwind würden die Gruppen Bestellungen addieren, nicht Positionen. $round rundet auf den Cent: Sonst kommt eine Summe von Bruchzahlen als 22.799999999999997 statt 22,80 € heraus, wie bei allen Gleitkommazahlen.
Genauer, für Neugierige
MongoDB verspricht nach $group keine Reihenfolge der Gruppen, deshalb folgt danach ein $sort, und bei gleichen Werten auch ein zweiter Schlüssel (_id: 1). Der Simulator liefert die Gruppen in der Reihenfolge, in der sie aufgetaucht sind. Er addiert Bruchzahlen ein wenig anders als MongoDB und unterscheidet sich manchmal in der letzten Stelle. Nach $round ergeben beide dasselbe.
Übung
Versand, Allergene und Bestseller
Die Felder der Aufgaben haben die Bestellungen 53–60 und die Produkte vom Anfang der Lektion.
1. Der Kurier kommt für die bezahlten Bestellungen.
Ändere den Status aller Bestellungen mit status: 'paid' auf 'shipped'.
Lösung
db.orders.updateMany({ status: 'paid' }, { $set: { status: 'shipped' } })
db.orders.updateMany({ status: 'paid' }, { $set: { status: 'shipped' } }). Die Bestellungen 55 und 59 sind bezahlt. updateOne ändert nur die erste, die es findet (55), und 59 bleibt paid.
2. Es stellt sich heraus, dass im Butterbrötchen auch Eier stecken.
Füge 'Eier' zu den Allergenen des Butterbrötchens (Produkt 3) hinzu.
Lösung
db.products.updateOne({ _id: 3 }, { $push: { allergens: 'Eier' } })
db.products.updateOne({ _id: 3 }, { $push: { allergens: 'Eier' } }) hängt ans Ende des Arrays an. $set mit ['Eier'] ersetzt das Array, und das Gluten und die Milch sind weg. Und alergens mit einem „l“ legt ein neues Feld an, genau wie prise.
3. Das Lager will wissen, was sich am meisten verkauft.
Welche Produkte haben sich 5 Stück oder öfter verkauft (ohne die stornierten Bestellungen)? Zeige sie vom Bestseller abwärts, und bei gleicher Stückzahl nach Namen.
Lösung
db.orders.aggregate([
{ $match: { status: { $ne: 'cancelled' } } },
{ $unwind: '$items' },
{ $group: { _id: '$items.name', sold: { $sum: '$items.qty' } } },
{ $match: { sold: { $gte: 5 } } },
{ $sort: { sold: -1, _id: 1 } },
])
$match entfernt die stornierten, $unwind zerlegt die Positionen, $group nach '$items.name' addiert $sum: '$items.qty', dann ein zweites $match auf das neue Feld sold (wie HAVING) und $sort: { sold: -1, _id: 1 }. Ohne $unwind ist der Wert von '$items.qty' ein Array, und $sum in $group überspringt Arrays. Alles ergibt 0, und nichts besteht $gte: 5.
Die Idee
Wann eine Dokumentendatenbank, wann nicht
Eine Dokumentendatenbank ist gut, wenn ein Datensatz als Ganzes gelesen und geschrieben wird und sich seine Form ändert: ein Katalog verschiedener Produkte, Profile und Einstellungen, der Inhalt einer Seite, Ereignisse aus einer App, eine Bestellung, die auf einmal angezeigt wird.
Sie ist eine schlechte Wahl, wenn die Daten in alle Richtungen verbunden sind. Der Shop fragt „welcher Kunde hat was gekauft“, „welche Produkte werden zusammen gekauft“, „Umsatz pro Kategorie und Monat“. Jede dieser Fragen geht durch Kunden, Bestellungen und Produkte zugleich. In SQL sind das JOINs über normalisierte Tabellen, in denen jede Tatsache einmal geschrieben steht. Hier sind es Kopien, die gleich gehalten werden müssen, oder ein $lookup in jeder Abfrage.
Dasselbe gilt für eine Operation, die viele Dokumente berührt: eine Zahlung, die den Bestand von fünf Produkten senkt und die Bestellung speichert. Die Änderung eines Dokuments ist alles oder nichts. Mehrere Dokumente brauchen eine Transaktion, die komplexer und langsamer ist als ein einzelnes Schreiben.
Genauer, für Neugierige
Dokumentendatenbanken sind MongoDB (die am weitesten verbreitete, mit mongosh und aggregate wie im Simulator), Couchbase, Apache CouchDB, Firestore in Google Cloud und Amazon DocumentDB, das die MongoDB-Befehle versteht. MongoDB hat Transaktionen über viele Dokumente seit Version 4.0. PostgreSQL hat den Typ jsonb: eine Spalte mit einem Dokument in einer gewöhnlichen Tabelle, mit Indizes und Operatoren für ihre Felder. Das ist oft der gute Mittelweg: Tabellen für die verbundenen Daten und jsonb für die verschiedenen Eigenschaften der Produkte.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Natürliche Schlüssel und Denormalisierung · ACID: alles oder nichts · Indizes · Key-Value und der Cache
- Geht weiter in: Wie du eine Datenbank wählst
- Aufgaben zum Thema: Arten von Datenbanken