CQRS

CQRS (Command Query Responsibility Segregation) ist ein Architekturprinzip, bei dem eine klare Trennung der Verantwortlichkeiten einer Software in Lesen und Schreiben vorgeschrieben wird. CQRS stellt damit eine Alternative zur klassischen 3 Schichten-Architektur dar. Wie das funktioniert und wozu das Ganze gut sein soll, will ich in diesem Beitrag vorstellen.

Problemstellung

Lese- und Schreib-Operationen einer Software haben meist unterschiedliche nicht-funktionale Anforderungen.
Schreib-Operationen fordern eine hohe Konsistenz der Daten und verwenden ein normalisiertes Datenmodell. Die erzeugte Last auf einem System durch Schreib-Operationen ist meist, gemessen an der Gesamtlast, eher gering. Für Schreib-Operationen besteht oft die Möglichkeit, diese asynchron auszuführen.
Für Lese-Operationen sind dagegen kurze Inkonsistenzen der Daten oftmals unproblematisch. Aus Performancegründen wird gerne ein denormalisiertes Datenmodell verwendet. Lese-Operationen erzeugen zudem die meiste Last auf einem System und sie werden oft synchron (außer z.B. von einem AJAX UI, dann auch asynchron) ausgeführt.

Diesen unterschiedlichen nicht-funktionalen Anforderungen mit einem einheitlichen Architekturansatz zu begegnen stößt schnell an Grenzen.

Selbst habe ich das schon erlebt, dass es ein schönes, normalisiertes, relationales Datenmodell gab und lesende Zugriffe, insbesondere bei Suchen, zu langsam waren. Weitere Indexe in der Datenbank haben auch keinen Geschwindigkeitsvorteil gebracht, da die SQL-Abfragen über die vielen verteilten Tabellen einfach zu komplex waren. Also wurde eine denormalisierte Tabelle in der Datenbank eingeführt, in der alle, für die Suche relevanten Daten, flachgeklopft drin standen.

Grundprinzip von CQRS

CQRS begegnet dieser Problemstellung damit, dass es eine klare Trennung der Verantwortlichkeit einer Software in Lesen und Schreiben vorschreibt. Lese-Operationen werden durch Queries, Schreib-Operationen durch Commands abgebildet. Die Software selbst wird in zwei Instanzen aufgeteilt, eine Lese-Instanz und eine Schreib-Instanz. Queries gehen an die Lese-Instanz, Commands an die Schreib-Instanz. Beide Instanzen haben ihr eigenes Datenbankmodell, das Read-Model für Lesen und das Write-Model für Schreiben.

Die Architektur von CQRS
Die Architektur von CQRS

Commands vom User-Interface, oder von anderen Anwendungen, werden von der Write-Instanz in einem Command-Handler verarbeitet. Dieser verändert das Write-Model und publiziert die Änderung über ein Event. Das Event wird von einem Event-Handler der Read-Instanz aufgegriffen und das Read-Model aktualisiert.
Queries werden vom User-Interface, oder von anderen Anwendungen, direkt an die Read-Instanz gesendet und durch das Read-Model bedient.

Wichtig ist, dass die Commands Befehle an die Anwendung darstellen und die fachliche Intention des Benutzers wiederspiegeln sollen. Dabei soll nicht, wie oft in der 3-Schichten-Architektur, ein Datensatz gelesen und dann wieder komplett gespeichert werden. Sondern es soll genau ein Anwendungsfall abgebildet werden. Z.B. „Ändere Adresse“ oder „setze Kundenstatus auf Premium“ bei einem CRM System oder „setze Aufgabe auf erledigt“ bei einem Task-Management System. Der Command Handler hat dann die Aufgabe, diesen Befehl in Änderungen im Datenbank-Modell umzusetzen.

Dieser Ansatz wirkt einem Monolithen entgegen und führt zu kleinen unabhängigen Services. Ist das sogar ein Schritt in Richtung Microservices?

Laut dem CAP-Theorem kann ein verteiltes System nicht gleichzeitig Konsistenz, Verfügbarkeit und Partitionstoleranz garantieren. Eine auf CQRS basierte Architektur sollte  immer Verfügbarkeit und Partitionstoleranz den Vorzug vor Konsistenz zwischen Read- und Write-Model geben.
Übrigens gehen diesen Weg auch die NoSQL Datenbanken, mit dem BASE-Prinzip (Basically Available, Soft state, Eventual consistency). Dabei besteht eine potentielle Inkonsistenz zwischen der Master-Instanz und den Slave-Instanzen, die verteilt sind.

Pros und Cons von CQRS

Vorteile

CQRS bietet eine Reihe an Vorteilen, insbesondere in Bezug auf die nicht-funktionalen Anforderungen, die eingangs beschrieben wurden.

So können Read- und Write-Model unterschiedliche Datenbank-Modelle und sogar Datenbanken haben. Das Write-Model ist normalisiert, z.B. in einer relationalen Datenbank und sorgt für Konsistenz der Daten. Das Read-Model hingegen ist denormalisiert und liegt bereits im JSON-Format in einer NoSQL-Datenbank in der Form, wie es das User-Interface benötigt.
Dadurch werden nicht nur die Abfragen auf die DB schneller, auch entfällt die, bei jedem Zugriff nötige, Transformation und ggf. die Aggregation der Daten aus dem normalisierten Model in das, durch das User-Interface, benötigte Format.

Zudem ist es möglich im Read-Model nur einen Teil der Daten vorzuhalten. Z.B nur die Attribute, die im UI angezeigt werden oder nur die Daten der letzten 90 Tage, falls nur diese operativ benötigt werden.
Auch kann das Read-Model flexibel um neue, weitere Sichten bzw. Modelle ergänzt werden ohne, dass die komplette Anwendung und das Write-Model verändert werden müssen.

Werden Read- und Write-Instanz physisch getrennt deployed besteht sogar die Möglichkeit diese unterschiedlich zu skalieren und verschiedene Service Levels anzubieten. Die Read-Instanz kann hoch verfügbar und extrem hoch skaliert werden, um vielen Anfragen von Clients und mobilen Endgeräten gerecht zu werden. Die Write-Instanz dagegen wird weniger hoch skaliert, da es in der Regel weniger Commands als Queries gibt.
Auch kann die Write-Instanz für Wartungszwecke offline gehen, während die Read-Instanz weiter läuft. Werden Commands asynchron als Messages gesendet, können diese in dem Wartungsfenster gepuffert und später ausgeführt werden. Somit können Commands auch im Wartungsfenster entgegengenommen werden und der Client kann trotz Wartung weiter arbeiten.

Einsparungspotential mit CQRS
Einsparungspotential mit CQRS

Ein weiterer interessanter Aspekt ist der Einsatz von verschiedenen Technologien in der Read- und Write-Instanz.
Im Read-Model kommt es auf Performance und hohe Skalierbarkeit an, Transaktionalität ist nicht notwendig. Somit können günstige NoSQL Datenbanken oder Open Source Lösungen wie MySQL und einfache Application Server wie ein Tomcat zum Einsatz kommen. Oder sogar spezialisierte Technologien wie node.js, vert.x oder Akka.
Dagegen ist beim Write-Model Konsistenz und Transaktionssicherheit das oberste Gebot. Somit empfehlen sich ApplicationServer und Datenbanken die dies sicherstellen, die aber auch oft komplex und kostenintensiv sind.
Diese Aufteilung spart Kosten, da nicht die komplette Anwendung (Read- und Write-Instanz) auf den komplexen und kostenintensiven Technologien laufen müssen. Hoch skaliert werden nur die günstigen Lösungen in der Read-Instanz, die Write-Instanz kann deutlich kleiner ausfallen.

Ein weiterer Vorteil ist, dass Read- und Write-Instanz unabhängig voneinander weiter entwickelt werden können, ggf. sogar von unterschiedlichen Entwicklungsteams.

Dadurch, dass die Commands exakt die Intention des Nutzers wiederspiegeln und nicht nur einfach sagen „speichere Datensatz“ können potentielle konkurrierende Schreib-Zugriffe und sync-Konflikte aufgelöst werden.
Beispiel: Ein Kunde ändert seine Adresse, ein Mitarbeiter ändert gleichzeitig den Status des selben Kunden auf „Premiumkunde“. Würde, wie klassisch oft gemacht, der Kundendatensatz gelesen, geändert und wieder gespeichert werden, gäbe es einen Konflikt. Lösungsansätze gab es dafür bisher verschiedene, pessimistisches, optimistische Locking, oder der letzte gewinnt. Mit den Commands kann fachlich geprüft werden, ob ein gleichzeitiges Ändern von Adresse und Status einen Konflikt darstellt. Wenn nicht, können diese beiden Updates unabhängig voneinander durchgeführt werden.

Zusätzlich können alle Commands separat gespeichert werden. Damit erhält man neben einem Audit Log weitere Vorteile, die aber den Rahmen dieses Artikels sprengen würden. Dies ist ein eigenes Thema und fällt unter die Rubrik Event Sourcing.

Nachteile

Die Nachteile sollen natürlich nicht verschwiegen werden.

Der wichtigste Punkt ist die potentielle Inkonsistenz zwischen dem Read- und dem Write-Model. Diese besteht zumindest für die Zeit, zwischen dem Speichern der Änderung im Write-Model und der Aktualisierung des Read-Models. Ein User kann also potentiell veraltete Daten bekommen. Diese temporäre Inkonsistenz wird aber bewusst von CQRS, zugunsten von Verfügbarkeit und Partiotionstoleranz, eingegangen (siehe CAP-Theorem).

Problematisch wird es, wenn die Update-Events vom Read-Model nicht verarbeitet werden können und auf einen Fehler laufen. Dann ist ein Retry oder eine manuelle Nachverarbeitung nötig. Es muss also eine Clearing-Stelle (maschinell oder manuell) eingeführt werden, die Events, die auf einen Fehler laufen nachverarbeitet. Dies ist ein klassisches Problem von asynchronen Messaging Systemen. Die EAI-Patterns bieten als Lösung den Dead Letter Channel und den Invalid Message Channel.

Ein weiterer Nachteil liegt darin, dass zwei Instanzen einer Anwendung mit jeweils einer Datenbank gewartet werden müssen. Ob dies nun wirklich ein Nachteil, oder, durch die Trennung der Verantwortlichkeiten, eher ein Vorteil ist, darüber lässt sich diskutieren.

Iteratives Vorgehen

Bei einer CQRS Architektur kann man iterativ vorgehen und klein starten. Wichtig ist, dass von Anfang an die logische Trennung der Anwendung in Lesen und Schreiben vorgenommen wird. In Java z.B. durch zwei getrennte Interfaces und getrennt Module. Diese Module können zunächst in einem Paket gepackt und deployed werden. Auch ist es im ersten Schritt möglich eine Datenbank zu nutzen und nur eine Trennung in Form von verschiedenen Tables vorzunehmen. Mit wachsender Last auf das System, kann die logische Trennung immer mehr in eine physische ausgeweitet werden. Bis dahin, dass es zwei getrennt physische Instanzen und zwei getrennt Datenbanken gibt.

Fazit

CQRS ist ein interessanter Ansatz der speziell bei nicht funktionalen Anforderungen bezüglich Skalierbarkeit und Verfügbarkeit hilft. Wenn Konsistenz der Daten, auch im verteilten System, das oberste Gebot ist, ist CQRS der falscher Ansatz. Eine Entscheidung für CQRS sollte somit bewusst getroffen werden. Es macht also vorher Sinn, sich das Dreieck des CAP-Theorems genauer anzuschauen und zu prüfen, welche Kanten sind für mein System wichtig. Wenn man zur Entscheidung kommt, Verfügbarkeit und Skalierbarkeit sind wichtig, dann ist CQRS ein guter Ansatz. Dies trifft oft bei Anwendungen aus den Bereichen Internet, Mobile oder auch IoT zu. Die nicht funktionalen Anforderungen aus diesen Bereichen sind mit den klassischen Ansätzen oft nicht umsetzbar bzw. stoßen bald an (finanzielle) Grenzen.

In einem folgenden Artikel werde ich an einem fiktiven Praxisbeispiel zeigen, wie CQRS bei der Integration von Systemen und deren Entkopplung genutzt werden kann.

weiterführende Informationen

2 Replies to “CQRS”

  1. Im Artikel habe ich den Event Handler, der die Update Events verarbeitet und in das Read Model schreibt der Read Instanz zugeordnet. Motivation war eine Entkopplung der Read- und Write-Instanz. Read und Write Model bzw. Instanz sollen unabhängig voneinander weiter entwickelt und deployed werden können, ganz im Sinne von Microservices.
    Auf der W-JAX 2014 war die Empfehlung, in einem Vortrag über CQRS von Michael Plöd, den Event Handler der Write-Instanz zuzuordnen. Vor dem Hintergrund der unterschiedlichen Skalierung von Read- und Write-Instanz absolut richtig. Der Event-Handler verarbeitet die Update-Events, die immer dann auftreten, wenn es eine Aktualisierung im Write Model gibt. Somit gibt es genau so viel Last auf dem Event-Handler wie auf dem Command-Handler und sie müssen gleich skaliert werden. Sind beide nun auf die Read- und die Write- Instanz verteilt, müssen beide Instanzen doch wieder identisch skaliert werden und damit wird ein Ziel von CQRS nicht erreicht.
    Auf der anderen Seite entsteht, wenn man den Event-Handler der Write-Instanz zuordnet, eine enge Kopplung zwischen Read- und Write-Instanz, da beide auf das Read Model zugreifen und es somit kennen müssen. Änderungen im Read Model führen dazu, dass sowohl die Read- als auch die Write-Instanz geändert und deployed werden müssen. Damit ist die lose Kopplung futsch.
    Ein Ausweg wären 3 Deployment-Einheiten:
    1. Read-Instanz für die Queries
    2. Write-Instanz für die Commands mit dem Command Handler
    3. Event Instanz mit dem Event Handler
    Damit wären Read- und Write-Instanz entkoppelt und können trotzdem unabhängig voneinander skaliert werden.
    Ob das nun zu viele Deployment-Einheiten sind, ist Geschmacksache. Letztlich muss man sich entscheiden, welches Ziel einem am wichtigsten ist und bewusst eine Entscheidung treffen.

  2. Pingback: CQRS im Integrations-Szenario | discoveration

Schreibe einen Kommentar

Anmelden um einen Kommentar abzugeben.

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

*