Webentwicklung & Cloud-Lösungen

Backend-Devs kaufen mit neuen Tools unsichtbare Wartung

Ein neuer Event-Broker wirkt für einen Backend-Entwickler wie die saubere Antwort auf einen langsamen Bestellprozess: API antwortet sofort, Arbeit läuft später. Meine Gegenposition: Für den ersten asynchronen Workflow würde ich keinen Kafka-Cluster einführen, weil die eigentliche Arbeit erst bei Migrationen, Duplikaten und Wiederherstellungen beginnt. Diese Wartungslast taucht in der Architekturzeichnung selten auf, bleibt aber nach dem ersten Release dauerhaft beim Team.

Für den ersten asynchronen Auftrag ist ein Broker oft die teurere Wahl

Stell dir einen Dienst vor, der nach dem Speichern einer Bestellung eine Bestätigung versendet. Der HTTP-Request soll nicht auf den Mailanbieter warten. Damit ist noch nicht entschieden, dass Apache Kafka 4.0 gebraucht wird. Entscheidend ist zunächst, was bei einem Absturz zwischen Datenbank-Commit und Versandauftrag passieren darf: Geht der Auftrag verloren, fehlt die Bestätigung; wird er zweimal verarbeitet, erhalten Kunden möglicherweise zwei Nachrichten.

Ich lese Junior Devs sehen die Wartungslast neuer Tools zu spaet kritisch, weil Erfahrung allein diese Lücke nicht schließt: Auch ein erfahrenes Team kann einen Broker korrekt betreiben und trotzdem die Grenze zwischen Geschäftsdatensatz und Ereignis falsch setzen. Vor der Toolwahl würde ich daher einen konkreten Fehlermodus aufschreiben: Der Prozess stirbt unmittelbar nach dem Commit der Bestellung. Wo liegt jetzt der noch offene Versandauftrag?

Hier stehen zwei benannte Optionen gegenüber. PostgreSQL-Outbox mit Polling gewinnt, wenn Bestellung und Versandauftrag in derselben PostgreSQL-16-Datenbank entstehen und ein kurzer Verzug akzeptabel ist; sie kostet zusätzliche Tabellenpflege, Polling-Last und einen verlässlichen Worker. Apache Kafka mit KRaft gewinnt, wenn mehrere unabhängige Verbraucher denselben Ereignisstrom benötigen oder eine längere, gezielte Wiederverarbeitung fachlich vorgesehen ist; es kostet Broker-Betrieb, Topic- und ACL-Pflege, Client-Upgrades sowie einen Plan für die Kopplung an die Datenbanktransaktion. Kafka entfernt diese Kopplung nicht automatisch, weil ein SQL-Commit und ein Kafka-Publish zwei getrennte Operationen bleiben.

RabbitMQ 4.1 wäre ebenfalls kein kostenloser Mittelweg: Queues und Quorum Queues lösen andere Zustellprobleme, während ihre Aufbewahrung, Überwachung und Wiederherstellung trotzdem jemand übernehmen muss. Redis Streams mit XREADGROUP kann für vorhandene Redis-Infrastruktur attraktiv sein, verlangt aber ebenfalls Entscheidungen über Pending Entries und deren erneute Zuweisung. Ich würde keines dieser Systeme allein deshalb einführen, weil „asynchron“ im Ticket steht; ein zusätzlicher Dienst vergrößert die Zahl der Ausfallzustände, ohne die fachliche Zustellregel festzulegen.

Für die erste Entscheidung reicht eine ehrliche Größenordnung. Als Beispielrechnung, nicht als Messwert, ergeben 10 000 Bestellungen pro Tag und 30 Tage Aufbewahrung 300 000 Outbox-Einträge vor Bereinigung. Diese Zahl ist noch kein Argument für oder gegen Kafka, weil Payload-Größe, Indizes und Datenbanklast den tatsächlichen Preis bestimmen. Sie ist aber konkret genug, um Speicher, Löschung und einen Restore zu planen.

Die Outbox ist nur dann einfach, wenn ihre Aufräumarbeit mitgebaut wird

Eine Outbox ist keine magische Zuverlässigkeitsschicht. Sie funktioniert für diesen Fall, weil derselbe PostgreSQL-Commit sowohl die Bestellung als auch den Auftrag zum Versand dauerhaft macht. Der Worker liest offene Einträge danach getrennt aus. Das folgende Schema lässt sich in psql auf PostgreSQL 16 ausführen:

CREATE TABLE IF NOT EXISTS outbox (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  event_type text NOT NULL,
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  published_at timestamptz
);
CREATE INDEX IF NOT EXISTS outbox_pending_idx
  ON outbox (id) WHERE published_at IS NULL;

Das Schema zeigt den Vorteil und die Rechnung, die oft verschwiegen wird. Jede neue Ereignisart benötigt eine stabile Bedeutung für event_type und payload. Wird das Format geändert, können noch unverarbeitete Zeilen im alten Format liegen; ein Deployment, das ausschließlich das neue Format versteht, lässt sie dauerhaft hängen. Eine additive Änderung oder ein explizites Versionsfeld im Payload kostet zunächst mehr Code, erlaubt aber die Verarbeitung während eines Rollouts. Flyway kann die Datenbankmigration ordnen, ersetzt jedoch keinen Kompatibilitätstest mit alten Outbox-Zeilen.

Für den Dispatcher würde ich als anfänglich zu justierenden Wert ein Polling-Intervall von 500 ms wählen und erst nach Messung von Verzögerung und Datenbanklast ändern. Mehrere Worker können Einträge mit SELECT … FOR UPDATE SKIP LOCKED aufteilen, weil gesperrte Zeilen dann nicht gegenseitig die Verarbeitung blockieren. Die Abfrage braucht ein begrenztes Batch und eine feste Sortierung nach id; ohne Limit kann ein Worker zu viele Zeilen sperren, und ohne nachvollziehbare Reihenfolge wird ein Rückstau schwerer zu untersuchen.

Der partielle Index auf offene Einträge hält die Suche gezielt, löst aber die Bereinigung nicht. Sobald published_at gesetzt wird, entstehen Änderungen, die PostgreSQL per Autovacuum aufarbeiten muss. Wer bestätigte Zeilen unbegrenzt behält, bezahlt länger für Backups und Restore, obwohl der Dispatcher sie nicht mehr benötigt. Eine Retention-Regel muss deshalb benennen, wann ein Eintrag gelöscht werden darf und wo ein Nachweis für tatsächlich versendete Bestätigungen verbleibt. Falls dafür fachlich ein Audit-Trail nötig ist, gehört er nicht stillschweigend in eine endlos wachsende Worker-Tabelle.

Ich lese Als Junior siehst du die Wartung neuer Tools zu spaet mit Gewinn, widerspreche aber der naheliegenden persönlichen Deutung: Die Wartungslast entsteht hier aus fehlenden Zuständigkeiten, nicht aus dem Erfahrungsgrad der Person, die das Schema angelegt hat. Vor dem Merge sollten daher Besitzer für Schemaänderungen, Bereinigung und Alarmierung feststehen.

Nach einem Absturz zählt Idempotenz mehr als das Zustellversprechen

Angenommen, der Worker verschickt die Bestätigung und stürzt ab, bevor er published_at setzt. Beim Neustart findet er denselben Eintrag wieder. Das ist bei einer Outbox kein überraschender Sonderfall, sondern eine unvermeidliche Lücke zwischen dem externen Effekt und der lokalen Bestätigung. Auch ein Kafka-Consumer muss mit erneuter Verarbeitung rechnen, weil ein Commit seines Offsets und ein Aufruf beim Mailanbieter nicht dieselbe Transaktion bilden.

Die Versandseite benötigt deshalb einen stabilen Idempotenzschlüssel, etwa die Outbox-id zusammen mit dem Ereignistyp. Unterstützt der Mailanbieter einen solchen Schlüssel, lässt sich ein wiederholter Aufruf dort abfangen. Unterstützt er ihn nicht, kann die eigene Datenbank Wiederholungen zwar erkennen, aber den Absturz nach erfolgreichem Versand und vor lokaler Bestätigung nicht eindeutig auflösen. Ich würde in diesem Fall keine „genau einmal“-Zusage machen, weil sie für den gesamten Weg bis zum externen Anbieter nicht belegbar wäre.

Auch das Retry-Verhalten braucht eine Grenze. Als bewusst festzulegende Betriebsregel könnten 3 erfolglose Versuche vor einer manuell prüfbaren Fehlerablage stehen; der richtige Wert hängt davon ab, ob Fehler meist vorübergehend oder dauerhaft sind. Ein ungültiger Empfänger wird durch schnelles Wiederholen nicht gültig, während ein kurzer Timeout beim Anbieter nach einer Pause verschwinden kann. Speichere deshalb mindestens Fehlertyp, letzten Versuch und nächsten geplanten Versuch, statt dieselbe kaputte Nachricht bei jedem Poll sofort erneut zu senden.

Ein Rückstau ist erst sichtbar, wenn seine Bedeutung gemessen wird. Prometheus kann die Anzahl offener Outbox-Einträge und das Alter des ältesten offenen Eintrags erfassen; Grafana kann daraus ein Dashboard machen. Als vorgeschlagenes SLO für diesen Beispielprozess sollten 99,9 % der Bestätigungsaufträge innerhalb von 2 Minuten abgearbeitet sein. Das ist keine Herstellerangabe und kein allgemeingültiger Grenzwert: Eine Anmeldung mit zeitkritischem Einmalcode hätte andere Anforderungen als eine gewöhnliche Bestellbestätigung. OpenTelemetry-Traces helfen bei der Ursachenanalyse, wenn sie Bestell-ID und Ereignis-ID verbinden, ohne sensible Payloads als Attribute zu kopieren.

Bei Kafka kommt eine weitere Uhr hinzu. Die Apache-Kafka-Konfiguration nennt für retention.ms standardmäßig 604 800 000 ms, also 7 Tage. Dieser veröffentlichte Default ist keine fachliche Aufbewahrungsentscheidung: Ist ein Verbraucher länger außer Betrieb, muss das Team wissen, ob die fehlenden Ereignisse noch im Topic liegen und wie es die Lücke feststellt. Genau diese Frage sollte vor der Einführung beantwortet sein, weil ein erfolgreich gestarteter Consumer keine Vollständigkeit der verarbeiteten Bestellungen beweist.

Der Restore-Test entscheidet, ob sich das neue Werkzeug lohnt

Die laufende Wartung zeigt sich besonders deutlich nach einem Datenbank-Restore. Spielt das Team einen älteren PostgreSQL-Stand ein, können Outbox-Einträge erneut offen sein, obwohl die Bestätigung längst verschickt wurde. Startet der Dispatcher sofort, produziert er möglicherweise Duplikate. Bleibt er aus, fehlen vielleicht Aufträge, die erst nach dem Zeitpunkt des Backups entstanden sind. Der Wiederanlauf braucht deshalb eine schriftliche Entscheidung: Welche Quelle gilt für bereits ausgeführte externe Effekte, und wer gibt die Verarbeitung wieder frei?

Mit Kafka wird der Restore nicht automatisch leichter. Nach dem Zurücksetzen der Datenbank können Datenbankstand, Topic-Inhalt und Consumer-Offsets unterschiedliche Zeitpunkte repräsentieren. Ein Team muss dann festlegen, ob es Ereignisse erneut einspielt, Zustände aus Kafka rekonstruiert oder Unterschiede gezielt abgleicht. Diese Arbeit ist gerechtfertigt, wenn Wiederverarbeitung ein geplanter Produktbedarf ist; für eine einzelne Bestätigung ist sie häufig teurer als ein gut verstandener Outbox-Restore.

Ein brauchbarer Test beginnt nicht mit einem Architekturdiagramm, sondern mit einer isolierten Umgebung und drei dokumentierten Zuständen: Bestellung gespeichert, Outbox-Eintrag noch offen und Bestätigung bereits extern ausgelöst. Stelle ein Backup wieder her und prüfe für jeden Zustand, ob der Worker etwas verliert oder doppelt ausführt. pg_stat_statements kann anschließend zeigen, ob die Polling-Abfrage unter Rückstau auffällig teuer wird; EXPLAIN (ANALYZE, BUFFERS) zeigt für einen konkreten Testlauf, ob der partielle Index tatsächlich genutzt wird. Die Messwerte gehören zur eigenen Installation, weil Datengröße und Lastprofil die Kosten bestimmen.

Erst wenn dieser Ablauf verlässlich funktioniert, würde ich den Wechsel zu Debezium 3.0 oder Kafka prüfen. Debezium kann Änderungen über PostgreSQL Logical Decoding erfassen und damit Polling ersetzen, verlangt aber die Pflege von Replication Slots; ein liegen gebliebener Slot kann WAL-Dateien zurückhalten und Speicher füllen. Ein zusätzlicher Connector spart also möglicherweise Abfragen, schafft jedoch eine neue Überwachungs- und Wiederherstellungsaufgabe. Der Tausch lohnt sich, wenn die gemessene Polling-Last oder die benötigte Ereignisverteilung ihn rechtfertigt, nicht weil Change Data Capture eleganter klingt.

Der erste Schritt ist ein Absturztest ohne neuen Dienst

Lege für einen einzigen Versandauftrag eine Outbox-Zeile in derselben Transaktion wie die Bestellung an. Stoppe den Worker einmal vor und einmal nach dem externen Aufruf und notiere, was beim Neustart geschieht. Kannst du für beide Fälle Verlust, Duplikate und Zuständigkeit benennen, hast du eine belastbare Grundlage für die Toolwahl. Falls nicht, würde ein Broker die unbeantwortete Frage lediglich auf mehr Komponenten verteilen.