Outbox-Muster
Outbox ist ein architektonisches Muster, bei dem ein Domänendienst eine Geschäftsänderung und ein entsprechendes Ereignis in einer einzigen lokalen Transaktion in seinem Repository erfasst. Die Veröffentlichung des Ereignisses an den externen Bus/die Warteschlange erfolgt asynchron durch einen separaten sicheren Prozess (Publisher), der die Tabelle' outbox' liest und die Einträge weiterleitet. Dieser Ansatz eliminiert das Rennen „erst in der DB, dann im Bus“ und sorgt für eine zuverlässige Lieferung auch bei Ausfällen.
1) Wann anzuwenden
Geeignet für:- Microservices und modulare Monolithen mit Ereignissen zwischen Kontexten.
- Es soll sichergestellt werden, dass „der Zustand erfasst wird ↔ das Ereignis nicht verloren gehen kann“.
- Wir brauchen Idempotenz und kontrollierte Nachlieferung.
- Harte globale Transaktionen über mehrere Ressourcen (besser TCC/Saga mit expliziten Verträgen) sind kritisch.
- Es gibt keine dedizierte Quelle der Wahrheit (der Status wird nicht dort gespeichert, wo das Ereignis erzeugt wird).
2) Ziele und Eigenschaften
Atomic write: Domain-Eintrag + Outbox - in einer einzigen Transaktion.
At-least-once publishing: Wir erlauben eine Wiederholung, wir schließen einen Verlust aus.
Verbraucher-Idempotenz: Schutz vor Takes auf der Abonnentenseite.
Effektiv exactly-once: erreicht durch die Kombination outbox + idempotent consumer + dedup.
Klare Telemetrie: Korrelation von Geschäftsvorfällen und Ereignissen.
3) Datenschema (Beispiel)
sql
-- Domain table (example: orders)
CREATE TABLE orders (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
status TEXT NOT NULL,
total_amount NUMERIC(12,2) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
-- Outbox
CREATE TABLE outbox (
id UUID PRIMARY KEY, -- event_id aggregate_type TEXT NOT NULL, -- 'order'
aggregate_id UUID NOT NULL, -- order_id tenant_id TEXT NOT NULL,
type TEXT NOT NULL, -- 'OrderCreated'
payload JSONB NOT NULL, -- serialized headers event JSONB NOT NULL DEFAULT '{}':: jsonb,
occurred_at TIMESTAMP NOT NULL, -- time in domain transaction available_at TIMESTAMP NOT NULL, -- earliest publish time (backoff)
published_at TIMESTAMP, - is filled by the attempts INT NOT NULL DEFAULT 0,
error TEXT
);
CREATE INDEX ON outbox (available_at) WHERE published_at IS NULL;
CREATE INDEX ON outbox (tenant_id, available_at) WHERE published_at IS NULL;
4) Transaktionsmuster (Anwendungsschicht)
pseudo begin tx domainChange () # INSERT/UPDATE in domain table insert into outbox (event) # event with aggregate/tenant commit tx keys
Wenn der Commit erfolgreich ist, ist das Ereignis in der Outbox garantiert vorhanden. Wenn die App nach einem Commit fällt, holt der Pablischer auf.
5) Pablisher (Reader → Publisher)
Aufgaben:- Lesen Sie periodisch nicht veröffentlichte Ereignisse ('published _ at IS NULL' und 'available _ at <= now ()'), Schlachten.
- Versuchen Sie, in den Bus/die Warteschlange zu veröffentlichen; wenn erfolgreich - Markieren Sie' published _ at'.
- Bei einem Fehler - erhöhen Sie' attempts', setzen Sie' available _ at 'für die Zukunft (exponential backoff), schreiben Sie' error'.
- Respektieren Sie Tenant/Key-Limits (Fairness), blockieren Sie nicht produktiv.
pseudo loop:
events = select from outbox where published_at is null and available_at <= now()
order by occurred_at limit BATCH_SIZE for update skip locked
for e in events:
try:
broker. publish(topicFor(e), serialize(e. payload), headers(e))
markPublished(e. id, now())
except Retryable:
backoff = computeBackoff(e. attempts)
reschedule(e. id, now()+backoff, attempts+1, last_error)
except NonRetryable:
moveToDLQ (e) or markError (e) # by sleep (POLL_INTERVAL) policy
6) Idempotenz und Deduplizierung
Auf der Verbraucherseite (Inbox/Idempotency Store):sql
CREATE TABLE inbox (
consumer_name TEXT,
event_id UUID,
processed_at TIMESTAMP NOT NULL,
PRIMARY KEY (consumer_name, event_id)
);
Algorithmus: beim Empfang eines Ereignisses - zuerst Versuch 'INSERT' in 'inbox'; wenn der Schlüsselkonflikt ein Ereignis ist, das bereits → „no-op“ verarbeitet wurde. Als nächstes kommt die Geschäftslogik.
Auf der Seite der Kneipe: 'Idempotency-Key' in den Köpfen (z.B. 'event _ id'), so dass der Bus/Broker/Proxy Duplikate filtern kann.
7) Ordnung und Kausalität
Die lokale Reihenfolge nach 'aggregate _ id' wird durch die Sortierung 'occurred _ at' und die Veröffentlichung 'nach Schlüssel' sichergestellt.
Für Log-Busse mit Partitionierung - Partitionieren Sie mit dem Schlüssel 'aggregate _ id '/' tenant _ id', so dass die Ereignisse eines Aggregats im selben Partitionieren sind.
Wenn die Reihenfolge kritisch ist, vermeiden Sie Zwischenlauf-Kneipenrennen nach einem Schlüssel.
8) CDC (Change Data Capture)
Anstelle eines aktiven Pablishers können Sie CDC verwenden: Die Engine liest das DB-Transaktionslog und übersetzt die Zeilen „outbox“ in den Bus. Vorteile - minimale Belastung der DB, genaue Reihenfolge, kein Polling. Nachteile - Komplikation der Operation und Bindung an die Besonderheiten des DBMS. Beide Ansätze sind gültig; Wählen Sie nach Kompetenzen und SLO.
9) Fehler, DLQ und redrive
Retryable (Netzwerk, Limits) - erhöhen Sie' attempts', verschieben Sie' available _ at'(exponential backoff + jitter).
Non-retryable (nicht-valides Schema/Vertrag) - übertragbar auf DLQ/Dead-Letter Topic mit reichen Metadaten.
Sicheres Redrive: Batches, Rate-Limit, Schema-Validierung, Priorität unter dem Prod-Traffic.
10) Multi-Tenante und Grenzen
Erforderliche Tags: 'tenant _ id', 'plan', 'region' - in 'outbox. headers`.
Pro-Tenant-Fairness: Der Kneipentaler verteilt die „Fenster“ der Publikationen und die Grenzen der Versuche auf die Mieter.
Residency: Speichern Sie die Outbox in derselben Region, in der sich die Domain-Daten befinden. interregionale Veröffentlichung - nur Aggregate/Zusammenfassungen.
11) Sicherheit und Compliance
PII-Redaktion in payload/headers zur Tenant-/Regionalpolitik.
Signatur/Verschlüsselung der Nutzlast, wenn der Bus "fremd' ist.
Prüfung aller Statusübergänge: erstellt, veröffentlicht, Fehler, redrive.
12) Beobachtbarkeit
Metriken:- Veröffentlichungs-Lag ('now - occurred_at' p50/p95/p99).
- Erfolgsquote, Fehlerquote, Verteilung der Ursachen.
- Outbox-Größe (Anzahl der nicht veröffentlichten), Versuche/Sek.
- Per-Tenant-Diagramme durch und lag.
- Korrelation 'event _ id '/' aggregate _ id '/' saga _ id'; spans „db-tx“, „publish“, „retry“.
- Anmerkungen: 'attempt', 'backoff _ ms', 'dlq = true'.
- Kurze Erfolgsrekorde; Vollständige Details pro Fehler/Redrive.
13) Testen und Chaos
Atomicity-Test: Wir „fallen“ künstlich nach dem Commit einer Domain-Transaktion vor der Veröffentlichung - das Ereignis muss später beendet werden.
Duplicate Test: Wir veröffentlichen dasselbe Event mehrmals - der Consumer führt genau einen Effekt aus (inbox).
Bestelltest: Ein Bündel von Ereignissen in einem Aggregat - Überprüfung der Konsistenz/Idempotenz.
Chaos: Maklerverweigerung, zunehmende DB-Latenz, Split-Brain-Pablishers, Clock-Skew.
14) Konfigurationsvorlagen (Beispiel)
yaml outbox:
poll_interval_ms: 200 batch_size: 200 order_by: occurred_at backoff:
strategy: exponential_full_jitter initial_ms: 250 max_ms: 10_000 max_attempts: 20 fairness:
per_tenant_parallelism: 4 per_key_serial: true
publisher:
rate_limit_per_sec: 500 headers:
idempotency_key: event_id schema_version: v3 dlq:
enabled: true topic: myapp. events. dlq include_metadata:
- error
- attempts
- source_table
- tenant_id
- aggregate_id
15) Integration mit Sagas und Retrays
Outbox - „Sicherheitstransport“ für Saga-Schritte: Eine lokale Transaktion schreibt einen Effekt und ein Kommando/Ereignis; Veröffentlichung - zuverlässig und dosierbar.
Wiederholungs- und Backoff-Richtlinien müssen mit „Retry-After“ und Circuit Breaker abgestimmt werden; Vermeiden Sie den „Retrae-Sturm“.
16) Typische Fehler
Schreiben Sie ein Ereignis nach einem Domain-Status-Commit - ein Verlust bei einem Sturz ist möglich.
Keine Indizes/Archiv in 'outbox' → eine Zunahme der Veröffentlichungsverzögerung.
Kneipe ohne' SKIP LOCKED 'oder ohne Sharding - Konkurrenz und Blockaden.
Mangel an Idempotenz bei Verbrauchern - Takes und Nebenwirkungen.
Mischen von PII ohne Maskierung in DLQ/Logs.
Eine einzige globale Publikationswarteschlange ohne Fairness - der „laute“ Tenant bremst alle aus.
Mangelnde Überwachung der Verzögerung → versteckte Degradation.
17) Schnelle Strategieauswahl
Startstufe: Polling aus der DB, Batchi von 100-500, Volljitter-Backoff, Inbox bei den Consumern.
Hohe Belastung: CDC aus dem Transaktionsprotokoll, Sharding durch 'tenant _ id/aggregate _ id', WFQ durch Mieter.
Strikte Reihenfolge nach Aggregat: serielle Veröffentlichung per key (mutex), Partitionierung des Topics mit dem Key.
Compliance/PII: Payload-Verschlüsselung, Überarbeitung im DLQ, regionale Outbox.
18) Checkliste vor dem Verkauf
- Domänenänderungen und der Eintrag in die' outbox' erfolgen in einer Transaktion.
- Pablisher verarbeitet Batches, verwendet 'SKIP LOCKED', Backoff mit Jitter und Limits.
- Consumers idempotent (Tabelle' inbox '/dedup-log).
- DLQ und sicherer Redrive konfiguriert.
- Verzögerungs-/Fehlermetriken und Warnungen nach p95/p99-Schwellenwerten.
- Die Reihenfolge nach Schlüssel ist gewährleistet (Partitur/Serialität).
- Archiv/retention 'outbox' und Bereinigung der veröffentlichten Einträge.
- PII-Richtlinien und Überwachung von Zustandsübergängen.
- Falltests zwischen Commit und Veröffentlichung, Duplikate und Reihenfolge.
- Dokumentation der Veranstaltungsverträge (Diagramme/Versionen/Kompatibilität).
Schlussfolgerung
Das Outbox-Muster verwandelt das „fragile“ Bündel „DB ↔ Bus“ in eine zuverlässige Pipeline: atomare Zustandsfixierung, garantierte (wenn auch „mindestens einmal“) Veröffentlichung, idempotente Abonnenten und kontrollierter Redrive. Mit der richtigen Telemetrie, Limits und Schaltungsdisziplin bietet es ein praktisches exactly-once-Verhalten, das die Komplexität verteilter Transaktionen reduziert und die Widerstandsfähigkeit des Systems gegenüber Ausfällen und Spitzenlasten erhöht.