Über unsMediaKontaktImpressum
Tobias Trelle 28. Juli 2015

MongoDB für Software-Entwickler

MongoDB [1] ist eine dokumentenorientierte NoSQL-Datenbank [2], die sich immer größerer Beliebtheit erfreut und langsam aber sicher den Einzug in die Unternehmen hält. In diesem Artikel werde ich neben einer allgemeinen Einführung insbesondere auf die Aspekte eingehen, die Sie als Entwickler beim Umstieg aus der relationalen Welt berücksichtigen müssen.

Los geht's!

Die Einstiegshürde ist bei MongoDB denkbar gering. Von der Download-Seite [3] laden wir ein ZIP (oder ein anderes Paket-Format je nach Plattform) herunter und entpacken dies in ein Verzeichnis, das wir im Folgenden als MONGO_HOME bezeichnen. Vor dem Start legen wir das Default-Verzeichnis an, in dem MongoDB seine Daten ablegt:

$ mkdir /data/db

Anschließend können wir den Server ohne weitere Kommandozeilenparameter im bin-Verzeichnis unterhalb von MONGO_HOME starten:

$ cd MONGO_HOME/bin
$ mongod

Im gleichen Verzeichnis starten wir den Kommandozeile-Client, die sog. Mongo-Shell, die sich per Default mit dem zuvor gestarteten Server auf localhost und Port 27017 verbindet und die Datenbank test verwendet:

$ mongo
MongoDB shell version: 3.0.4
connecting to: test
>

Die Mongo-Shell verwendet eine JavaScript-Engine, mit der wir Datenbank-Objekte manipulieren können. Dort fügen wir nun das erste Dokument in eine sog. Collection namens hello ein:

> db.hello.insert( { "hello": "MongoDB" } )

Mit diesem Befehl haben wir implizit eine ganze Menge an Konzepten verwendet, die wir im Folgende näher untersuchen.

Datenbanken, Collections und Dokumente

Die grundsätzliche Struktur einer MongoDB-Instanz wird in Abb.1 dargestellt. Ein Server verwaltet mehrere logische Datenbanken, die wiederum einen oder mehrere logische Namensräume enthalten, die sogenannten Collections. In einer Collection werden die einzelnen Datensätze, Dokumente genannt, verwaltet. Eine Abfrage bezieht sich immer auf genau eine Collection.

Bei einem Dokument handelt es sich nicht um ein PDF- oder Word-Dokument; MongoDB ist kein Dokumentenmanagement-System. Formal gesehen ist ein Dokument eine geordnete Menge von Key-Value-Paaren, wobei ein Value ein einfacher Datentyp, ein Array von Values oder ein eingebettetes Dokument sein kann. Ein einzelnes Key-Value-Paar wird auch Feld genannt. Ein konkretes Beispiel kann so aussehen:

{    "_id" : ObjectId("53e3663ccb3bd259f9252f67"),
    "typ" : ["gastro", "kultur"],
    "name" : "Unperfekthaus",
    "tags" : "uph unperfekt perfekt haus essen",
    "desc" : "Im Unperfekthaus bekommen Künstler & Gründer ... ",
    "adresse" : {    "str" : "Friedrich-Ebert-Straße 18",
                "plz" : 45127,
                "ort" : "Essen"
    },
    "location" : {    "type" : "Point",
                "coordinates" : [ 7.0075, 51.45902 ]
    }
}

Intern verwaltet MongoDB die Dokumente im BSON-Format [4]. Da dies ein binäres Format ist, verwenden wir hier zur Darstellung JSON, das aber die gleiche Mächtigkeit in der Struktur aufweist. Im Prinzip stellt ein Dokument einen Objekt-Baum dar, wie Sie ihn in OO-Sprachen verwenden. Den Impedance Mismatch [5], den Sie vom Object/Relation-Mapping kennen, gibt es hier also nicht, was insb. bei komplexen Objekt-Netzen und bei Vererbung ein echter Pluspunkt ist.

Schema-Freiheit

Grundsätzlich ist auf einer Collection kein Schema vorhanden. Man muss, aber man kann auch keins definieren. Dieser Umstand erfordert ein Umdenken, da die Datenbank dann nicht mehr die letzte Instanz ist, die Ihre Daten validiert. Es findet keine Typ- und Pflichtfeld-Prüfung auf einzelnen Feldern statt. Diese Aspekte müssen Sie in Ihrer Anwendung selbst implementieren.

Die Schemafreiheit bietet aber auch eine bislang ungewohnte Flexibilität: So können Objekte mit 1:n-Beziehungen direkt in einem Datensatz (=Dokument) abspeichert werden. Abb.2 zeigt den Ausschnitt aus einem UML-Model eines Shop-Systems, bei dem die Collections als Pakete modelliert sind.

Jede Bestellposition einer Bestellung referenziert zum einen das Produkt mit all seinen Attributen (auch solchen, die für den Aspekt der Bestellung unwichtig sind), zum anderen werden in der ProduktInfo die für die Bestellung relevanten Daten eines Produkts, z. B. der Name und die Größe, denormalisiert bei der Bestellung gespeichert. Entitäten vom Typ Produkt liegen in einer eigenen Collection. Unser Produktkatalog ist vielfältig (s. Abb.3), im Modell ist dies über eine Vererbungshierarchie abgebildet.

All diese verschiedenen Produkte können wir in einer Collection speichern. Ein lästiges und oft inperformantes Abspeichern mit der Single- oder Multi-Tabellen-Strategie gängiger O/R-Mapper entfällt. Suchen nach bestimmten Produktarten schränken einfach auf ein Attribut der jeweiligen Art ein, andere Produktgruppen werden dann nicht zurückgeliefert.

Bei relationalen Datenbanken liegt der Fokus bei der Datenmodellierung traditionell auf der effizienten Ablage der Daten (weil Speicherplatz ein rares Gut war, als das relationale Paradigma entstand). Im Gegensatz dazu muss man beim Schema-Design in MongoDB (oder allgemeiner bei dokumentenorientierten NoSQL Datenbanken) den Fokus auf die Abfragen legen, die auf den Daten ausgeführt werden sollen. Grund hierfür ist die bereits erwähnte Einschränkung, dass Abfragen immer auf genau einer Collection operieren. Ein "Dazu-Joinen" wie in SQL ist nicht möglich oder muss in der Anwendung selbst realisiert werden. Stellen Sie sich bei Schema-Design also zuerst die Frage nach den häufigsten Abfragen! Mit der Version 3.2, die vorraussichtlich Ende des Jahres 2015 erscheinen soll, wird es sogenannte Lookups geben, die in etwa einem Join entsprechen und mit denen man Daten auch aus mehreren Collection lesen kann [6].

Abfragen

MongoDB kennt im Wesentlichen drei Arten von Abfragen, um Dokumente in einer Collection zu suchen. Grundsätzlich werden Abfragen nicht in einer eigenen Query-Language formuliert, sondern in Form von Dokumenten, so dass komplexe Abfragen schnell in tief geschachtelten Dokumentenstrukturen münden.

Query-by-Example

Die gängigsten Suchabfragen lassen sich über das Find-Kommando auf einer Collection absetzen:

> db.pois.find( {"adresse.ort": { $gte: "E"} } )

Diese Abfrage findet alle Point-of-Interests, bei denen der Name des Orts (aus dem Subdokument adresse) mit einem "E" beginnt.

Dabei kommt das Prinzip Query-by-Example [7] zum Einsatz, bei dem die Suchkriterien im Wesentlichen mit einem Dokument beschrieben werden, das als Filter auf die durchsuchte Collection wirkt. Es stehen hier die gewohnten logischen And-, Or- und Not-Verknüpfungen zur Verfügung und auch Vergleichsoperatoren (>, >=, =, <, <=), die durch spezielle Feldnamen, die mit einem $ beginnen, umgesetzt sind. Für Arrays und Subdokumente stehen ebensolche Operatoren zur Verfügung wie für Existenz- und Typprüfungen auf Feldern. Eine vollständige Auflistung entnehmen Sie bitte der Online-Dokumentation [8].

Zu beachten ist, dass die Operanden immer konstant sind, ein Selbstbezug zwischen Feldern ist nicht möglich. Es kann z. B. nicht nach Dokumenten gesucht werden, bei denen der Wert eines Feldes x größer ist als der Wert eines Feldes y. Wenn man das dennoch tun will oder muss, kann ein spezieller Operator $where verwendet werden, dessen Anwendung allerdings relativ inperformant ist.

Das Find-Kommando liefert immer einen Cursor zurück, über den die Client-Anwendung dann iterieren kann. Dieser Cursor kann noch modifiziert werden, um Sortierungen und Beschränkungen der Treffermenge vorzunehmen, wie folgendes Beispiel demonstriert:

> db.pois.find().sort({"adresse.plz": -1}).limit(5).skip(2)

bei dem absteigend nach der Postleitzahl sortiert wird. Es wird auf 5 Treffer beschränkt, von denen die ersten beiden übersprungen werden.

Aggregierende Abfragen (also das GROUP-BY, das Sie von SQL gewohnt sind) lassen sich nicht mit dem Find-Kommando formulieren. Dazu gibt es dann das ...

Aggregation-Framework

Das sogenannte Aggregation Framework arbeitet in Form einer Pipeline, bei der die Ergebnisdokumente einer Pipeline-Operation als Eingabe der nächsten Operation dient. Das folgende Beispiel gruppiert anhand der Postleitzahl der Adresse und zählt die Dokumente mit gleicher Postleitzahl und sortiert diese absteigend nach der Anzahl

> db.pois.aggregate([
    {$group: {_id: "$adresse.ort", n: {$sum:1}}},
    {$sort: {n: -1}}
])
{ "_id" : "Essen", "n" : 5 }
{ "_id" : "Bochum", "n" : 2 }

Die Pipeline-Operatoren werden als Array von Dokumenten formuliert und mit dem Aggregate-Kommando auf einer Collection ausgeführt. Folgende Pipeline-Operatoren stehen zur Verfügung:

Operator Beschreibung
$match Sucht Dokumente analog zu find(). Sollte idealerweise mindesten 1x zu Beginn der Pipeline ausgeführt werden, um die Ergebnismenge einzuschränken.
$project Schränkt auf eine Teilmenge von Feldern ein und verändert die Werte der Felder.
$sort Sortiert die Dokumente. Analog zu sort() bei find().
$skip Überspringt n Dokumente. Analog zu skip() bei find().
$limit Begrenzt auf n Dokumente. Analog zu limit() bei find().
$group Gruppiert nach einem oder mehreren Feldern.
$unwind Wird auf ein Array angewendet. Jeder Array-Eintrag generiert dann ein neues Dokument für die nächste Pipeline-Stufe.
$redact Filtert Felder und Teilbäume des Dokuments in Abhängigkeit vom Inhalte anderer Felder.
$out Leitet das Ergebnis der Aggregation in eine Collection um. Kann nur als letzter Operator verwendet werden.

Für jeden Operator gibt es eine Menge von sogenannten Expressions, die zur Modifikation der Felder und ähnlichem dienen. Die sehr ausführliche Dokumentation aller Operatoren und Expressions finden sie online [9].

MapReduce

Unter MapReduce [10] versteht man im Allgemeinen einen Algorithmus zur teilweise parallelen Verarbeitung großer Datenmengen, der sich im Wesentlichen aus zwei Phasen zusammensetzt. In der paralellisierbaren Map-Phase werden für jedes Dokument beliebig viele Key-Value-Paare emittiert, die in der anschließenden Reduce-Phase anhand der Keys zusammengefasst werden.

In MongoDB werden die Map- und Reduce-Algorithmen als JavaScript-Funktionen implementiert, die von der serverseitigen JS-Engine auf die Dokumente einer Collection angewendet werden. Als Beispiel wollen wir einen naiven Word-Count implementieren, was in der MapReduce-Welt dem "Hello World"-Beispiel der Programmier entspricht. Dazu definieren wir in der Mongo-Shell zunächst zwei JavaScript-Funktionen:

> var map = function() {
    var text = this.desc;
    if (!text) { return;}
    text.split(' ').forEach(function(word) {
        // Remove whitespace
        word = word.replace(/\s/g, "");
        
        // Remove all non-word characters
        word = word.replace(/\W/gm,"");
        
        // Finally emit the cleaned up word
        if(word != "") { emit(word, 1); }
    });
};

Dabei ist this die Referenz auf das aktuell zu verarbeitende Dokument. Wir untersuchen ein String-Feld desc und extrahieren daraus anhand des Whitespace-Delimiters einzelne Wörter. Mit der nur im MapReduce-Kontext verfügbaren Funktion emit(...) emittieren wir ein entsprechendes Key-Value-Paar, wobei jedes aufgefundene Wort als Schlüssel verwendet wird. Die Reduce-Funktion ist dann relativ trivial:

> var reduce = function(key, values) {
    return Array.sum(values);
};

Sie erhält als Übergabe-Parameter jeweils einen der Keys aus der Map-Phase sowie ein Array mit allen zu diesem Key emittierten Werten. Diese summieren wird dann nur noch auf. Der Aufruf von MapReduce sieht dann wie folgt aus:

> db.pois.mapReduce(map, reduce, {out: "pois_wc} )

Wir spezifizieren eine Ausgabe-Collection namens pois_wc, aus der wir dann die Ergebnisse auslesen können, wobei wir uns nur für die fünf häufigsten Wörter interessieren:

> db.pois_wc.find({}).sort({value:-1}).limit(5)
{ "_id" : "und", "value" : 13 }
{ "_id" : "der", "value" : 11 }
{ "_id" : "die", "value" : 9 }
{ "_id" : "im", "value" : 8 }
{ "_id" : "fr", "value" : 7 }

Die MapReduce-Implementierung ist grundsätzlich flexibler als das Aggregation-Framework, allerdings nicht so performant, da die Implementierungen hier innerhalb der JavaScript-Engine laufen und jedes Dokument daher erst mal nach JSON konvertiert werden muss. Auch kann die MongoDB-Implementierung nicht mit dedizierten MapReduce-Frameworks wie Apache Hadoop [11] mithalten. Gemäß der NoSQL-Idee "Nimm das richtige Tool für Deinen Job" sollten Sie nicht MongoDB verwenden, wenn Sie im Wesentlichen MapReduce-Algorithmen implementieren müssen.

Indizes

Neben dem obligatorischen Primär-Index auf dem Feld _id, das in jedem Dokument existieren und pro Collection eindeutig sein muss, können Sie in MongoDB bis zu 63 weitere Sekundär-Indizes pro Collection anlegen, um Suchanfragen zu beschleunigen. Ein Sekundär-Index kann auf einem einzelnen Feld oder einer Gruppe von Feldern angelegt werden:

> db.pois.createIndex( {"adresse.ort": 1} )

definiert z. B. einen aufsteigend organisierten Index auf dem Feld ort des Subdokuments adresse. Alle Indizes einer Collection können Sie erfragen mit:

> db.pois.getIndexes()

Um zu überprüfen, ob Abfragen Indizes verwenden, können Sie ein Explain aufrufen:

> db.pois.find({"adresse.ort": "Essen"}).explain(1)

Sie erhalten dann sehr ausführliche Ausgaben des Query-Planers, auf dessen Dokumentation [12] an dieser Stelle verwiesen wird. Erwähnenswert ist an dieser Stelle die Unterstützung für Geodaten-Suche. Sie können Shapes gemäß der GeoJSON-Spezifikation [13] in Feldern ablegen und auch in Suchanfragen verwenden. So lassen sich extrem einfach Umkreis-Suchen oder z. B. Überschneidungen zwischen Polygonen ermitteln. In kommerziellen relationalen DB-Systemen zahlen Sie für solche Features in der Regel zusätzliche Lizenzgebühren.

Darüber hinaus verfügt MongoDB über eine einfache Volltext-Suche. Sie können auf einer Collection einen Text-Index anlegen, der sich über mehrere Felder erstrecken kann, die dann auch noch unterschiedlich stark für die Trefferrelevanz gewichtet werden können. Bei der Textanalyse werden ein algorithmischer Stemmer und feste Stopp-Wort-Listen verwendet; die wesentlichen westeuropäischen Sprachen sind unterstützt. An ein Lucene [14] reicht dies alles allerdings nicht heran.

CRUD = IFUR

Die üblichen CRUD-Operationen heißen in MongoDB insert, find, update und remove. Den insert-Befehl haben wir bereits zu Anfang verwendet, um ein Dokument einzufügen. Existiert die Ziel-Collection nicht, wird diese vorher implizit angelegt, sofern Sie das Recht dazu haben, was per Default aber immer der Fall ist. Den find-Befehl haben wir uns auch bereits angeschaut.

Die Semantik von Update ist etwas gewöhnungsbedürftig. Nehmen wir an, wir haben in einer Collection foo folgendes Dokument:

{_id:1, text: "hello update"}
Ein Update der Form
>db.foo.update( {_id:1}, {anzahl:1} )

würde nun beim Dokument mit der Eigenschaft {_id:1} (Suchausdruck wie bei find()) alle Felder komplett mit dem Dokument ersetzen, das als 2. Parameter übergeben wurde:

> db.foo.find()
{_id:1, anzahl: 1}

Um nur partielle Ersetzungen vorzunehmen, müssen Sie einen speziellen $set-Operator verwenden:

>db.foo.update( {_id:1}, {$set: {anzahl:1}} )

Es gibt noch weitere solche Operatoren für das Löschen von Feldern ($unset) oder das Inkrementieren ($inc) von Feld-Werten und die die Manipulation von Arrays [15].

Beachten Sie auch, dass ein Update per Default immer nur das erste gefundene Dokument manipuliert. Ein Multi-Dokumenten-Update müssen Sie durch einen weiteren Parameter explizit anfordern. Dies ist der grundsätzlichen Tatsache geschuldet, dass es keinen Transaktionsmanager wie in relationen DB-Systemen gibt; datenverändernde Operationen auf mehreren Dokumenten können also nicht zu einer logischen Einheit zusammengefasst und "atomar comittet" werden. Auch ist ein Rollback grundsätzlich (selbst von nur einem Dokument) nicht möglich. Oft ist dies allerdings nicht so kritisch wie es aussehen mag, insb. weil man ja ganze Objektnetze (mit 1:n-Beziehungen) in einem Dokument speichern kann. Manipulationen an einem Dokument sind nämlich stets atomar im Sinne des ACID-Prinzips.

Das Löschen von Dokumenten geschieht mit dem Remove-Befehl...

> db.foo.remove({_id:1})

... und unterliegt den gleichen Konsistenzbeschränkungen wie ein Update. Alle Dokumente einer Collection löschen Sie mit

> db.foo.remove()

was bei vielen Dokumenten (und Indizes) relativ teuer sein kann. Dann kann man vielleicht besser die ganze Collection als solche entsorgen (samt Index-Strukturen):

> db.foo.drop()

Ausfallsicherheit

Die Ausfallsicherheit wird bei MongoDB mit einem sogenannten Replica Set [16] sichergestellt. Dabei handelt es sich im Prinzip um ein Master-Slave-Konzept mit automatischem Failover. Der Master wird bei MongoDB Primary genannt, die Slaves Secondary (vermutlich um der Politcal Correctness zu genügen). Fällt der Primary aus, wählen die verbleibenden Secondaries einen neuen Primary.

Schreibzugriffe nimmt nur der Primary entgegen, Lesezugriffe können wahlweise vom Primary und/oder den Secondaries erfolgen. Bei jeder datenverändernde Operation können Sie den gewünschten Replikationsgrad angeben, mit dem Sie Ihr Dokument gespeichert sehen wollen. Hier geben Sie im Prinzip die Anzahl an Knoten an, die den Write bestätigen müssen, bevor die Operation als erfolgreich gilt:

> db.foo.insert({msg: "alles ist sicher"}, {writeConcern: {w:1}})
WriteResult({ "nInserted" : 1 })

Der Default ist {w:1} was der Bestätigung durch den Primary entspricht. Sie können auch den symbolischen Wert {w: "majority"} verwenden, dann erwarten Sie eine Bestätigung von der aktuellen Mehrheit der Clusterknoten, so dass Ihre Anwendung keine Kenntnis über die konkrete Anzahl von Knoten haben muss.

Horizontales Skalieren

Wenn Sie ihre Schreib- und Lesezugriffe horizontal skalieren möchten (oder müssen), setzen Sie das sog. Sharding [17] ein. Dabei werden die Dokumente anhand eines Shard Keys disjunkt auf mehrere Shards verteilt. Ein Shard kann eine einzelne mongod-Instanz sein oder besser ein ganzes Replica Set. Der Client verbindet sich dann gegen Router-Prozesse, die anhand des Shard Keys die Schreib- oder Lese-Operation an den passenden Shard bzw. eine Gruppe von Shards delegieren. Die Aufteilung der Shard Keys und die Zuordnung von Shards werden in den sog. Config-Servern verwaltet, was insgesamt zu einer komplexen System-Architektur führt. In der realen Welt setzt daher auch nur ein relativ geringer Prozentsatz der MongoDB-Nutzer ein Sharding ein.

API

Die Kommunikation mit Clients erfolgt über ein proprietäres binäres Protokoll. Für sehr viele Programmiersprachen existieren Treiber [18], die Sie relativ einfach in Ihre Anwendung einbinden können. Der Java-Treiber z. B. hat ein ähnliche Abstraktionsniveau wie das JDBC-API. Aufbauend auf dem Treiber haben sich insb. in OO-Sprachen der sog. Object/Document-Mapper (analog zu O/R-Mappern in der relationalen Welt) etabliert. Mit diesen lassen sich Objekte dann deklarativ sehr bequem auf Dokumente abbilden und oft wird die Implementierung von Queries stark vereinfacht.

Autor

Tobias Trelle

Diplom-Mathematiker Tobias Trelle ist Senior IT-Consultant bei der codecentric AG, Solingen. Er interessiert sich für Software-Architekturen und skalierbare Lösungen. Tobias hält Vorträge auf Konferenzen und Usergruppen und ist...
>> Weiterlesen
Buch des Autors:

botMessage_toctoc_comments_9210