Event Stores selber bauen

Im Zusammenhang mit CQRS und Domain-Driven Design (DDD) kommt regelmäßig auch Event Sourcing zur Sprache. Das Konzept beschreibt das Speichern von fachlichen Ereignissen, aus denen sich der aktuelle Zustand der Anwendung berechnen lässt. Wie funktioniert die zugrunde liegende Datenbank, der sogenannte Event Store?

Know-how  –  9 Kommentare
Event Stores selber bauen

Event Sourcing ist ein verhältnismäßig einfaches Konzept, das dem Speichern des Anwendungszustands dient. Im Gegensatz zum herkömmlichen Ansatz, den Zustand direkt in einer relationalen Datenbank zu speichern, werden beim Event Sourcing fachliche Ereignisse in einer langen Liste gesammelt. Diese wird in einer Datenbank gespeichert, die als Event Store bezeichnet wird.

Der auffälligste Unterschied zum herkömmlichen Ansatz ist, dass die gespeicherten Ereignisse nicht mehr verändert oder gar gelöscht werden. Der Event Store dient so als Append-Only-Datenspeicher. Der Datenschatz wächst daher im Lauf der Zeit immer weiter.

Was zunächst nach Speicherplatzverschwendung klingt, eröffnet in der Praxis ganz unerwartete Vorteile: Wer statt des aktuellen Zustands die fachlichen Ereignisse speichert, die zu ihm geführt haben, erhält ganz neue Möglichkeiten, derartige Ereignisse auszuwerten. Die historischen Daten stehen praktisch en Passant zur Verfügung, sodass sich beispielsweise die Analyse von Zeitreihen äußerst einfach durchführen lässt.

Auch Zusammenhänge zwischen unterschiedlichen fachlichen Ereignissen lassen sich suchen, was unter Umständen zu neuen Einblicken in die zugrundeliegende Domäne führt. Das Besondere daran ist, dass das sogar im Rückblick funktioniert, da die historischen Daten vorliegen.

Hinzu kommt, dass es sich bei Event Sourcing um ein Konzept handelt, das seit vielen Jahren erfolgreich in der Praxis zum Einsatz kommt. Jede Bank, die Konten verwaltet, erledigt die Aufgabe auf diese Weise: Statt den aktuellen Saldo zu speichern, werden lediglich Zahlungsein- und -ausgänge vermerkt. Aus ihnen lässt sich dann der aktuelle Saldo berechnen – oder der zu jedem beliebigen Zeitpunkt in der Vergangenheit. Den Vorgang nennt man Replay.

Wurde ein Konto beispielsweise zunächst mit einer Einzahlung von 500 Euro eröffnet, dann nochmals 200 Euro eingezahlt, und dann 300 Euro abgebucht, ergibt sich folgende Rechnung:

  500 (Einzahlung)
+ 200 (Einzahlung)
- 300 (Auszahlung)
---
= 400 (Saldo)

Jeder Zahlungsein- oder -ausgang stellt dabei ein fachliches Ereignis dar. Dabei handelt es sich um Fakten, die sich nicht mehr rückgängig machen lassen: Sie sind passiert. Wurde eine Buchung fälschlicherweise durchgeführt, lässt sich lediglich eine Gegentransaktion erzeugen, die den Effekt aufhebt.

Die fachlichen Ereignisse sind allerdings zunächst zu modellieren, damit sie die Domäne der Anwendung abbilden. Dazu bietet sich der Einsatz von Domain-Driven Design (DDD) an, einem Ansatz zur fachlichen Modellierung. Zwar sind Event Sourcing und DDD nicht zwingend miteinander verbunden, sie passen allerdings hervorragend zueinander und ergänzen sich hervorragend, weshalb sich die Kombination anbietet.

Werden dem Event Store stets nur Ereignisse hinzugefügt, wächst der Datenschatz immer weiter an. Das ist aus fachlicher Sicht wünschenswert, wirft aber unter technischen Gesichtspunkten ein Problem auf. Je mehr dieser Ereignisse bei einem Replay abzuspielen sind, desto zeitaufwändiger wird der Vorgang, und desto langsamer wird die gesamte Anwendung. Allerdings lässt sich für das Problem leicht ein Ausweg finden.

Da neue Ereignisse stets am Ende der Liste hinzugefügt werden und bestehende Ereignisse niemals geändert werden, ergibt der einmal berechnete Replay für einen bestimmten Zeitpunkt stets das gleiche Ergebnis. Bemüht man wieder die Analogie mit der Kontoführung, ist das logisch: Der Kontostand zu einem gegebenen Zeitpunkt ist stets der gleiche, unabhängig davon, ob danach noch Ein- oder Auszahlungen erfolgt sind.

Den Umstand kann man sich zunutze machen, indem man gelegentlich den aktuell berechneten Zustand als sogenannten Snapshot speichert. Auf dem Weg muss nicht stets die gesamte Historie abgespielt werden. Meistens genügt es, vom letzten Snapshot auszugehen und lediglich die Ereignisse zu betrachten, die seitdem gespeichert wurden. Da ein Snapshot die Historie lediglich ergänzt, aber nicht ersetzt, stehen die älteren Ereignisse trotzdem noch zur Verfügung, sollten sie für eine Auswertung erforderlich sein.

Für den Event Store lässt sich prinzipiell jede beliebige Datenquelle verwenden, die Daten am Ende einer Liste einfügen und die gesamte Liste wieder auslesen und durchsuchen kann. Das gilt für relationale und NoSQL-Datenbanken ebenso wie für simple Textdateien, die im Dateisystem abgelegt werden. Dazu ist zunächst ein Format festzulegen, in dem die Ereignisse zu hinterlegen sind.

Kommt eine klassische relationale Datenbank zum Einsatz, bedeutet das, dass zunächst das Tabellenschema zu definieren ist. Da sich jedes Ereignis auf ein Objekt (oder im Sprachkontext von DDD auf ein Aggregat) bezieht, ist dessen ID nötig, um das Ereignis nachher wieder zuordnen zu können. Außerdem muss die Reihenfolge der Ereignisse für ein Objekt klar sein, weshalb man einen zusätzlichen Sortierschlüssel benötigt.

Außerdem sind die eigentlichen Nutzdaten des Ereignisses zu speichern, beispielsweise als JSON- oder XML-Struktur. Der Event Store greift auf die Ereignisse stets nur über deren ID und den Sortierschlüssel zu, weshalb für die Daten prinzipiell ein Feld vom Typ blob oder etwas Ähnliches genügt. Da die Kombination aus Aggregat-ID und Sortierschlüssel zudem eindeutig sein muss, empfiehlt es sich noch, ein passendes Tabellen-Constraint festzulegen. Die grundlegende Tabellenstruktur sieht daher wie folgt aus:

CREATE TABLE IF NOT EXISTS "events" (
"aggregateId" uuid NOT NULL,
"revision" integer NOT NULL,
"event" jsonb NOT NULL,
CONSTRAINT "aggregateId_revision" UNIQUE ("aggregateId", "revision")
);

In der Regel findet ein Replay lediglich für ein einzelnes Objekt statt. Hatte ein Client zwischenzeitlich aber die Verbindung verloren, kann es auch durchaus geschehen, dass er alle Ereignisse ab einem bestimmten Punkt anfordern muss. Zeitstempel funktionieren in verteilten Systemen auf Grund potenzieller Abweichungen zwischen den Systemen nur bedingt, weshalb sich eine fortlaufende ID anbietet, die sogenannte position. Sie kann zudem als Primärschlüssel dienen, sodass sich die vorige Definition wie folgt erweitern lässt:

CREATE TABLE IF NOT EXISTS "events" (
"position" bigserial NOT NULL,
"aggregateId" uuid NOT NULL,
"revision" integer NOT NULL,
"event" jsonb NOT NULL,
CONSTRAINT "${this.namespace}_events_pk" PRIMARY KEY("position"),
CONSTRAINT "aggregateId_revision" UNIQUE ("aggregateId", "revision")
);

Außerdem kann es sinnvoll sein, den Veröffentlichungszustand eines Ereignisses abzulegen, um im Zweifelsfall entscheiden zu können, welche Ereignisse den Clients bereits bekannt sind und welche nicht. Dazu dient im folgenden das Feld hasBeenPublished, das aber je nach Szenario nicht zwingend erforderlich wäre.