Ich habe heute auf Twitter zwei Tweets gepostet, die zu reichlich Kommentaren geführt haben:
"Das denormalisierte Speichern mag für ein Gästebuch cool und easy sein, bei einem komplexen Modell wird es schnell nervig. Nervig bedeutet: fehleranfällig und enorm aufwändig."
Nun ist Twitter nicht gerade das Medium, über das man ausführlich über bestimmte Themen diskutieren kann, weshalb ich an dieser Stelle noch ein paar Worte nachreiche.
Ich arbeite seit geraumer Zeit an einem Projekt, das sehr agil entstanden ist. Heute sind wir fast "Feature complete" - die genaue Funktionsweise, die Zusammenhänge und die konkrete Implementierung waren bei Projektstart aber nur sehr wage oder noch gar nicht definiert.
Bei jenem Start haben wir uns aus dem Bauch heraus für den Verzicht auf ein RDBMS entschieden und zu RavenDB gegriffen. Über den Projektverlauf wechselten wir zu MongoDB, heute sind wir aber wieder bei RavenDB angelangt.
Gründe für NoSQL im Allgemeinen waren primär die (einfache) horizontale Skalierbarkeit, sowie die Tatsache, dass wir mit deutlich mehr Lese- als Schreibzugriffen rechneten (etwas, das sich wahrscheinlich nicht ganz halten lassen wird, wie wir heute wissen.).
Nehmen wir an, es geht um ein öffentliches Blogging-System, über welches registrierte Nutzer eigene Blogs anlegen und mit Artikeln befüllen können. Diese Artikel können kommentiert werden, primär von auf der Plattform registrierten Nutzern. Nutzerschnittstellen sind einmal der normale Webbrowser, als auch Apps für Smartphones. Die Apps können jedoch nicht nur zum Veröffentlichen von Blog-Beiträgen benutzt werden, sondern auch zum Lesen abonnierter Blogs, Kommentieren und Vernetzen mit anderen Plattform-Nutzern. Das Ganze ist also durchaus ein Stück weit "social".
Die Anwendung besteht im Kern aus folgenden Komponenten:
- API-Webanwendung (ASP.NET WebAPI)
- Website-Webanwendung (ASP.NET MVC)
- Smartphone-Apps
Sowohl Website als auch Apps beziehen ihre Daten ausschließlich über die API, welche für die Datenhaltung allein zuständig ist. Wie das ganze skaliert und ab welchem Punkt Caching oder CQRS relevant werden, soll hier nicht das Thema sein.
Was uns zunächst zum Wechsel von MongoDB zurück zu RavenDB bewegte, war die Tatsache, wie wir Daten in Dokumentform ablegen. Denn wir haben derzeit nur zwei Dokument-Typen: Blogs und User.
Ein User hat n Blogs und n andere User als Kontakte. Ein Blog wiederum hat n Autoren, n Artikel, welche wiederum n Kommentare haben.
Beim Lesen ist das derzeit tatsächlich sehr bequem, weil die einzelnen Dokumente rasend schnell ausgelesen sind und alle relevanten Informationen bereits beinhalten, die auf dem "klassischen Weg" erst über teure Joins zusammengepackt werden müssten.
Damit das aber funktioniert, müssen viele Daten stark denormalisiert gespeichert werden. So sind zum Beispiel alle von einem Nutzer abonnierten Blogs in einer Collection auf ihm gespeichert, in Form eines BlogBriefs, das die wesentlichen Informationen bereithält, die beim Auflisten aller Abonnements benötigt werden (Titel, BildID).
Umgekehrt hat zum Beispiel jeder Kommentar, der einem Artikel zugeordnet ist, welcher wiederum in einer Collection in einem Blog beheimatet ist, einen UserBrief. Dieser enthält gleichfalls den Namen des Autors und die ID seines Profilbildes.
Der Vorteil liegt auf der Hand: man lädt zur Darstellung eines Blogs bspw. genau dieses eine Blog-Dokument und kann ohne weitere Datenbankzugriffe auf einen Schlag alle Artikel mit allen Kommentaren darstellen. Und zu jedem Kommentar kann man Namen und Bild des Autors anzeigen.
Spannend wird die Sache, wenn nun ein User seinen Namen ändert. In einem RDBMS wäre das ein Update auf die Users-Tabelle und die Sache wäre gegessen (gut, der Cache müsste invalidiert werden … aber egal).
An dieser Stelle müssen jedoch sämtliche Blogs geöffnet, alle darin befindlichen Artikel und alle wiederum darin enthaltenen Kommentare durchgeschaut, und die passenden UserBriefs aktualisiert werden.
Da das Wahnsinn ist, speichert man also auf den User noch die ID jedes Blogs, in welchem er einen Kommentar geschrieben hat - somit muss man nur die Blogs aktualisieren, die wirklich betroffen sind.
Wird nun ein Blog gelöscht, müsste man wiederum alle mit ihm in Verbindung stehenden User durchgehen und die ID des Blogs aus deren Collections entfernen. Man kann natürlich auch die ID erst dann löschen, wenn mal wieder ein Profilupdate erfolgt. Auch wenn an dieser Stelle der kleine RDBMS-Administrator fürchterlich weint.
Das alles ist nervig, aber irgendwie beherrschbar (falls es für diese Denormalisierungs-Geschichten übrigens Best Practices gibt, nur her mit den Vorschlägen - momentan ist das alles Marke Eigenbau).
Nun kommt aber noch etwas dazu, ich schulde ja noch die Begründung für den Wechsel weg von MongoDB hin zu RavenDB. RavenDB beherrscht Transactions! Ja ja, Deadlocks und so weiter und so fort. Aber lieber einen Deadlock als Inkonsistenz.
Denn: bei so gut wie jedem Update müssen - schon wg. der starken Denormalisierung - mehrere Dokumente, oft sogar User und Blogs, aktualisiert werden. Und geht beim Aktualisieren eines UserBriefs zu einem Kommentar etwas schief, sollte auch das Aktualisieren des Userprofils nicht durchgehen - sonst hat der Nutzer nachher zwei Namen im System.
Man muss sehen, wie sich das in der Praxis auswirkt, wenn einmal viele Blogs mit noch mehr Kommentaren vorhanden sind und ein Update innerhalb des Transaction-Scope dutzende oder hunderte Dokumente blockiert.
Aber es erscheint derzeit als bester Kompromiss. Schade, dass wir nicht in einer perfekten Welt leben können ;-).
PS: Das Buch NoSQL Distilled von Martin Fowler ist schon auf dem Kindle und als Bettlektüre für die nächsten Tage fest eingeplant.
PPS: Alles Feedback ist willkommen.