Datenkonsistenz in Microservice-Projekten mithilfe des Outbox-Patterns

Die Herausforderung der Konsistenz in Microservice-Architekturen

In der Softwareentwicklung haben sich Microservices als beliebte Architektur für die Entwicklung skalierbarer und flexibler Anwendungen etabliert. Die verschiedenen autonomen Services interagieren häufig unter Verwendung von Message-Brokern miteinander. Dabei stellt die Erhaltung der Daten-Konsistenz zwischen diesen verteilten Services eine komplexe Herausforderung dar.

Man stelle sich vor, ein Service führt eine Datenänderung durch und möchte andere Services über diese Änderung informieren. Dazu persistiert er die Änderung in seiner Datenbank und initiiert über einen Message-Broker einen Update-Event, der von den anderen Services konsumiert wird. Was passiert aber, wenn ein Fehler auftritt, nachdem das Event gesendet wurde, aber bevor die Datenänderung in der Datenbank committed wurde? Diese Situation kann zu Inkonsistenzen führen, da die anderen Services aufgrund des abgesetzten Events bereits über eine Datenänderung informiert wurden, die vom initialen Service möglicherweise rückgängig gemacht werden muss.

Das Outbox-Pattern als elegante Lösung

Das Outbox-Pattern bietet eine elegante Lösung für dieses Konsistenzproblem. Es basiert auf dem Change-Data-Capture-Prinzip (CDC). Dabei werden Datenänderungen in der Datenbank protokolliert, indem sie in einem Event-ähnlichen Format abgelegt werden. Diese Änderungs-Events werden in Echtzeit von einem nachgeschalteten Prozess verarbeitet, der anhand der Protokoll-Records entsprechende Update-Events über den Message-Broker absetzt. Dadurch wird sichergestellt, dass Datenänderungen und Update-Events atomar und konsistent sind. Im Fehlerfall, wie er vorhin beschrieben wurde, würde ein Rollback das Speichern des Protokoll-Records verhindern, infolgedessen kein inkonsistenter Update-Event abgesetzt werden kann.

Um die Änderungs-Events in der Datenbank zu speichern, sieht das Outbox-Pattern eine eigens dafür vorgesehene Tabelle in der Service-Datenbank vor. Diese Tabelle wird allgemein als Outbox-Tabelle bezeichnet. Neben der Outbox-Tabelle ist das Konzept der Eventual-Consistency ein weiteres Schlüsselelement des Outbox-Patterns. Es bedeutet, dass temporäre Inkonsistenzen zwischen den einzelnen Services des verteilten Systems akzeptabel sind. Die Konsistenz wird erst im Laufe der Zeit durch die schrittweise Verarbeitung von Events hergestellt.

Funktionsweise des Outbox-Patterns

Ohne Outbox-Pattern werden Zustandsänderungen auf der Datenbank persistiert und Änderungs-Events direkt an der Message-Broker übermittelt. Was aber, wenn es bei der Speicherung der Zustandsänderungen oder der Übertragung der Änderungs-Events zu Fehlern kommt? Solche Fehler würden zu einer Inkonsistenz zwischen den einzelnen Services führen:

  1. Zustandsänderungen können nicht gespeichert werden: Der Quell-Service fällt zurück in den alten Datenstand, während alle anderen Services Änderungs-Events konsumieren, die tatsächlich gar nie stattgefunden haben.
  2. Änderungs-Events können nicht übermittelt werden: Der Quell-Service arbeitet mit dem neuen Datenstand weiter. Andere Services haben aber nie von den Zustandsänderungen erfahren und arbeiten weiter, als wären diese Änderungen nie passiert.
Fehler führen zu Inkonsistenzen zwischen den einzelnen Services

Das Outbox-Pattern löst beide diese Probleme: Änderungs-Events zuerst auf der Datenbank gespeichert werden, bevor sie an den Message-Broker übermittelt werden.1

Dies ist der Ablauf des Outbox-Patterns in drei Schritten:

Schritt 1: Sammeln und Eintragen von Änderungs-Events in die Outbox-Tabelle

  1. Änderungen treten in einem Quell-Service auf.
  2. Diese werden in einem Event-ähnlichen Format erfasst und in der Outbox-Tabelle des Quell-Services protokolliert.
Sammeln und Eintragen von Änderungs-Events in der Outbox-Tabelle

Schritt 2: Verarbeitung der Outbox-Nachrichten

  1. Ein unabhängiger Prozess oder Dienst, der als Outbox-Prozessor bezeichnet wird, überwacht kontinuierlich die Outbox-Tabelle.
  2. Der Outbox-Prozessor liest die Änderungs-Events aus der Outbox-Tabelle und bereitet entsprechende Events für die Übertragung vor.
Verarbeitung der Outbox-Nachrichten

Schritt 3: Empfangsbestätigung und Bereinigung

  1. Der Outbox-Prozessor übergibt die Events an den Message-Broker, der seinerseits die Ziel-Services benachrichtigt.
  2. Nach erfolgreicher Übertragung sendet der Outbox-Prozessor eine Empfangsbestätigung an den Quell-Service.
  3. Die abgearbeiteten Änderungs-Events bzw. Protokoll-Records werden aus der Outbox-Tabelle entfernt.
Empfangsbestätigung und Bereinigung

Diese Abfolge von Schritten garantiert, dass Events zuverlässig erfasst, übertragen und verarbeitet werden. Auch im Fehlerfall wird jeder Änderungs-Event garantiert mindestens einmal übermittelt. Dabei bleibt der Quell-Service unabhängig vom Ziel-Service und die angestrebte Konsistenz wird schrittweise erreicht.

Implementation des Outbox-Patterns

Um das Outbox-Pattern umzusetzen brauchen es drei Komponenten:

  • Outbox-Tabelle: Die Änderungs-Events werden auf der Datenbank in der Outbox-Tabelle gespeichert, bevor sie an den Message-Broker weitergeschickt werden.
  • Message-Broker: Zwischen den einzelnen Services werden die Änderungs-Events über einen Message-Broker ausgetauscht.
  • Outbox-Prozessor: Der Outbox-Prozessor muss die Nachrichten in der Outbox-Tabelle abholen und an den Message-Broker weiterleiten.

Je nach Wahl der Datenbank und des Message-Brokers gibt es bereits etablierte Lösungen für den Outbox-Prozessor, die nur noch konfiguriert werden müssen. Eine solche Lösung wird im folgenden Abschnitt näher vorgestellt.

Debezium als konfigurierbarer Outbox-Prozessor

In unseren Projekten mit Microservice-Architektur setzen wir bei Inventage oft PostgreSQL als Datenbank und Kafka als Message-Broker ein. Für den Outbox-Prozessor bietet es sich daher an, Debezium einzusetzen. Dabei handelt es sich um ein Open-Source-Projekt, das Change-Data-Capture (CDC) für verschiedene Datenbanken und Message-Broker zur Verfügung stellt.

Im Zusammenspiel mit PostgreSQL und Kafka implementiert Debezium den Outbox-Prozessor mit Kafka-Connect in Form eines Kafka-Konnektors. Dieser konfiguriert die Outbox-Tabelle als Quelle und leitet neue Events — also neue Einträge in der Outbox-Tabelle — direkt an Kafka weiter.

Hands-On

Vorbereitend muss PostgreSQL installiert und Kafka und Kafka-Connect eingerichtet sein. Für Kafka-Connect muss ausserdem das Debezium-Plugin installiert sein.

1. Erstellen der Outbox-Tabelle

Die Outbox-Tabelle (z.B. public.outbox) in der PostgreSQL-Datenbank, die eigens für die Speicherung der Änderungs-Events vorgesehen ist, muss die notwendigen Spalten definieren, um relevante Event-Informationen wie Zeitstempel, Event-Typ und Event-Inhalt zu speichern.

Das folgende SQL-Skript erstellt die Outbox-Tabelle in der PostgreSQL Datenbank.

CREATE TABLE outbox
(
	id uuid NOT NULL
		CONSTRAINT "OUTBOX_pkey" PRIMARY KEY,
	timestamp TIMESTAMP NOT NULL,
	aggregatetype VARCHAR(256) NOT NULL,
	aggregateid VARCHAR(256) NOT NULL,
	type VARCHAR(256) NOT NULL,
	payload VARCHAR(1000000) NOT NULL
);

Jede Spalte in der Outbox-Tabelle speichert wichtige Informationen, um später daraus ein Kafka-Event zu generieren:

SpalteBeschreibung
idDer Primary-Key der Outbox-Tabelle.
timestampIn dieser Spalte wird der Erfassungszeitpunkt des Events gespeichert. Der timestamp hilft dabei, die Reihenfolge der Events nachzuverfolgen und sicherzustellen, dass sie korrekt verarbeitet werden.
aggregatetypeIn dieser Spalte wird der Typ des Aggregats gespeichert, zu welchem der Änderungs-Event gehört. Ein Aggregat ist eine Gruppierung von Entitäten in der Anwendung, z.B. eine Benutzerregistrierung oder eine Bestellung. Diese Information wird auf Kafka als Topic abgebildet.
aggregateidDie eindeutige Kennung des betreffenden Aggregats, zu dem der Änderungs-Event gehört, ermöglicht die Zuordnung des Events zur entsprechenden Entität. Die aggregateid wird später zum Key der Kafka-Nachricht.
typeDer Event-Typ gibt Aufschluss über dessen Art und Bedeutung (z.B. BenutzerRegistriert oder BestellungGeändert).
payloadDie Payload-Spalte enthält die Details des Events, die von den Zielkomponenten verarbeitet werden, im JSON-, Avro- oder Textformat.

Die Anwendung muss nun so konfiguriert werden, dass sie Änderungs-Events als neue Einträge in der frisch erstellten Outbox-Tabelle speichert. Dieser Schritt ist abhängig von der eingesetzten Technologie.

Der Programmcode für die Speicherung der Events in der Datenbank-Tabelle ist relativ einfach. Noch einfacher geht es, wenn dafür auf das Debezium-Ökosystem zugegriffen wird. In unseren Projekten setzen wir unter anderem das Java-Framework Quarkus ein. Dieses bietet eine Debezium-Extension an, mit der die Anwendung auf einfache Weise so konfiguriert werden kann, dass Kafka-Events automatisch in die Outbox-Tabelle umgeleitet werden.

2. Konfiguration des Kafka-Konnektors mit Debezium

Die Events werden nun in der Outbox-Tabelle gespeichert. Ein Kafka-Konnektor muss anschliessend so konfiguriert werden, dass er die Events aus der Outbox-Tabelle ausliest und an Kafka weiterleitet. Die Übertragung der Events an Kafka darf nur erfolgen, wenn alle anderen Datenbankänderungen erfolgreich gespeichert werden konnten.

Die Kafka-Konnektoren von Debezium nutzen die Replikationsfunktionalität von PostgreSQL, um die Ereignisse direkt aus der Datenbank abzurufen, ohne auf periodische Abfragen oder manuelles Polling angewiesen zu sein. Die Details der Replikationsfunktionalität von PostgreSQL kann in der offiziellen Dokumentation nachgelesen werden. Debezium kann ohne Weiteres auch ohne diese Detailkenntnisse erfolgreich eingesetzt werden. Es lohnt sich aber diese Kenntnisse anzueignen. Sowohl die Details der Konfiguration der Debezium Kafka-Konnektoren können so besser verstanden werden, als auch die spätere Fehlersuche im laufenden Betrieb wird durch dieses Wissen erleichtert.

Über die REST-API von Kafka-Connect können Kafka-Konnektoren erstellt und konfiguriert werden. Der folgende curl-Befehl macht sich diese API zunutze und erstellt den Kafka-Konnektor:

$ curl -X PUT -H "Content-Type: application/json" http://localhost:8083/connectors/my-connector/config --data '{
  "connector.class" : "io.debezium.connector.postgresql.PostgresConnector",
  "database.dbname" : "my_project",
  "database.hostname" : "postgres",
  "database.port" : "5432",
  "database.user" : "postgres",
  "database.password" : "postgres",
  "database.server.name" : "dbserver",
  "schema.include.list" : "public",
  "table.include.list" : "public.outbox",
  "publication.autocreate.mode" : "filtered",
  "plugin.name" : "pgoutput",
  "slot.name" : "debezium_my_project",
  "transforms" : "outbox",
  "transforms.outbox.type" : "io.debezium.transforms.outbox.EventRouter",
  "transforms.outbox.route.topic.replacement" : "${routedByValue}",
  "transforms.outbox.table.field.event.timestamp" : "timestamp",
  "transforms.outbox.table.fields.additional.placement" : "type:header:eventType",
  "transforms.outbox.table.expand.json.payload" : "true"
  "tasks.max" : "1",
  "tombstones.on.delete" : "false"
}'

Dieser Aufruf erzeugt einen Kafka-Konnektor namens my-connector:

Der Inhalt der Kafka-Nachricht wird als “echtes” JSON publiziert.

KonfigurationBeschreibung
connector.classDer Konnektor ist vom Typ PostgresConnector und nutzt das Debezium-Plugin.
database.*Der Konnektor greift auf die Datenbank my_project zu.
schema.include.listDie Outbox-Tabelle (public.outbox) wird auf neue Events überwacht.
publication.autocreate.mode, plugin.name und slot.nameSind alles Konfigurationen der Replikationsfunktionalität von PostgreSQL. Der slot.name soll den Projektnamen beinhalten und das pgoutput-Plugin wird genutzt, um Änderungen in der Tabelle public.outbox zu erfassen.
transforms, transforms.outbox.typeUm das Outbox-Pattern mit Debezium umzusetzen, transformiert der outbox.EventRouter jede Zeile in der Outbox-Tabelle zu einem Kafka-Event. Alle weiteren transforms.*-Konfigurationen sind nicht zwingend notwendig, können das Verhalten und die Nachvollziehbarkeit aber verbessern.
transforms.outbox.route.topic.replacementGibt den Namen der Topic an, an welche der Konnektor den Eintrag in der Outbox-Tabelle schickt. Die Variable ${routedByValue} legt fest, dass der Inhalt der Spalte aggregateType aus der Outbox-Tabelle als Topic-Namen verwendet wird.
transforms.outbox.table.field.event.timestampDer Erfassungszeitpunkt des Events in der Outbox-Tabelle soll auch als timestamp für den Kakfa-Event verwendet werden. Standardmässig würde Debezium den timestamp im Moment der Transformation im Kafka-Konnektor erzeugen.
transforms.outbox.table.fields.additional.placementFür eine bessere Nachvollziehbarkeit und die Möglichkeit unterschiedliche Eventtypen auf der gleichen Topic zu unterscheiden, wird der type aus der Outbox-Tabelle zusätzlich in einem Header-Feld der Kafka-Nachricht mit dem Namen eventType gespeichert.
transforms.outbox.table.expand.json.payloadDer Inhalt der Kafka-Nachricht wird als “echtes” JSON publiziert.
tasks.maxDie maximale Anzahl Konnektor-Tasks, welche die Nachrichten aus der Outbox-Tabelle auf Kafka überschreiben.
tumbstones.on.deleteFalls diese Konfiguration true ist, werden “Deletes” als Tombstone-Nachrichten auf Kafka geschrieben.

In gewissen Projekten verwenden wir auch Avro in der Payload der Kafka-Nachricht. Für Avro Nachrichten muss der Kafka-Konnektor von Debezium etwas anderst konfiguriert werden.

Beispiel

Mit der erstellten Outbox-Tabelle und der Konfiguration und Initialisierung des Kafka-Konnektors von Debezium sind die Voraussetzungen für das Outbox-Pattern gegeben. Abschliessend soll an dieser Stelle das Outbox-Pattern nochmals am Beispiel einer Bestellung erklärt und veranschaulicht werden.

Bestellung Geändert Beispiel

Durch die Änderung der Bestellung (1) wird ein Änderungs-Event generiert und zusammen mit den Zustandsänderungen in der gleichen Transaktion (2) in die Outbox-Tabelle gespeichert:

SpalteInhaltBeschreibung
id7d826f00-9e19-4997-a2d2-320693e5ea46Automatisch von PostgreSQL generierte UUID
timestamp2023-09-15 15:13.20Zeitpunkt der Erstellung dieses Eintrags in der Outbox-Tabelle
aggregatetypeBestellungAlle Änderungs-Events für Bestellungen werden in die gleiche Topic geschrieben
aggregateid183662Eindeutige Bestellnummer
typeBestellungGeändertÜber den Event-Typ lassen sich die unterschiedlichen Änderungs-Events von Bestellungen unterscheiden
payload"{ \"id\": 183662, \"items\": [{\"id\": 293810, \"beschreibung\": \"Bildschirm\"}]}"Inhalt der Kafka-Nachricht. Die Bestellung mit der Nummer 183662 beinhaltet das Item “Bildschirm”. Diese Payload ist nur beispielhaft. Kafka-Nachrichten die in unseren Microservice-Projekten ausgetauscht werden, beinhalten meist mehr Detail-Informationen.

Dieser Eintrag in der Outbox-Tabelle (3) wird vom Kafka-Konnektor von Debezium gelesen, transformiert und (4) schliesslich auf Kafka publiziert. Die Nachricht auf Kafka hat folgenden Inhalt.

FeldInhaltBeschreibung
key183662Eindeutige Bestellnummer
value{ id: 183662, items: [{id: 293810, beschreibung: "Bildschirm"}]}Inhalt der Kafka-Nachricht
header->id09730326-7c9e-4e40-bf1e-2f550b9339daAutomatisch von Kafka generierte UUID
header->eventTypeBestellungGeändertEvent-Typ des Änderungs-Events

Nach dem erfolgreichen Publizieren der Nachricht auf Kafka wird der Eintrag aus der Outbox-Tabelle gelöscht. Andere Microservices können die Kafka-Nachricht zur Änderung der Bestellung mit der Nummer 183662 konsumieren und ihrerseits ihre Daten entsprechend nachführen.

Footnotes

1 Beide Probleme können auch durch verteilte Transaktionen gelöst werden: Treten beim Schreiben der Datenänderungen in die Datenbank oder beim Publizieren der Nachricht auf dem Message-Broker Probleme auf, werden alle Änderungen auf dem Message-Broker und in der Datenbank rückgängig gemacht (Rollback). Wenn sowohl die Softwarebibliotheken für die Anbindung an die Datenbank und den Message-Broker, als auch der Anwendungscontainer selbst die X/Open XA-Spezifikation implementieren, können verteilte Transaktionen ohne grossen Entwicklungsaufwand konfiguriert werden.