Schema-Evolution, Event Sourcing, CQRS und OLAP
Track Konzepte · Modellierung und Analytik · ca. 55 Min.
Was du aus Teil a brauchst
Du weißt, dass es über mehrere Systeme keine einzelne Datenbank-Transaktion gibt und dass eine Saga lokale Schritte mit Kompensation verbindet. Hier geht es um die zweite Frage: Wie änderst du etwas Laufendes, ohne dass es ausfällt? Siehe Teil 1.
Worum es geht
Sobald dein System läuft, ändert sich das Schema, während Nutzer es benutzen. Teil 2 behandelt drei Antworten:
- Schema-Evolution (schema evolution) mit Expand/Contract: Spalten ändern ohne Downtime.
- Event Sourcing und CQRS: nicht den Zustand speichern, sondern die Ereignisse, und Lesemodelle daraus ableiten.
- OLTP vs. OLAP: warum Auswertungen auf einem anderen Speicher laufen sollten als das Tagesgeschäft.
Am Ende kannst du erklären, wie du eine Spalte auf einer riesigen Tabelle ohne Downtime änderst. Alles wird wie in Teil 1 simuliert.
Von JS/TS her gedacht
| Konzept | Was du schon kennst |
|---|---|
| Event Sourcing | Ein Redux-Reducer: zustand = events.reduce(reducer, anfang) |
| Expand/Contract | Eine API-Änderung mit Deprecation-Phase: erst neues Feld zusätzlich anbieten, dann Clients umstellen, dann das alte Feld entfernen |
| CQRS | Der Redux-Store schreibt über Actions, die UI liest über Selektoren (andere Form als der Schreibweg) |
Konzept
Schritt 1: Schema-Evolution und Expand/Contract
Ein Deployment schaltet selten alle Server auf einmal um (Rolling Deployment): Für einige Minuten laufen alte und neue Version der Anwendung gleichzeitig gegen dieselbe Datenbank. Eine Migration muss mit beiden Versionen funktionieren.
Der naive Weg, eine Spalte name in vollname umzubenennen:
Sobald die Migration durch ist, bricht jede noch laufende alte Instanz. Das Muster dagegen heißt Expand/Contract (auch Parallel Change): erst erweitern (expand), dann umstellen, dann zurückbauen (contract). Jeder einzelne Schritt ist für sich rückwärtskompatibel. Die Prüffrage für jeden Schritt lautet: Funktioniert er für jede App-Version, die gerade läuft, und für Zeilen, die währenddessen neu entstehen?
Achtung: Die Buchstaben A bis E sind nur Namen der Schritte, nicht ihre richtige Reihenfolge. Die Reihenfolge herauszufinden ist Übung 1.
| Schritt | Was passiert |
|---|---|
| A | Expand: Spalte vollname hinzufügen (ALTER TABLE kunde ADD COLUMN vollname TEXT) |
| B | Backfill: bestehende Zeilen füllen (UPDATE kunde SET vollname = name WHERE vollname IS NULL) |
| C | Deploy App v2: schreibt beide Spalten (dual write), liest vollname und fällt auf name zurück |
| D | Deploy App v3: liest und schreibt nur noch vollname |
| E | Contract: Spalte name löschen (ALTER TABLE kunde DROP COLUMN name) |
Die drei App-Versionen als SQL:
| Version | Schreiben | Lesen |
|---|---|---|
| v1 (alt) | INSERT INTO kunde (name) VALUES (?) |
SELECT id, name FROM kunde |
| v2 | INSERT INTO kunde (name, vollname) VALUES (?, ?) |
SELECT id, COALESCE(vollname, name) FROM kunde |
| v3 | INSERT INTO kunde (vollname) VALUES (?) |
SELECT id, vollname FROM kunde |
COALESCE(a, b) liefert a, und wenn a NULL ist, b.
Sehr große Tabelle: Der Backfill (Schritt B) läuft dort nicht als ein riesiges UPDATE, das lange Sperren hält, sondern in kleinen Batches mit eigenem Commit. Wie lange eine Änderung eine Tabelle sperrt, hängt von Datenbanksystem und Version ab (bitte prüfen). Das Prinzip in sqlite, hier mit Batches von 4 Zeilen auf 10 Zeilen:
Übung 1: Reihenfolge bestimmen und simulieren (ca. 12 Min.)
Bringe die fünf Schritte A bis E in eine Reihenfolge, bei der zu keinem Zeitpunkt eine laufende App-Version bricht. Die Simulation simuliere(reihenfolge) steht dir in der Übung zur Verfügung: Sie führt die Schritte nacheinander aus, lässt bei jedem Schritt die aktiven App-Versionen (bei einem Deployment kurz alte und neue) je einen Kunden schreiben und alle Kunden lesen. Sie gibt None zurück, wenn alles gut geht, sonst die erste Fehlermeldung. Die Aufgabe ist, die Reihenfolge zu begründen, nicht durch Ausprobieren zu finden: Überlege vor jedem Aufruf, was passieren muss.
Frage dich bei jedem Schritt: Welche Spalte braucht er, und gibt es sie schon? Und: Welche App-Version läuft, während der Backfill arbeitet, und was schreibt sie in neue Zeilen?
reihenfolge = ["A", "C", "B", "D", "E"]
print(simuliere(reihenfolge))
reihenfolgeA zuerst, denn alles Weitere braucht die neue Spalte. C vor B: Läuft der Backfill vor dem Dual Write, schreibt App v1 danach noch Zeilen nur mit name, und der Backfill hat sie verpasst. B vor D: v3 liest nur vollname und sähe bei den alten Zeilen NULL. E zuletzt: Solange eine Version name benutzt, darf die Spalte nicht verschwinden.
Schritt 2: Event Sourcing und CQRS
Bisher speichert deine Datenbank den aktuellen Zustand: Der Warenkorb enthält zwei Tassen. Beim Event Sourcing speicherst du stattdessen die Ereignisse (events), die dorthin geführt haben, als unveränderliche Liste, an die nur angehängt wird (append-only). Der Zustand ist abgeleitet: Du spielst die Ereignisse der Reihe nach durch (Replay). Das ist exakt ein Redux-Reducer.
In JavaScript (mit Node ausgeführt) gibt der Aufruf { Tasse: 1, Buch: 1 }:
const reducer = (z, e) => ({
...z,
[e.artikel]: (z[e.artikel] ?? 0) + (e.typ === "hinzugefuegt" ? e.menge : -e.menge),
});
events.reduce(reducer, {}); // { Tasse: 1, Buch: 1 }
events.slice(0, 2).reduce(reducer, {}); // { Tasse: 2, Buch: 1 } (Stand nach 2 Ereignissen)Dasselbe in Python:
Was du dafür bekommst: vollständige Historie und Audit-Trail, Zeitreisen (Zustand zu jedem früheren Zeitpunkt, siehe die zweite Zeile), und die Möglichkeit, neue Sichten nachträglich zu bauen. Was es kostet: mehr Aufwand beim Lesen (Replay, deshalb oft mit Snapshots), und Ereignisse sind schwer zu ändern, wenn sich ihr Format ändert (Event-Versionierung).
CQRS (Command Query Responsibility Segregation) trennt Schreibmodell (commands: nimmt Befehle an, schreibt Ereignisse) und Lesemodell (queries: eine für die Abfrage optimierte Form, z. B. eine Tabelle “Kontostand je Konto”). Das Lesemodell ist eine Projektion (projection): ein Programm, das die Ereignisse beobachtet und das Lesemodell aktualisiert. Es ist eventually consistent, also kurz hinter dem Schreibmodell. Weil Nachrichten oft at-least-once ankommen (doppelt möglich, siehe Lektion 1), muss die Projektion idempotent sein. Dafür hat jedes Ereignis eine laufende Nummer nr, und das Lesemodell merkt sich die letzte verarbeitete Nummer.
Ein weiterer Gewinn: Ist die Projektion fehlerhaft oder brauchst du eine neue Sicht, löschst du das Lesemodell und spielst alle Ereignisse neu ab (rebuild).
Übung 2: Projektion mit Replay (ca. 12 Min.)
Schreibe anwenden(modell, event). Das Lesemodell sieht so aus:
modell = {"letzte_nr": 0, "konten": {}}
# konten["K1"] = {"inhaber": "Ayla", "stand": 70, "buchungen": 2}Ereignisse haben nr, typ und konto. Regeln:
eroeffnet(mitinhaber): legt das Konto mitstand0 undbuchungen0 an.eingezahlt/abgehoben(mitbetrag): ändertstandund erhöhtbuchungenum 1.geschlossen: entfernt das Konto aus dem Lesemodell.- Jeder andere Typ wird ignoriert (neue Ereignisarten dürfen alte Projektionen nicht kaputtmachen),
letzte_nrwird aber trotzdem weitergezählt. - Ist
event["nr"]nicht größer alsletzte_nr, wurde das Ereignis schon verarbeitet: nichts ändern.
Die Funktion ändert modell und gibt es zurück. Die Prüfung spielt Ereignisse einzeln, doppelt und komplett neu ab.
Was muss passieren, bevor du irgendetwas am Modell änderst? Und was gilt für letzte_nr, wenn der Typ unbekannt ist? Bedenke, dass “ignorieren” nicht “abbrechen” heißt.
def anwenden(modell, event):
if event["nr"] <= modell["letzte_nr"]:
return modell
typ, konto = event["typ"], event["konto"]
if typ == "eroeffnet":
modell["konten"][konto] = {"inhaber": event["inhaber"], "stand": 0, "buchungen": 0}
elif typ == "eingezahlt":
modell["konten"][konto]["stand"] += event["betrag"]
modell["konten"][konto]["buchungen"] += 1
elif typ == "abgehoben":
modell["konten"][konto]["stand"] -= event["betrag"]
modell["konten"][konto]["buchungen"] += 1
elif typ == "geschlossen":
del modell["konten"][konto]
modell["letzte_nr"] = event["nr"]
return modell
anwendenDie Prüfung auf nr macht die Projektion idempotent: Ein doppelt zugestelltes Ereignis ändert nichts. Und weil der Zustand nur aus den Ereignissen entsteht, ergibt ein Replay in ein leeres Modell immer dasselbe Ergebnis.
Multi-Tenancy-Modelle (Überblick)
Wenn mehrere Kunden (Mandanten, tenants) dieselbe Anwendung nutzen, gibt es drei übliche Wege, ihre Daten zu trennen. Das ist allgemeines Fachwissen, die Quelle nennt nur das Stichwort:
| Modell | Trennung | Typischer Vorteil | Typischer Nachteil |
|---|---|---|---|
Geteiltes Schema, Spalte tenant_id |
nur logisch (jede Abfrage filtert) | billig, einfach zu betreiben | ein vergessener Filter leakt Daten |
| Ein Schema pro Mandant | Schema in derselben Datenbank | bessere Trennung | Migrationen laufen pro Mandant |
| Eine Datenbank pro Mandant | physisch | stärkste Isolation | höchster Betriebsaufwand |
Auch hier gilt Expand/Contract: Bei 500 Schemas läuft eine Migration nicht atomar überall gleichzeitig.
Schritt 3: OLTP vs. OLAP
OLTP (Online Transaction Processing) ist das Tagesgeschäft: viele kleine Abfragen und Änderungen einzelner Datensätze (“Bestellung 4711 laden, Status ändern”), kurze Latenz, ACID. OLAP (Online Analytical Processing) ist Auswertung: wenige, große Abfragen über viele Zeilen, aber nur wenige Spalten (“Umsatz je Land und Monat”).
Daraus folgt die Speicherform. Ein Row Store (zeilenorientiert) legt alle Werte einer Zeile nebeneinander ab, ideal für “eine ganze Zeile lesen oder ändern”. Ein Column Store (spaltenorientiert) legt alle Werte einer Spalte nebeneinander ab, ideal für “eine Spalte über alle Zeilen summieren”, weil nicht gelesene Spalten gar nicht angefasst werden müssen (und gleichartige Werte sich gut komprimieren lassen).
Rechts (spalten["betrag"]) liest die Summe nur eine Liste, links müssen alle Zeilen mit allen Feldern durchlaufen werden. Beide Wege ergeben dasselbe Ergebnis, es geht um den Aufwand bei großen Datenmengen.
Weiter im Überblick (allgemeines Fachwissen, nur die Begriffe):
- Data Warehouse: zentraler Analytics-Speicher, meist spaltenorientiert, mit aufbereiteten Daten. Lakehouse: kombiniert Rohdaten-Ablage (Data Lake) mit Tabellen-Funktionen eines Warehouse.
- CDC (Change Data Capture): liest die Änderungen der OLTP-Datenbank (aus dem Transaktionslog) und liefert sie als Strom von Ereignissen weiter, ohne dass die Anwendung etwas ändern muss.
- Stream Processing: Verarbeitung dieses Stroms fortlaufend, statt in nächtlichen Batches.
Übung 3: Wohin mit den Auswertungen? (Multiple Choice, ca. 6 Min.)
Ein Shop speichert Bestellungen in einer Tabelle mit 40 Spalten auf seiner Hauptdatenbank. Der Checkout lädt und schreibt einzelne Bestellungen mit niedriger Latenz. Das Marketing möchte tagsüber ad hoc Auswertungen wie “Umsatz je Land und Woche” über alle Bestellungen, mit Daten, die höchstens 15 Minuten alt sind. Diese Abfragen lesen nur 2 bis 3 der 40 Spalten, bremsen aber aktuell den Checkout. Was ist die beste Lösung?
- A Die Auswertungen nur nachts erlauben und tagsüber gar nicht anbieten, damit der Checkout tagsüber Ruhe hat.
- B Auf jede der 40 Spalten einen Index legen, damit die Auswertungen schneller laufen und den Checkout weniger bremsen.
- C Das gesamte Shopsystem auf einen spaltenorientierten Store umziehen, weil der bei Auswertungen deutlich schneller ist.
- D Änderungen fortlaufend per CDC in einen spaltenorientierten Analytics-Store kopieren und die Auswertungen dort laufen lassen.
Es gibt zwei gegensätzliche Arbeitslasten. Prüfe bei jeder Option, was sie für den Checkout bedeutet und ob sie die genannte Anforderung (ad hoc, höchstens 15 Minuten alt) erfüllt.
antwort = "D"
antwortD trennt die Arbeitslasten: Der Checkout behält seine zeilenorientierte OLTP-Datenbank, die Auswertungen laufen auf einem Speicher, der für sie gebaut ist. CDC hält die Daten nahe am Ursprung (Verzögerung im Minutenbereich ist typisch, aber bitte im konkreten System messen).
Falle
Die erste Falle ist das direkte Umbenennen oder Löschen einer Spalte, während noch alte Instanzen laufen. Der Fehler zeigt sich erst im Deployment, nicht in den Tests mit nur einer Version. Und beim Event Sourcing: Projektionen, die nicht idempotent sind, ändern bei einer doppelten Zustellung ihre Zahlen stillschweigend.
Merksatz
Schema-Änderungen gelingen ohne Downtime nur in kleinen, einzeln rückwärtskompatiblen Schritten: expand, umstellen, contract. Event Sourcing speichert Ereignisse, und Lesemodelle lassen sich jederzeit daraus neu aufbauen.
Prüfstein
Wie änderst du eine Spalte auf einer sehr großen Tabelle ohne Downtime?
Zurück zu Teil 1.
Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Schicht 5 “Datenbanken” (Schema-Evolution: Migrationen ohne Downtime); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 5 mit den Punkten “Modellierung” (Event Sourcing, CQRS, Multi-Tenancy-Modelle, Expand/Contract) und “Analytik” (OLTP vs. OLAP, Column Stores, Data Warehouse, Lakehouse, CDC, Stream Processing) und der Prüfstein dazu.
Über die Quelle hinaus (allgemeines Fachwissen, die Quellen enthalten nur Stichworte): Batch-Backfill, die Drei-Phasen-Struktur von Expand/Contract im Detail, Event Sourcing als Reducer und Snapshots, Idempotenz von Projektionen, die drei Multi-Tenancy-Modelle, Row Store vs. Column Store, die Beschreibungen von Warehouse, Lakehouse, CDC und Stream Processing. Das Sperrverhalten einzelner Datenbanksysteme bei ALTER TABLE und konkrete Verzögerungen von CDC-Pipelines sind nicht ausgeführt (bitte prüfen).