Zum Inhalt springen
Architektur

Transactional Inbox: wenn das Ack die Nachricht löscht

Ein Ack an Google Cloud Pub/Sub ist kein Verarbeitungsbeleg, sondern ein Löschbefehl. Steht zwischen Empfang und Ack eine Workflow-Engine, die eine Nachricht auch fachlich ablehnen kann, dann entscheidet der Ort dieses einen Aufrufs darüber, ob eine ausbleibende Rückmeldung ein sichtbarer Betriebsvorfall ist oder stiller Datenverlust. Dieser Beitrag ist ein vollständiges Tutorial: er baut den typischen Empfänger, zerlegt seine Fehlermodi und stellt fünf Varianten dagegen, von Nack mit Dead-Letter-Topic bis zur Transactional Inbox mit Relay. Aller Code läuft auf Spring Boot mit Spring Cloud GCP und Flowable.

Inhalt

Die Szene: eine Rückmeldung, die nie ankommt

Ein Auftragssystem übergibt Aufträge an einen ausführenden Dienstleister. Ein Auftrag umfasst mehrere tausend Positionen. Den Ablauf steuert ein BPMN-Prozess auftrag-ausfuehrung: er übergibt den Auftrag an den Dienstleister, wartet dann an einem Intermediate Message Catch Event auf dessen Abschlussmeldung „Auftrag abgeschlossen“, und erst danach gilt der Auftrag als erledigt und wird abgerechnet.

┌───────────────────┐   ┌──────────────────────┐   ┌─────────────────────┐
│ Auftrag übergeben │───│ Warten auf Abschluss │───│ Rechnung stellen    │
└───────────────────┘   └──────────────────────┘   └─────────────────────┘
      Send Task           Message Catch Event        Service Task, async

Die Abschlussmeldung kommt über Google Cloud Pub/Sub. Der ausführende Dienstleister publiziert auf ein Topic, ein Spring-Boot-Service namens auftrag-service hält die Subscription und korreliert jede Nachricht über den Correlation Key auftragId in die laufende Prozessinstanz.

Bleibt die Abschlussmeldung aus, gibt es keinen Fehler, keinen roten Alarm, keinen Stacktrace. Der Prozess steht einfach am Catch Event und wartet, der Auftrag bleibt offen, und abgerechnet wird nichts. Aufgefallen ist so ein Stillstand erfahrungsgemäß erst, wenn jemand nach dem Verbleib eines Auftrags fragt, und dann geht es nicht um eine Position, sondern um mehrere tausend.

Die Frage dieses Tutorials lautet deshalb: wie baust du den Weg von der Pub/Sub-Nachricht in die Prozessinstanz so, dass eine Störung auf diesem Weg sichtbar bleibt und keine Nachricht endgültig verliert?

Der Empfänger, der harmlos aussieht

So sieht der Empfänger aus, den vermutlich jeder schon einmal geschrieben hat. Er nutzt PubSubTemplate.subscribe aus Spring Cloud GCP (Starter com.google.cloud:spring-cloud-gcp-starter-pubsub, Version 8.1.0 für Spring Boot 4.0 und 4.1, daneben 7.4.10 für Spring Boot 3.5), liest die Nachricht, korreliert sie in die Engine und ackt.

@Component
public class AuftragAbgeschlossenReceiver {

    private static final Logger log =
            LoggerFactory.getLogger(AuftragAbgeschlossenReceiver.class);

    private final PubSubTemplate pubSubTemplate;
    private final AuftragProzessService prozessService;
    private final ObjectMapper objectMapper;

    public AuftragAbgeschlossenReceiver(PubSubTemplate pubSubTemplate,
                                        AuftragProzessService prozessService,
                                        ObjectMapper objectMapper) {
        this.pubSubTemplate = pubSubTemplate;
        this.prozessService = prozessService;
        this.objectMapper = objectMapper;
    }

    @PostConstruct
    void starteSubscription() {
        pubSubTemplate.subscribe("auftrag-abgeschlossen-sub", this::verarbeite);
    }

    private void verarbeite(BasicAcknowledgeablePubsubMessage message) {
        try {
            AuftragAbgeschlossen ereignis = objectMapper.readValue(
                    message.getPubsubMessage().getData().toByteArray(),
                    AuftragAbgeschlossen.class);

            prozessService.auftragAbgeschlossen(ereignis.auftragId(), Map.of(
                    "erledigtePositionen", ereignis.erledigtePositionen(),
                    "abschlussZeitpunkt", ereignis.abschlussZeitpunkt().toString()));

            message.ack();
        } catch (Exception e) {
            log.error("Rückmeldung nicht verarbeitbar", e);
            message.ack();   // damit die Subscription nicht verstopft
        }
    }
}
public record AuftragAbgeschlossen(String auftragId,
                                   int erledigtePositionen,
                                   Instant abschlussZeitpunkt) {
}

Der Code kompiliert, läuft, besteht jeden Demo-Test und übersteht Monate im Betrieb. Er hat trotzdem drei stille Schwächen, und jede bekommt in diesem Artikel ihr eigenes Kapitel:

  1. Die Workflow-Engine steht im Ack-Pfad. Jede Störung der Engine, auch ein gewöhnliches Deployment, verhindert das Ack und macht aus dem Empfänger einen Engpass.
  2. Alle Fehler landen im selben catch. Ein abgerissener Datenbank-Connect und eine fachliche Ablehnung der Korrelation werden identisch behandelt, obwohl sie gegensätzliche Reaktionen bräuchten.
  3. Das Ack steht im catch-Block. Der Kommentar daneben klingt vernünftig, tatsächlich ist diese Zeile ein Löschbefehl für jede Nachricht, die einen Fehler ausgelöst hat.

Alle drei hängen daran, was ein Ack bei Pub/Sub tatsächlich bedeutet.

Was ein Ack wirklich zusagt

Ein Ack an Pub/Sub sagt genau eines zu: diese Nachricht muss nicht mehr zugestellt werden. Sobald für jede Subscription mindestens ein Subscriber die Nachricht bestätigt hat, löscht Pub/Sub sie aus dem Speicher. Ob dein Code die Nachricht vorher erfolgreich verarbeitet hat, weiß Pub/Sub nicht und prüft es nicht.

Drei Uhren bestimmen das Verhalten:

Die Ack-Deadline entscheidet über die Wiederzustellung. Bestätigst du eine Nachricht nicht innerhalb der Deadline, stellt Pub/Sub sie erneut zu. Der Default liegt bei 10 Sekunden, konfigurierbar zwischen 10 und 600 Sekunden. Die Client-Library verlängert die Deadline laufend im Hintergrund, solange die Nachricht bei dir in Arbeit ist, begrenzt durch die maxAckExtensionPeriod des Clients.

Die Message Retention der Subscription entscheidet über das endgültige Verfallsdatum. Default sind 7 Tage, konfigurierbar zwischen 10 Minuten und 31 Tagen. Nach Ablauf darf Pub/Sub die Nachricht verwerfen, und zwar unabhängig davon, ob sie bestätigt wurde oder nicht. Wer eine Nachricht 31 Tage lang nicht verarbeiten konnte, verliert sie auch ohne jedes Ack.

Und die dritte Uhr existiert gar nicht: eine bestätigte Nachricht hat kein Zurück. Ohne die Option „Retain acknowledged messages“ auf der Subscription oder eine konfigurierte Message Retention auf dem Topic lässt sich eine geackte Nachricht nicht per Seek zurückholen. Das Ack im catch-Block aus dem Empfänger oben ist deshalb endgültig.

Die Zustellgarantie dazu: at-least-once ist der Default für alle Subscription-Typen. Jede Nachricht kann mehrfach ankommen, auch ohne dass irgendwer einen Fehler gemacht hat, etwa weil ein Ack auf dem Netzweg verloren ging. Ein Empfänger, der Duplikate nicht verträgt, ist auf Pub/Sub falsch gebaut. Was es mit exactly-once delivery auf sich hat, klärt die FAQ, so viel vorweg: es löst das Problem dieses Artikels nicht.

Warum die Korrelation ablehnt

Auf der Engine-Seite wartet der Prozess an einem Message Catch Event. Im BPMN sieht das so aus:

<message id="auftragAbgeschlossenMessage" name="AuftragAbgeschlossen" />

<process id="auftrag-ausfuehrung" name="Auftragsausführung">

    <sendTask id="auftragUebergeben" name="Auftrag übergeben"
              flowable:async="true" />
    <sequenceFlow sourceRef="auftragUebergeben" targetRef="warteAufAbschluss" />

    <intermediateCatchEvent id="warteAufAbschluss" name="Warten auf Abschluss">
        <messageEventDefinition messageRef="auftragAbgeschlossenMessage" />
    </intermediateCatchEvent>
    <sequenceFlow sourceRef="warteAufAbschluss" targetRef="rechnungStellen" />

    <serviceTask id="rechnungStellen" name="Rechnung stellen"
                 flowable:async="true" />
</process>

Drei Elemente, und die mittlere Stelle ist die verwundbare. Der Send Task übergibt den Auftrag, das Catch Event wartet, der Service Task stellt die Rechnung. Solange kein Token am Catch Event steht, existiert für die Abschlussmeldung kein Empfänger.

Flowable bietet im Core-API keinen Ein-Schritt-Aufruf, der eine Nachricht anhand eines Correlation Keys selbst zustellt. Der Aufrufer baut die Query, wertet die Treffer aus, stellt zu und behandelt die Fehler. Solche Abfragen heißen correlation queries, und wer eine schreibt, trifft damit eine Annahme über das Prozessmodell: hier die, dass je auftragId höchstens eine Instanz wartet. Die Korrelationslogik liegt in deinem Receiver, nicht in der Engine.

@Service
public class AuftragProzessService {

    private final RuntimeService runtimeService;

    public AuftragProzessService(RuntimeService runtimeService) {
        this.runtimeService = runtimeService;
    }

    public void auftragAbgeschlossen(String auftragId, Map<String, Object> variablen) {
        List<Execution> wartende = runtimeService.createExecutionQuery()
                .processDefinitionKey("auftrag-ausfuehrung")
                .messageEventSubscriptionName("AuftragAbgeschlossen")
                .variableValueEquals("auftragId", auftragId)
                .list();

        if (wartende.isEmpty()) {
            throw new KeineWartendeInstanzException(auftragId);
        }
        if (wartende.size() > 1) {
            throw new MehrdeutigeKorrelationException(auftragId, wartende.size());
        }

        runtimeService.messageEventReceived(
                "AuftragAbgeschlossen", wartende.get(0).getId(), variablen);
    }
}

Dahinter steht eine Regel, die im BPMN nirgends fett gedruckt ist und trotzdem alles bestimmt: Zu einer Nachricht darf es genau eine angemeldete Wartestelle geben. Eine Message adressiert einen Empfänger, und die Engine muss sie genau einer Execution zustellen. Gibt es keine, hat sie niemanden. Gibt es zwei, hat sie keine richtige Wahl, und dann trifft sie lieber gar keine.

Dieser Aufruf kann auf vier Arten ablehnen, und keine davon ist ein Bug in der Engine:

  1. Kein Treffer, weil die Instanz das Catch Event noch nicht erreicht hat. Die Event-Subscription, gegen die korreliert wird, ist eine Zeile in ACT_RU_EVENT_SUBSCR, und sie entsteht erst, wenn der Token das Catch Event erreicht. Flowable kennt für BPMN-Messages keinen Puffer-Mechanismus, korreliert wird ausschließlich gegen bestehende Event-Subscriptions. Überholt die Abschlussmeldung des Dienstleisters den eigenen Prozess, etwa weil der Übergabe-Schritt noch läuft, findet die Query nichts.
  2. Kein Treffer, weil die Instanz schon durch ist. Die Nachricht kam doppelt, oder jemand hat den Prozess von Hand weitergeschoben. Von außen ist dieser Fall vom ersten nicht zu unterscheiden, die Query liefert in beiden Fällen eine leere Liste.
  3. Mehr als ein Treffer. Warten zwei Executions mit derselben auftragId auf dieselbe Message, liefert list() beide, und der Aufrufer muss entscheiden, was das bedeutet. Wer stattdessen singleResult() nimmt, bekommt die Entscheidung als FlowableException mit dem Wortlaut Query return 2 results instead of max 1 zurück, verliert dabei aber die Information, wie viele es waren. Wie es überhaupt zu zwei wartenden Executions kommt, zeigt das Kapitel über die Reparatur.
  4. Die Execution existiert, hat die Subscription aber nicht mehr. Zwischen Query und Zustellung liegt ein Zeitfenster, und wenn die Instanz genau darin weiterläuft, wirft messageEventReceived laut Javadoc eine FlowableObjectNotFoundException, wenn die Execution fehlt, oder eine FlowableException, wenn sie die Message nicht abonniert hat.

Alle vier Ablehnungen sind Aussagen über den Zustand des Prozesses, nicht über die Infrastruktur. Nur die erste heilt von selbst, wenn man ihr Zeit gibt. Die anderen drei bleiben bestehen, egal wie oft man denselben Aufruf wiederholt. Diese Unterscheidung kommt bei jeder Variante wieder.

Der erste Fehler: Retry gegen eine fachliche Ablehnung

Der erste Reflex gegen die Ablehnung ist ein Retry im Empfänger: wenn die Korrelation fehlschlägt, kurz warten und noch einmal versuchen.

private void verarbeite(BasicAcknowledgeablePubsubMessage message) throws Exception {
    AuftragAbgeschlossen ereignis = lese(message);

    for (int versuch = 1; versuch <= 30; versuch++) {
        try {
            prozessService.auftragAbgeschlossen(ereignis.auftragId(), variablen(ereignis));
            message.ack();
            return;
        } catch (KeineWartendeInstanzException e) {
            Thread.sleep(10_000);   // die Instanz kommt sicher gleich am Catch Event an
        }
    }
}

Für Ablehnung Nummer eins, die Instanz ist noch nicht so weit, funktioniert das sogar: Zeit heilt diesen Fall. Der Ort des Wartens ist trotzdem falsch, aus zwei Gründen.

Der erste ist Head-of-Line-Blocking. Der Callback belegt einen Verarbeitungs-Thread, und die Flow Control der Client-Library begrenzt, wie viele Nachrichten gleichzeitig ausstehen dürfen. Hängt der Thread fünf Minuten in der Schleife für Auftrag 4711, warten die Rückmeldungen aller anderen Aufträge hinter ihm. Head-of-Line-Blocking ist hier eine Eigenschaft der Consumer-Konfiguration, nicht des Brokers. Pub/Sub hätte die anderen Nachrichten längst zugestellt, dein Empfänger nimmt sie nur nicht an.

Der zweite Grund ist die Lease. Die Ack-Deadline liegt bei maximal 600 Sekunden, alles darüber hält allein die Client-Library, indem sie die Deadline im Hintergrund verlängert, bis ihre maxAckExtensionPeriod erreicht ist. Ein Retry, der sich über hundert Minuten gegen einen Fehler stemmt, arbeitet am Ende mit einer Nachricht, deren Lease längst gefallen sein kann. Pub/Sub hat sie dann bereits erneut zugestellt, womöglich an eine andere Instanz des auftrag-service, und ob das eigene, späte Ack noch etwas bewirkt, weiß niemand mehr.

Gegen die Ablehnungen zwei bis vier richtet der Retry ohnehin nichts aus. Eine Prozessinstanz, die schon durch ist, kommt durch Wiederholen nicht zurück, und ein doppelter Token verschwindet nicht davon. Der Retry verwandelt eine fachliche Ablehnung in eine Endlosschleife mit Wartezeit.

Der zweite Fehler: das Ack im catch-Block

Nach dem ersten Retry-Sturm, der die Subscription verstopft hat, kommt verlässlich die zweite Reparatur: im Fehlerfall wird geackt, damit es weitergeht. Genau so entstand der catch-Block im Empfänger vom Anfang.

Jetzt sind die zwei Defekte zu trennen. Wer bei einer Exception nackt, hat ein Verfügbarkeits- und Kopplungsproblem: die Subscription staut sich, solange die Engine nicht erreichbar ist. Aber die Nachricht existiert noch, und wenn die Störung vorbei ist, kommt sie wieder. Wer bei einer Exception ackt, macht aus derselben Störung Datenverlust: Pub/Sub löscht die Nachricht, ein Zurück gibt es ohne „Retain acknowledged messages“ oder Topic-Retention nicht, und der Prozess des Auftrags wartet für immer.

Das Tückische daran: dieses Antipattern braucht keinen handgeschriebenen catch-Block. Es entsteht auch rein aus Konfiguration. Wer statt PubSubTemplate.subscribe den Spring-Integration-Weg über den PubSubInboundChannelAdapter mit @ServiceActivator geht, wählt einen AckMode, und dessen Varianten verhalten sich im Fehlerfall grundverschieden:

AckMode bei Erfolg bei Exception
AUTO Ack ohne Error-Handler Nack mit sofortiger erneuter Zustellung, mit erfolgreichem Error-Handler Ack
AUTO_ACK Ack ohne Error-Handler keine Aktion, die Deadline läuft ab; mit Error-Handler wird geackt
MANUAL nichts nichts, dein Code ackt oder nackt selbst

Die Zeile, die den Datenverlust scharf schaltet, sieht dabei völlig harmlos aus:

@Bean
public PubSubInboundChannelAdapter auftragAdapter(PubSubTemplate pubSubTemplate,
                                                  MessageChannel auftragKanal) {
    var adapter = new PubSubInboundChannelAdapter(pubSubTemplate, "auftrag-abgeschlossen-sub");
    adapter.setOutputChannel(auftragKanal);
    adapter.setAckMode(AckMode.AUTO_ACK);
    adapter.setErrorChannelName("pubsubErrors");   // ab hier wird im Fehlerfall geackt
    return adapter;
}

Mit AUTO_ACK und einem Error-Handler gilt die Nachricht als erledigt, sobald der Handler sie gesehen hat, auch wenn er nichts weiter tut als loggen. Wer volle Kontrolle will, nimmt MANUAL und holt sich die BasicAcknowledgeablePubsubMessage aus dem Header GcpPubSubHeaders.ORIGINAL_MESSAGE. Damit steht die Empfehlung fest, bevor eine einzige Zeile Inbox-Code geschrieben ist: ein Ack gehört ausschließlich hinter den Punkt, an dem die Nachricht dauerhaft in Sicherheit ist.

Warum die Reparatur den nächsten Ausfall baut

Bleibt die Frage, woher Ablehnung Nummer drei kommt, der doppelte Token. Er entsteht nicht durch einen Bug, sondern durch eine Reparatur von Hand, und zwischen Ursache und Wirkung können Monate liegen.

Die Vorgeschichte: eine Rückmeldung ist verloren gegangen, sagen wir durch das Ack im catch-Block. Der Auftrag steht. Jemand aus dem Betrieb greift ein und stellt der Instanz per Change-State-Operation einen Token an den Übergabe-Schritt, damit der Auftrag erneut übergeben wird.

Der Übergabe-Schritt ist mit flowable:async="true" asynchron modelliert, wie es sich für einen externen Aufruf gehört. Und genau das macht den Eingriff trügerisch: die Operation legt nur einen Job in ACT_RU_JOB an, ausgeführt wird er erst, wenn der Job Executor ihn aufnimmt. Direkt nach dem Eingriff sieht deshalb alles unverändert aus. Kein neuer Aufruf in den Logs, kein sichtbarer Fortschritt im Prozessbild. Der naheliegende Schluss lautet: der Eingriff hat nicht funktioniert, also noch einmal. Jeder dieser Anläufe stellt einen weiteren Token daneben.

Im Moment seiner Entstehung verursacht der Zusatz-Token kein einziges Symptom. Beide Token durchlaufen die Übergabe, beide erreichen das Catch Event, beide legen eine Event-Subscription mit derselben auftragId an, und der Prozess sieht im Monitor aus wie immer. Erst wenn die nächste Rückmeldung für diesen Auftrag eintrifft, schlägt singleResult() mit Query return 2 results instead of max 1 fehl. Läuft dann noch der Empfänger mit dem Ack im catch, wird diese fachliche Ablehnung geloggt, die Nachricht gelöscht, und der Auftrag steht wieder. Die Reparatur des letzten Ausfalls hat den nächsten gebaut.

Die Lehre daraus ist unbequem: ein Korrelationsfehler kann durch einen Eingriff entstanden sein, der Monate zurückliegt. Ein System, das solche Nachrichten wegwirft, statt sie aufzubewahren, nimmt sich jede Chance auf Diagnose und Reparatur.

Variante 1: Nack, Retry-Policy und Dead-Letter-Topic

Die erste tragfähige Variante bleibt komplett in Pub/Sub und braucht keine neue Infrastruktur: bei Fehlern wird genackt, die Wiederholung übernimmt eine Retry-Policy, und was dauerhaft scheitert, wandert in ein Dead-Letter-Topic.

Der Empfänger wird dadurch kürzer, nicht länger:

private void verarbeite(BasicAcknowledgeablePubsubMessage message) {
    try {
        AuftragAbgeschlossen ereignis = lese(message);
        prozessService.auftragAbgeschlossen(ereignis.auftragId(), variablen(ereignis));
        message.ack();
    } catch (Exception e) {
        log.warn("Rückmeldung zurückgestellt: {}", e.getMessage());
        message.nack();
    }
}

Zwei Konfigurationsdetails entscheiden darüber, ob das gut ausgeht, und beide werden regelmäßig übersehen.

Erstens die Retry-Policy. Ohne konfigurierte Policy ist der Default von Pub/Sub die sofortige erneute Zustellung, ohne jeden Backoff. Ein Nack auf eine fachliche Ablehnung erzeugt dann eine dichte Schleife aus Zustellung, Ablehnung, Zustellung. Mit Policy gilt exponentieller Backoff, minimumBackoff default 10 Sekunden, maximumBackoff default 600 Sekunden, beide einstellbar zwischen 0 und 600 Sekunden. Die Policy greift pro Nachricht, und zwar sowohl bei Nack als auch bei Ablauf der Ack-Deadline.

Zweitens das Dead-Letter-Topic. Es wird auf der Subscription konfiguriert, mit maxDeliveryAttempts zwischen 5 und 100, default 5:

gcloud pubsub topics create auftrag-abgeschlossen-dlt

gcloud pubsub subscriptions update auftrag-abgeschlossen-sub 
    --dead-letter-topic=auftrag-abgeschlossen-dlt 
    --max-delivery-attempts=10 
    --min-retry-delay=10s 
    --max-retry-delay=600s

Dazu gehören zwei IAM-Bindungen, ohne die schlicht keine Weiterleitung stattfindet: der Pub/Sub-Service-Account service-<projektnummer>@gcp-sa-pubsub.iam.gserviceaccount.com braucht roles/pubsub.publisher auf dem Dead-Letter-Topic und roles/pubsub.subscriber auf der Quell-Subscription.

gcloud pubsub topics add-iam-policy-binding auftrag-abgeschlossen-dlt 
    --member="serviceAccount:service-123456789@gcp-sa-pubsub.iam.gserviceaccount.com" 
    --role="roles/pubsub.publisher"

gcloud pubsub subscriptions add-iam-policy-binding auftrag-abgeschlossen-sub 
    --member="serviceAccount:service-123456789@gcp-sa-pubsub.iam.gserviceaccount.com" 
    --role="roles/pubsub.subscriber"

Zur Ehrlichkeit gehören die Grenzen der Zusage: die Weiterleitung ins Dead-Letter-Topic ist best-effort, es können weniger oder mehr Zustellversuche stattfinden als konfiguriert. Die weitergeleitete Nachricht wird gewrappt und trägt CloudPubSubDeadLetterSource-Attribute, unter anderem Quell-Subscription und Zustellzähler. Das Feld delivery_attempt sehen dagegen die Subscriber der Quell-Subscription bei jeder Zustellung. Der Zähler dahinter wird nur geführt, wenn das Dead-Letter-Topic korrekt konfiguriert ist, und er kann auf 0 zurückfallen, besonders bei Pull-Subscriptions mit zeitweise inaktiven Subscribern.

Viele Inbox-Artikel unterschlagen diesen Punkt: für einen Consumer ohne eigene Datenbank, mit mäßigem Volumen und ohne Anforderungen an die Reihenfolge ist diese Variante in vielen Fällen die bessere Lösung. Kein neuer Code-Pfad, keine Tabelle, kein Relay, und die Überwachung gibt es frei Haus, etwa über die Metrik oldest_unacked_message_age und die Belegung des Dead-Letter-Topics. Was ihr fehlt, zeigt sich erst an vier konkreten Anforderungen.

Variante 2: die Transactional Inbox

Die Transactional Inbox, das empfangsseitige Gegenstueck zur Transactional Outbox von Chris Richardson, trennt zwei Dinge, die der Empfänger vom Anfang vermischt hat: den Empfang der Nachricht und ihre Verarbeitung. Der Empfänger schreibt die Nachricht nur noch in eine eigene Tabelle und ackt. Die Korrelation in die Engine übernimmt später ein separater Relay.

Die Tabelle:

CREATE TABLE auftrag_inbox (
    id                BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    ereignis_typ      VARCHAR(64)  NOT NULL,
    auftrag_id         VARCHAR(36)  NOT NULL,
    payload           JSONB        NOT NULL,
    status            VARCHAR(16)  NOT NULL DEFAULT 'OFFEN',
    versuche          INT          NOT NULL DEFAULT 0,
    naechster_versuch TIMESTAMPTZ  NOT NULL DEFAULT now(),
    letzter_fehler    TEXT,
    empfangen_am      TIMESTAMPTZ  NOT NULL DEFAULT now(),
    verarbeitet_am    TIMESTAMPTZ,
    CONSTRAINT uq_auftrag_inbox UNIQUE (ereignis_typ, auftrag_id)
);

Der Empfänger schrumpft auf Empfangen, Ablegen, Bestätigen:

private void verarbeite(BasicAcknowledgeablePubsubMessage message) {
    try {
        AuftragAbgeschlossen ereignis = lese(message);
        inbox.legeAb("AuftragAbgeschlossen", ereignis.auftragId(), roheDaten(message));
        message.ack();
    } catch (Exception e) {
        log.warn("Rückmeldung nicht ablegbar: {}", e.getMessage());
        message.nack();
    }
}
@Repository
public class AuftragInbox {

    private final JdbcTemplate jdbc;

    public AuftragInbox(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public boolean legeAb(String ereignisTyp, String auftragId, String payload) {
        int eingefuegt = jdbc.update("""
                INSERT INTO auftrag_inbox (ereignis_typ, auftrag_id, payload)
                VALUES (?, ?, ?::jsonb)
                ON CONFLICT ON CONSTRAINT uq_auftrag_inbox DO NOTHING
                """, ereignisTyp, auftragId, payload);
        return eingefuegt == 1;
    }
}

In diesem kleinen Code stecken drei Entscheidungen.

Zwischen Insert und Ack bleibt ein Crash-Fenster. Stürzt der Pod genau zwischen den beiden Zeilen ab, ist die Nachricht in der Tabelle, aber nicht bestätigt, und Pub/Sub stellt sie erneut zu. Der Insert muss diese Wiederholung schadlos überstehen, deshalb ist er über den Unique Key idempotent: der zweite Insert derselben Nachricht trifft auf den Constraint, tut nichts, und das Ack geht trotzdem raus. Genau das ist der Idempotent Consumer von microservices.io, in eine Tabelle gegossen.

Die Wahl des Unique Keys ist dabei nicht trivial. (ereignis_typ, auftrag_id) funktioniert nur, solange der Dienstleister pro Auftrag höchstens eine Abschlussmeldung schickt. Tragen zwei fachlich verschiedene Nachrichten denselben Key, etwa weil ein Auftrag in zwei Tranchen abgeschlossen wird, verschluckt der Unique Key die zweite kommentarlos, ohne Fehler und ohne Logeintrag. Dann muss eine fachliche Ereignis-ID des Absenders in den Key, und wenn der Absender keine liefert, ist das ein Gespräch mit dem Absender, kein Implementierungsdetail.

Und ja: die Datenbank steht jetzt selbst im Ack-Pfad. Das ist kein Widerspruch zur Kritik am Empfänger vom Anfang, sondern deren Auflösung. Vor dem Ack darf stehen, was ausschließlich technisch scheitern kann, denn einen technischen Fehler heilt die erneute Zustellung durch Pub/Sub. Ein INSERT in eine eigene Tabelle kann nicht fachlich ablehnen, die Korrelation in die Engine kann es. Deshalb gehört der Insert vor das Ack und die Korrelation dahinter.

Der Relay, und die Falle mit der Reihenfolge

Der zweite Baustein ist der Relay: ein Poller, der offene Inbox-Einträge liest und in die Engine korreliert. Die naheliegende Form sieht so aus:

@Component
public class AuftragInboxRelay {

    private final JdbcTemplate jdbc;
    private final AuftragProzessService prozessService;

    public AuftragInboxRelay(JdbcTemplate jdbc, AuftragProzessService prozessService) {
        this.jdbc = jdbc;
        this.prozessService = prozessService;
    }

    @Scheduled(fixedDelay = 2000)
    @Transactional
    public void verarbeiteOffeneEintraege() {
        List<AuftragInboxEintrag> eintraege = jdbc.query("""
                SELECT * FROM auftrag_inbox
                WHERE status = 'OFFEN' AND naechster_versuch <= now()
                ORDER BY id
                LIMIT 10
                FOR UPDATE SKIP LOCKED
                """, eintragMapper());

        for (AuftragInboxEintrag eintrag : eintraege) {
            verarbeite(eintrag);
        }
    }
}

FOR UPDATE SKIP LOCKED macht den Relay horizontal skalierbar: mehrere Pods des auftrag-service pollen gleichzeitig, jeder sperrt seine Zeilen, keiner wartet auf den anderen. Das ist das Competing-Consumers-Muster aus Hohpe und Woolf, auf eine Tabelle übertragen.

Und genau darin steckt die Falle. Sobald die Inbox mehr als einen Ereignistyp pro Auftrag transportiert, etwa „Ausführung gestartet“ für den Statusverlauf und „Auftrag abgeschlossen“ für die Korrelation, können zwei Pods die beiden Nachrichten desselben Auftrags gleichzeitig ziehen. Dann wird womöglich „abgeschlossen“ korreliert, bevor „gestartet“ verarbeitet ist, der Prozess ist am zweiten Ereignis schon vorbei, und die Korrelation von „gestartet“ lehnt ab. Das ist exakt das Race aus Ablehnung Nummer eins, nur eine Etage tiefer nachgebaut, in der eigenen Infrastruktur statt im Broker.

Die Lösung heißt: pro Correlation Key serialisieren, über die Keys hinweg parallel bleiben. In SQL lässt sich das direkt ausdrücken: ein Eintrag darf nur gezogen werden, wenn für denselben Auftrag kein älterer unerledigter Eintrag existiert.

SELECT i.*
FROM auftrag_inbox i
WHERE i.status = 'OFFEN'
  AND i.naechster_versuch <= now()
  AND NOT EXISTS (
      SELECT 1
      FROM auftrag_inbox aelter
      WHERE aelter.auftrag_id = i.auftrag_id
        AND aelter.status IN ('OFFEN', 'IN_ARBEIT')
        AND aelter.id < i.id)
ORDER BY i.id
LIMIT 10
FOR UPDATE SKIP LOCKED;

Hat Pod A die ältere Zeile eines Auftrags gesperrt, sieht Pod B sie weiterhin als unerledigt und lässt die jüngere liegen. Im Grenzfall wartet ein Eintrag dadurch einen Poll-Zyklus länger als nötig, das ist die sichere Richtung. Wer sich das Konstrukt sparen will, hat eine einfachere Option mit einem klaren Preis: genau eine Relay-Instanz, striktes ORDER BY id, und der Durchsatz ist auf diesen einen Worker begrenzt. Für eine Handvoll Aufträge pro Stunde ist das völlig in Ordnung.

Technischer oder fachlicher Fehler?

Im Relay fällt jetzt die Entscheidung, die der ursprüngliche Empfänger nie getroffen hat: was bedeutet ein Fehler, und was folgt daraus? Die gängige Klassifikation aus verteilten Systemen trennt transiente Fehler, die mit exponentiellem Backoff wiederholt werden, von permanenten, die sofort auf die Endstation gehen. Für die Korrelation heißt das konkret:

private void verarbeite(AuftragInboxEintrag eintrag) {
    try {
        prozessService.auftragAbgeschlossen(eintrag.auftragId(), eintrag.variablen());
        markiereErledigt(eintrag);
    } catch (KeineWartendeInstanzException e) {
        // heilt womöglich mit der Zeit: Backoff, aber mit Frist
        if (eintrag.versuche() >= maxVersuche) {
            parke(eintrag, e);
        } else {
            verschiebeMitBackoff(eintrag, e);
        }
    } catch (MehrdeutigeKorrelationException e) {
        // zwei Wartestellen: kein Retry der Welt hilft
        parke(eintrag, e);
    } catch (FlowableException e) {
        // Subscription zwischen Query und Zustellung verschwunden
        parke(eintrag, e);
    } catch (Exception e) {
        // technisch: Datenbank, Engine nicht erreichbar, Timeout
        verschiebeMitBackoff(eintrag, e);
    }
}

Die Zuordnung folgt aus dem Kapitel über die vier Ablehnungen. Kein Treffer kann heißen, die Instanz ist noch unterwegs, also lohnt Geduld, aber nicht unbegrenzt: nach Ablauf einer Frist ist aus „noch nicht da“ ein „war nie da oder ist schon durch“ geworden, und der Eintrag wird geparkt. Ein Mehrfach-Treffer und eine verschwundene Subscription sind sofort ein Fall für Menschen, jeder automatische Wiederholungsversuch würde nur dieselbe Ablehnung produzieren. Und alles Technische bekommt Backoff, weil die erneute Ausführung dort tatsächlich heilt.

Eine Unschärfe bleibt, und die sollte man kennen: FlowableException ist keine rein fachliche Ausnahme. Die Engine verpackt darin auch technische Fehler, etwa wenn die Datenbank unter ihr wegbricht. Wer den Fall sauber trennen will, kommt nicht darum herum, die Ursache zu prüfen, statt sich auf den Ausnahmetyp zu verlassen. Die beiden Ausnahmen aus dem eigenen Service, KeineWartendeInstanzException und MehrdeutigeKorrelationException, sind genau deshalb eigene Typen und keine durchgereichten Engine-Fehler.

Geparkt heißt: status = 'GEPARKT', der letzte Fehler steht in der Zeile, und ein Alarm feuert. Zwei Alarme braucht der Betrieb mindestens, einen auf geparkte Einträge und einen auf das Alter des ältesten offenen Eintrags. Beides sind einfache SQL-Abfragen, und dass man sie selbst bauen und selbst betreiben muss, ist kein Detail, sondern ein echter Kostenpunkt dieser Architektur. Das Kapitel über die Grenzen der Inbox kommt darauf zurück.

Variante 3: die Engine konsumiert selbst

Wenn die Korrelationslogik ohnehin eng an der Engine hängt, liegt ein Gedanke nahe: warum konsumiert nicht die Engine selbst den Broker? Manche Engines bringen dafür eine Event-Anbindung mit, bei der eine Broker-Subscription deklarativ ans Prozessmodell gebunden wird und der handgeschriebene Receiver komplett entfällt.

Der Reiz ist real: eine Schicht weniger, keine eigene Korrelationslogik, das Mapping von Nachricht zu Correlation Key steht im Modell statt im Java-Code. Für einfache Fälle ist das die schlankste Lösung.

Drei Dinge sollte man dabei nüchtern sehen. Erstens verschwindet die Korrelationsablehnung nicht, sie zieht nur um: auch ein Engine-Adapter trifft auf Instanzen, die noch nicht oder nicht mehr warten, und wie er dann mit Ack, Retry und Endstation umgeht, bestimmt jetzt die Adapter-Implementierung statt deines Codes. Die Fragen aus diesem Artikel muss man an den Adapter genauso stellen, nur beantwortet sie dort jemand anderes. Zweitens rückt der Broker-Consumer in den Lebenszyklus der Engine: ein Neustart der Engine ist jetzt automatisch auch ein Neustart des Consumers, die Kopplung, die wir aus dem Ack-Pfad entfernen wollten, kehrt in anderer Form zurück. Und drittens ist die Anbindung eine Ausstattungsfrage: welcher Broker unterstützt wird, hängt von Engine und Version ab, und für Google Cloud Pub/Sub läuft es je nach Stack auf einen selbst gepflegten Adapter hinaus, womit der vermeintlich eingesparte Code wieder da ist.

Als Faustregel taugt: diese Variante spielt ihre Stärke aus, wenn der Adapter für deinen Broker existiert, seine Fehlerbehandlung dokumentiert und akzeptabel ist, und du keine eigenen Anforderungen an Deduplizierung oder Reparatur hast. Sonst bleibt der explizite Weg über Receiver oder Inbox die kontrollierbarere Wahl.

Variante 4: eine Engine, die Nachrichten puffert

Die vier Ablehnungen aus dem Korrelations-Kapitel haben eine gemeinsame Wurzel: Flowable korreliert ausschließlich gegen Event-Subscriptions, die im Moment des Aufrufs existieren. Es gibt Engines, die an dieser Stelle anders gebaut sind: publizierte Nachrichten werden im Broker der Engine gepuffert, mit einer Time-to-live, deren Parameter timeToLive heißt und in Millisekunden angegeben wird. Steht die TTL auf 0, ist die Pufferung aus. Solange die TTL läuft, korreliert eine gepufferte Nachricht auch dann noch, wenn die passende Subscription erst nach dem Publizieren entsteht. In einer verbreiteten Implementierung setzt der Client per Default eine Stunde, dazu gibt es eine optionale messageId für Idempotenz.

Auf unser Szenario übertragen verschwindet damit Ablehnung Nummer eins vollständig: die Rückmeldung darf den Prozess überholen, sie wartet einfach in der Engine, bis der Token am Catch Event ankommt. Das Race zwischen Übergabe und Rückmeldung, gegen das der Retry im Empfänger und die Frist im Relay gebaut wurden, existiert in so einem Modell schlicht nicht.

Ehrlich bleiben muss man bei den übrigen Ablehnungen. Eine Instanz, die schon durch ist, holt auch ein Puffer nicht zurück, sobald die TTL abgelaufen ist. Ein doppelter Token bleibt ein doppelter Token. Wer den separaten synchronen Korrelations-Aufruf solcher Engines nutzt, bekommt zudem keine Pufferung, die gilt nur für den publizierenden Weg. Und für Signals gilt sie gar nicht. Der Puffer räumt eine der vier Ablehnungen ab, die anderen drei bleiben.

Für alle, die auf Flowable setzen, ist die Konsequenz einfacher: die Pufferung, die andere im Broker haben, ist hier die Inbox-Tabelle. Gleiches Konzept, nur selbst betrieben und dafür per SQL zugänglich.

Variante 5: Claim Check

Claim Check stammt aus den Enterprise Integration Patterns von Hohpe und Woolf. Das Muster löst eigentlich ein anderes Problem: große Nachrichten. Der Payload wandert in einen Datenspeicher, durch den Broker fließt nur noch die Referenz, und der Empfänger holt sich die Daten beim Verarbeiten ab.

In unserem Szenario gäbe es dafür sogar einen echten Anlass: das vollständige Abschlussprotokoll eines Auftrags mit mehreren tausend Positionen hat in einer Pub/Sub-Nachricht nichts verloren. Der Dienstleister legt das Protokoll in einen Bucket, die Nachricht trägt auftragId und die Referenz, und die Korrelation arbeitet nur mit der schlanken Nachricht.

Zum Korrelationsproblem trägt Claim Check aber nur indirekt bei, und das sollte man nicht größer verkaufen, als es ist. Der indirekte Beitrag: die fachlichen Daten liegen dauerhaft im Store, unabhängig von Ack, Retention und Dead-Letter-Aufbewahrung. Selbst wenn die Nachricht endgültig verloren ist, lässt sich aus dem Store nachvollziehen, welche Aufträge abgeschlossen wurden, und ein Nachfahren der Korrelation von Hand oder per Skript ist möglich. Das ist ein Sicherheitsnetz unter dem Sicherheitsnetz, aber es korreliert nichts von allein: die Ablehnungen der Engine, das Crash-Fenster im Empfänger und die Reihenfolge im Relay bleiben exakt, wie sie waren.

Was die Inbox nicht löst

Ein eigenes Kapitel für die Grenzen, damit sie nicht im Kleingedruckten verschwinden.

Die Inbox heilt die Ursache nicht. Der doppelte Token aus dem Reparatur-Kapitel bleibt ein doppelter Token, die Korrelation lehnt seine Rückmeldung weiter ab, ob sie nun aus dem Receiver kommt oder aus dem Relay. Was die Inbox ändert, ist der Ausgang: aus einem gelöschten Beweisstück wird eine geparkte Zeile mit Payload und Fehlertext. Der Fehler wird überlebbar und reparierbar, nicht unmöglich. Die Reparatur selbst ist dann bewusst unspektakulär:

UPDATE auftrag_inbox
SET status = 'OFFEN', versuche = 0, naechster_versuch = now()
WHERE id = 4711;

Erst den doppelten Token in der Engine beseitigen, dann diese eine Zeile, und der Relay erledigt den Rest. Kein Re-Publish-Tooling, kein Kopieren aus dem Dead-Letter-Topic.

Dafür kauft man sich Betriebskosten ein, und der Vorwurf, der dabei regelmäßig fällt, ist berechtigt: ihr baut eine Queue vor die Queue. Der Rückstau wandert aus einer Subscription, die fertige Metriken wie oldest_unacked_message_age und eingespieltes Alerting mitbringt, in eine Tabelle, die zunächst niemand überwacht. Dazu kommen Wachstum und Cleanup der erledigten Zeilen, ein zweiter Retry-Mechanismus neben der Retry-Policy von Pub/Sub und eine zweite Endstation neben dem Dead-Letter-Topic. Jedes dieser Teile will gebaut, getestet und im Team verstanden werden.

Wann lohnt sich das trotzdem? Der Vergleich mit Variante 1 lässt sich auf vier Anforderungen eindampfen. Die Retention-Uhr: auch im Dead-Letter-Topic tickt sie weiter, nach spätestens 31 Tagen ist die Nachricht auch dort weg, Inbox-Zeilen verfallen nie. Deduplizierung: der Unique Key der Inbox macht den Consumer idempotent, Pub/Sub allein tut das nicht. Reparatur per SQL statt per Re-Publish-Tooling, wie oben gezeigt. Und eine bestehende lokale Transaktion, in die der Insert atomar hineingehört, etwa wenn der Empfang ohnehin fachliche Daten schreibt. Trifft keine der vier zu, ist Nack mit Retry-Policy und Dead-Letter-Topic die einfachere und damit bessere Wahl.

Der wiederaufsetzbare Worker

Mit der Korrelation ist die Geschichte nicht zu Ende, denn hinter dem Catch Event geht der Prozess weiter, und dort gelten dieselben Gesetze. Der Schritt „Rechnung stellen“ ist mit flowable:async="true" modelliert, und was das im Ausfall bedeutet, sollte man einmal im Detail gesehen haben.

flowable:async="true" erzeugt einen Job in ACT_RU_JOB. Nimmt ein Async Executor den Job auf, schreibt er Lock-Owner und Lock-Expiration in die Zeile. Stirbt der Pod mitten in der Ausführung, bleibt der Lock stehen, bis er abläuft, dann gibt ihn der Reset-Thread frei, und ein anderer Executor führt den Job aus. Und zwar komplett von vorn: was der gestorbene Pod schon getan hat, weiß niemand. Das ist at-least-once, dieselbe Semantik wie bei Pub/Sub, nur eine Ebene höher, und die Konsequenz ist dieselbe: die Rechnungsstellung muss idempotent sein, sonst wird derselbe Auftrag im Wiederholungsfall zweimal berechnet.

Die Defaults dazu, alle konfigurierbar:

Einstellung Default
asyncExecutorNumberOfRetries 3, danach Deadletter-Job-Tabelle
asyncExecutorAsyncJobLockTimeInMillis 5 Minuten
asyncExecutorTimerLockTimeInMillis 5 Minuten
asyncExecutorResetExpiredJobsInterval 60 Sekunden
asyncExecutorResetExpiredJobsPageSize 3
asyncExecutorDefaultAsyncJobAcquireWaitTime 10 Sekunden

Zwei Zahlen daraus sind Fallen. Drei Retries sind schnell aufgebraucht, wenn das Rechnungssystem zehn Minuten klemmt, danach liegt der Job in der Deadletter-Tabelle und braucht wieder einen Menschen. Und fünf Minuten Lock heißen: nach einem Pod-Tod steht der Schritt bis zu fünf Minuten plus Reset-Intervall still, bevor ihn jemand übernimmt.

Wer die Rechnungsstellung aus der Engine herauslösen will, nimmt den External Worker: der Prozess legt einen Job auf ein Topic, ein externer Worker holt ihn sich mit einem zeitlich begrenzten Exklusivanspruch, einer Lease.

String workerId = "abrechnung-worker-1";

List<AcquiredExternalWorkerJob> jobs = managementService
        .createExternalWorkerJobAcquireBuilder()
        .topic("rechnung-stellen", Duration.ofMinutes(10L))
        .acquireAndLock(20, workerId);

for (AcquiredExternalWorkerJob job : jobs) {
    try {
        stelleRechnung(job);
        managementService
                .createExternalWorkerCompletionBuilder(job.getId(), workerId)
                .complete();
    } catch (Exception e) {
        managementService
                .createExternalWorkerJobFailureBuilder(job.getId(), workerId)
                .errorMessage(e.getMessage())
                .fail();
    }
}

Scheitert die Verarbeitung, meldet der Worker den Fehlschlag statt complete(), und der Job wird erneut vergeben. Eine Verlängerung der laufenden Lease ist im Java-External-Client nicht dokumentiert, deshalb gehört die Lock-Dauer im topic()-Aufruf großzügig über die erwartete Verarbeitungsdauer gelegt: läuft die Lease mitten in der Arbeit ab, kann ein zweiter Worker denselben Job übernehmen, und die Idempotenz muss auch diesen Fall tragen.

Signal oder Message?

Beim Modellieren der Rückmeldung taucht regelmäßig die Frage auf, ob statt der Message nicht ein Signal einfacher wäre, gerade weil Signale bei fehlendem Empfänger keinen Fehler werfen. Die Antwort für dieses Szenario ist eindeutig.

In BPMN 2.0 hat ein Signal nur einen Namen und keinen Adressaten, eine Message hat Sender und Empfänger. Ein Signal wirkt global: es erreicht jede laufende Instanz, die gerade darauf wartet, und keine davon ist im Signal benannt. signalEventReceived(String signalName) notifiziert entsprechend alle Executions mit aktiver Signal-Subscription. Existiert keine einzige, passiert nichts: keine Exception, keine Pufferung, das Signal verpufft. Dazu kommt, dass der Signal-Wurf per Default synchron ist, der werfende Prozess wartet also, bis das Signal allen Catchern zugestellt ist.

Genau die Eigenschaft, die das Signal bequem aussehen lässt, disqualifiziert es für die Abschlussmeldung. „Auftrag abgeschlossen“ für Auftrag 4711 ist eine Aussage an genau eine Prozessinstanz, und wenn diese Instanz nicht wartet, ist das eine Information, die der Absender oder der Betrieb erfahren muss. Die Message liefert diese Information als Ablehnung, mit der die Kapitel oben umgehen. Das Signal liefert sie nicht: es meldet denselben Zustand als Erfolg ohne Wirkung, und aus dem lauten Verlust wird wieder ein stiller. Ein Signal passt dort, wo wirklich viele Instanzen dieselbe Neuigkeit interessiert, etwa ein Tarifwechsel des Dienstleisters, der alle laufenden Aufträge betrifft. Eine adressierte Rückmeldung mit Correlation Key ist eine Message, in jedem der hier gezeigten Modelle. Was passiert, wenn man sich trotzdem für den Rundruf entscheidet, und wie sich der Schaden im Test sichtbar machen lässt, steht in BPMN-Signal oder Nachricht.

Welche Variante wann

Die Entscheidungstabelle, mit dem Empfänger vom Anfang als Messlatte dessen, was zu vermeiden ist:

Variante Nimm sie, wenn Lass sie, wenn
Nack, Retry-Policy, Dead-Letter-Topic Consumer ohne eigene Datenbank, mäßiges Volumen, keine Ordnungsanforderung, Standard-Monitoring reicht Nachrichten länger überleben müssen als die Retention (max 31 Tage, auch im DLT), Deduplizierung gebraucht wird oder Reparatur per SQL gewünscht ist
Transactional Inbox mit Relay mindestens eins zählt: Retention-Uhr, Deduplizierung über Unique Key, Reparatur per SQL, oder ein Insert, der atomar in eine bestehende lokale Transaktion gehört keins der vier Kriterien zutrifft, denn dann bezahlst du Tabelle, Relay, Monitoring und Cleanup ohne Gegenwert
Engine konsumiert selbst ein fertiger, dokumentierter Adapter für deinen Broker existiert und seine Fehlerbehandlung deinen Anforderungen genügt du eigene Anforderungen an Deduplizierung, Backoff oder Endstation hast oder den Consumer unabhängig von der Engine deployen willst
Engine mit Nachrichten-Puffer die Engine-Wahl noch offen ist und das Überhol-Race dein Hauptproblem ist die Engine feststeht; bei Flowable übernimmt die Inbox die Puffer-Rolle
Claim Check die Payload groß ist und ein dauerhafter fachlicher Datenbestand neben dem Messaging entstehen soll du davon eine Lösung des Korrelationsproblems erwartest, denn die liefert es nicht

Quer zu allen Varianten liegen zwei Regeln, die immer gelten. Erstens: ein Ack erst, wenn die Nachricht dauerhaft in Sicherheit ist, und vor dem Ack nur Schritte, die ausschließlich technisch scheitern können. Zweitens: fachliche Ablehnungen der Korrelation brauchen eine Endstation und einen Alarm, keinen Retry.

FAQ

Warum ist meine Pub/Sub-Nachricht weg, obwohl der Code eine Exception geworfen hat?
Weil irgendwo trotzdem geackt wurde. Die üblichen zwei Wege: ein ack() im catch-Block, oder AckMode.AUTO_ACK in Kombination mit einem Error-Handler, denn diese Kombination bestätigt die Nachricht auch im Fehlerfall. Ohne „Retain acknowledged messages“ auf der Subscription oder Message Retention auf dem Topic ist eine geackte Nachricht nicht wiederherstellbar.

Löst exactly-once delivery von Pub/Sub das Problem?
Nein. Es gibt exactly-once delivery nur für Pull-Subscriptions, es gilt nur innerhalb einer Region, und es garantiert exactly-once delivery, nicht exactly-once processing. Publisher-seitige Duplikate bleiben zudem möglich. Die fachliche Ablehnung der Korrelation, das Kernproblem dieses Artikels, berührt es überhaupt nicht.

Warum findet Flowable meine Prozessinstanz nicht, obwohl sie läuft?
Weil korreliert wird, bevor die Instanz das Catch Event erreicht hat. Die Event-Subscription entsteht erst, wenn der Token am Catch Event ankommt, und Flowable kennt für BPMN-Messages keinen Puffer-Mechanismus. Eine Rückmeldung, die den eigenen Prozess überholt, findet deshalb keine Subscription und muss später erneut versucht werden.

Helfen Ordering Keys gegen die Reihenfolge-Falle im Relay?
Nur auf der Broker-Seite, und mit Preisschild: pro Ordering Key sind 1 MBps Durchsatz möglich, und die Redelivery einer Nachricht zieht alle nachfolgenden Nachrichten desselben Keys mit, auch bereits bestätigte. Sobald ein paralleler Relay aus einer Tabelle zieht, muss die Serialisierung pro Correlation Key ohnehin dort stattfinden, die Ordering Keys der Subscription helfen dann nicht mehr.

Brauche ich die Inbox noch, wenn ich schon ein Dead-Letter-Topic habe?
Oft nicht. Das Dead-Letter-Topic deckt den Fall „dauerhaft gescheitert, Mensch muss ran“ bereits ab. Die Inbox gewinnt erst, wenn eine von vier Anforderungen zählt: Aufbewahrung über die Retention hinaus, denn auch im Dead-Letter-Topic gilt maximal 31 Tage, Deduplizierung über einen Unique Key, Reparatur per SQL, oder ein atomarer Insert in eine bestehende lokale Transaktion.

Wie verhindere ich, dass die Inbox-Tabelle unbegrenzt wächst?
Mit einem eigenen Cleanup-Job, den es sonst niemand gibt: erledigte Zeilen nach einer Aufbewahrungsfrist löschen oder archivieren, geparkte Zeilen ausdrücklich ausnehmen. Dazu gehören zwei Alarme, einer auf geparkte Einträge und einer auf das Alter des ältesten offenen Eintrags, denn die fertigen Pub/Sub-Metriken sehen die Tabelle nicht.

Was passiert mit einer Nachricht, die 31 Tage lang nirgends verarbeitet wurde?
Sie ist weg. Die Message Retention einer Subscription beträgt maximal 31 Tage, und nach ihrem Ablauf darf Pub/Sub die Nachricht unabhängig vom Bestätigungszustand verwerfen. Das gilt auch für die Subscription auf einem Dead-Letter-Topic. Wer länger aufbewahren muss, braucht einen eigenen Speicher, etwa die Inbox oder einen Claim-Check-Store.

Fazit

Vor dem Ack darf nur stehen, was ausschließlich technisch scheitern kann. Einen technischen Fehler heilt die erneute Zustellung durch den Broker, eine fachliche Ablehnung der Korrelation heilt kein Retry der Welt, und deshalb gehört die Workflow-Engine hinter das Ack, nicht davor.

Auf dem Weg dorthin sind es zwei getrennte Defekte, die auseinandergehalten werden müssen. Die Engine im Ack-Pfad ist ein Verfügbarkeitsproblem: jedes Deployment staut die Subscription, aber die Nachrichten überleben. Das Ack im Fehlerfall, ob als catch-Block oder als AUTO_ACK mit Error-Handler, macht daraus Datenverlust, und der ist bei Pub/Sub ohne Vorkehrung endgültig.

Für die Lösung gilt diese Reihenfolge: Nack mit Retry-Policy und Dead-Letter-Topic zuerst prüfen, denn für viele Consumer ist das die bessere Wahl, mit Metriken und Alerting frei Haus. Die Transactional Inbox kommt ins Spiel, wenn Retention, Deduplizierung, SQL-Reparatur oder eine lokale Transaktion zählen, und sie kommt mit spürbaren Betriebskosten: eigene Überwachung, eigener Cleanup, eigene Endstation, und die Reihenfolge-Falle im parallelen Relay, die pro Correlation Key serialisiert werden muss. Und ganz gleich, welche Variante es wird: die Ursachen fachlicher Ablehnungen, vom Überhol-Race bis zum doppelten Token aus der Hand-Reparatur, verschwinden durch keine davon. Sichtbar, aufbewahrt und reparierbar: mehr kann eine Zustell-Architektur nicht leisten.

Quellen

Alle Code-Beispiele in diesem Artikel sind eigene.

Stand: August 2026, geprüft gegen Spring Cloud GCP 8.1 und Flowable 8.0.

$ lang DE EN ES