This post was originally published in English on Medium.com.
Wer mit Zeebe arbeitet, taucht in die Welt verteilter Systeme ein. Eine Welt, die auch im Jahr 2025 für viele Teams noch Neuland ist. Verteilte Systeme bringen eine Reihe von Herausforderungen mit sich, die jedes Team verstehen muss. Eine der kritischsten — oft unterschätzt oder sogar ignoriert — ist das Distributed Transaction Problem. Dieses Problem tritt früher oder später auf - egal was du baust.
Es tritt auf, weil Zeebe per Design als externer Service läuft. Getrennt von deiner Anwendung. Mit einer eigenen Datenbank. Nur über das Netzwerk erreichbar. Diese architektonische Entscheidung bringt verschiedene Vorteile: Skalierbarkeit, ein cloud-native Deployment und Resilienz. Aber es bedeutet auch — im Vergleich zu eingebetteten Engines wie dem Vorgänger Camunda 7 — dass Operationen, die früher atomisch innerhalb eines einzelnen Systems abliefen, nun mehrere Systeme umfassen. Systeme ohne gemeinsame Transaktionsgrenze. Wenn also etwas schiefgeht, kann es zu Dateninkonsistenzen, fehlschlagenden Prozessen und Koordinationsfehlern kommen.
Die gute Nachricht: Diese Herausforderung ist nicht Zeebe-spezifisch. Es ist ein bekanntes Problem in verteilten Systemen mit bewährten Lösungen.
Dieser Artikel untersucht diese Patterns und zeigt dir, wie du sie effektiv anwenden kannst.
Er geht dabei auf After-Transaction Hooks (einfach, aber begrenzt) und das Outbox Pattern (umfassend, aber komplex) ein.
Außerdem etabliert er Idempotenz als eine fundamentale Systemeigenschaft, die du beim Bau verteilter Systeme berücksichtigen musst.
Schließlich untersucht er Zeebe-spezifische Tools wie messageIDs und BPMN selbst, die sowohl Sendern als auch Empfängern helfen, Konsistenz zu gewährleisten.
Auf dieser Grundlage wird der Überblick über Muster mit einem Blick auf das SAGA-Pattern abgeschlossen.
Über Implementierungsmuster hinaus schlägt dieser Artikel vor, wie du die Herausforderungen systematisch angehen kannst: durch den Aufbau von Teambewusstsein in deiner Organisation, die Identifizierung kritischer Szenarien in deiner Codebasis und die Priorisierung von Lösungen basierend auf Business Impact. Denn die Lösung dieser Herausforderung ist nicht nur eine Frage der Implementierung von Patterns im Code — es geht auch darum, sicherzustellen, dass dein gesamtes Team die Herausforderung versteht.
Egal, ob du ein Greenfield-Zeebe-Projekt startest, von Camunda 7 migrierst oder bereits mit mysteriösen Problemen zu kämpfen hast: Dieser Leitfaden hilft dir, diese Herausforderungen zu meistern - um die vollen Vorteile von Zeebe und deinem verteilten System zu nutzen.
Es gibt überraschend wenig Informationen über das Distributed Transaction Problem im Kontext von Remote-Process-Engines wie Zeebe. Daher ist dieser Artikel bewusst umfassend und detailliert. Er ist als Referenzwerk gedacht, das diese Lücke füllen soll. Ich plane jedoch auch in Zukunft, kürzere, fokussiertere Artikel zu spezifischen Aspekten aus diesem Post zu veröffentlichen.
Der Artikel basiert auf einem Talk, den ich kürzlich bei einem Camunda University Chapter gehalten habe und ergänzt ein GitHub Repository mit umfangreichen Beispielen des Problems sowie Lösungsmustern.
Stell dir vor: Du betreibst eine erfolgreiche Gaming-Newsletter-Plattform mit vielen Abonnenten, die wöchentliche Updates über ihre Lieblingsspiele erhalten. Dein Zeebe-basierter Subscription-Prozess läuft reibungslos. User abonnieren, erhalten Bestätigungsmails, bestätigen ihr Abonnement und erhalten eine Willkommensmail. Danach bekommen sie die periodischen Newsletter. Alles scheint gut zu laufen.
Doch von Zeit zu Zeit häufen sich Support-Tickets: Mehrere User melden dasselbe Problem: “Ich habe mich angemeldet, aber keine Bestätigungsmail erhalten”. Eine Weile lang stuft das Support-Team dies als User-Fehler ein. Aber irgendwann wird es zu viel, und du musst nachforschen. Dabei stößt du auf ein beunruhigendes Muster:
Für jeden Fall existiert kein Abonnement in der Datenbank.
Soweit, so seltsam.
Aber dann wird es widersprüchlich: Du findest für jeden Fall eine Instanz in Operate.
Eine Instanz mit einem Incident.
Ein Incident, der immer dieselbe Fehlermeldung zeigt: NoSuchElementException.
Der Task zum Senden der Bestätigungsmail kann das Abonnement nicht finden.
Dir wird klar: Dies ist kein zufälliger Bug. Das ist systematisch. Etwas Grundlegendes ist kaputt. Also beginnst du zu debuggen. Dein Code sieht korrekt aus. Du speicherst ein Abonnement, sendest eine Message an Zeebe. Und all das wird in einer Operation gemacht, die eine Transaktion verwendet. Also, was läuft schief?
@Service
@Transactional
class SubscribeToNewsletterService(
private val subscriptionRepository: NewsletterSubscriptionRepository,
private val zeebeClient: ZeebeClient
) {
fun subscribe(email: String): SubscriptionId {
// Save the subscription to the database
val subscription = Subscription(email = email, status = PENDING)
subscriptionRepository.save(subscription)
// Start the process instance in Zeebe
val variables = mapOf("subscriptionId" to subscription.id.toString())
zeebeClient.newPublishMessageCommand()
.messageName("subscription-form-submitted")
.withoutCorrelationKey()
.variables(variables)
.send()
.join()
return subscription.id
}
}
Irgendwann kommt die Erkenntnis: Da Zeebe als völlig separates System mit eigener Infrastruktur läuft, hat @Transactional absolut keine Macht darüber.
Es kontrolliert nur deine lokale Datenbanktransaktion.
Wenn also die Transaktion fehlschlägt, nachdem die Message erfolgreich an Zeebe gesendet wurde, wird nur deine Datenbank zurückgerollt. Das Abonnement verschwindet. Aber der Prozess? Er läuft bereits & führt Tasks aus. Sucht nach Daten, die nicht existieren.
Das ist das Distributed Transaction Problem — und diese Incidents sind seine Signatur in deinen Production-Logs.
Um zu verstehen, warum dieses Problem wahrscheinlich viele Teams überrascht und warum es nur in verteilten Systemen existiert, müssen wir uns ansehen, woher die meisten Entwickler kommen: der monolithischen Einzelsystem-Welt.
Jahrelang war dies das dominierende Muster, wo alles zusammenlebte. Business-Logik, Datenbankinteraktionen und oft die Process-Engine selbst — alles innerhalb einer Anwendung, schreibend in eine Datenbank, geschützt durch eine Transaktion pro Operation.
Betrachte unseren Newsletter-Service als Beispiel. REST-Endpoints, Datenbank-Adapter und die Engine selbst konnten in einer einzelnen Anwendung koexistieren. Die Engine läuft embedded als Bibliothek und teilt die Datenbank der Anwendung. Diese Vereinigung machte Operationen sowohl einfach als auch sicher:
@Service
@Transactional
class SubscribeToNewsletterService(
private val subscriptionRepository: NewsletterSubscriptionRepository,
private val runtimeService: RuntimeService // Embedded engine
) {
fun subscribe(email: String): SubscriptionId {
val subscription = Subscription(email = email, status = PENDING)
subscriptionRepository.save(subscription)
val variables = mapOf("subscriptionId" to subscription.id.toString())
runtimeService.startProcessInstanceByKey(
"newsletter-subscription",
subscription.id.toString(),
variables
)
return subscription.id
// Transaction commits here - atomically!
}
}
Der obige Service war beispielsweise sicher, weil alle Operationen darin in dieselbe Datenbank schrieben - unter Verwendung derselben Transaktion. Und dieselbe Transaktion bedeutete dasselbe Schicksal. Wenn irgendetwas fehlschlug, wurde die gesamte Datenbankoperation automatisch zurückgerollt. Entweder wurden das Abonnement und die Instanz gespeichert - oder keines von beiden:
All dies war durch die ACID-Prinzipien geschützt. Sie machten Konsistenz unkompliziert - und halfen daher auch beim Arbeiten mit Engines. ACID steht für:
Aber schönreden wir nicht alles: Selbst in dieser monolithischen Welt konnte dich ACID nicht überall schützen. In dem Moment, in dem du mit externen Systemen interagiert hast - wie Email-Services, Message Brokern oder REST APIs — hast du seine Sicherheit verlassen. Deine Transaktion konnte nicht über Netzwerkgrenzen hinweg reichen. Das bedeutet, dass die gleichen Probleme auch damals existierten.
Allerdings war es weniger sichtbar, weil die meisten Operationen innerhalb deines Systems blieben. Das Problem tauchte nur auf, wenn du absichtlich mit externen Services integriert hast.
Und was im Kontext unserer Perspektive am relevantesten ist: Die Process-Engine selbst war nicht Teil des Problems.
Diese relative Sicherheit war möglich, weil Embedding eine Option war.
Mit Remote-Engines wie Zeebe ändert sich dies. Die Engine selbst wird zu einem externen System, mit dem du koordinierst — per Design. Und dieser Wandel ist fundamental.
Was einst ein gelegentliches Problem war — wenn du absichtlich mit externen Services integriert hast — gilt jetzt für jede einzelne Interaktion mit deiner Process-Engine. Erinnere dich daran, dass Embedding dein Sicherheitsnetz war. Mit Zeebe existiert diese Option nicht mehr.
Die Architektur besteht nun aus drei separaten Teilen: deiner Anwendung mit ihrer Datenbank, Zeebe mit seiner eigenen Infrastruktur und Netzwerkkommunikation zwischen ihnen.
Das bedeutet, dass die meisten Operationen zwei unabhängige Systeme mit zwei unabhängigen Transaktionen betreffen. Aber hier ist, was dies besonders trügerisch macht — dein Code sieht fast identisch aus:
@Service
@Transactional
class SubscribeToNewsletterService(
private val subscriptionRepository: NewsletterSubscriptionRepository,
private val zeebeClient: ZeebeClient
) {
fun subscribe(email: String): SubscriptionId {
// Save the subscription to the database
val subscription = Subscription(email = email, status = PENDING)
subscriptionRepository.save(subscription)
// Start the process instance in Zeebe
val variables = mapOf("subscriptionId" to subscription.id.toString())
zeebeClient.newPublishMessageCommand()
.messageName("subscription-form-submitted")
.withoutCorrelationKey()
.variables(variables)
.send()
.join()
return subscription.id
// Transaction commits here - but only for the database!
}
}
Jedoch hat sich unter der Oberfläche alles geändert. Das liegt daran, dass die Transaktion deine Engine nicht mehr kontrolliert - sondern nur noch die Datenbank deiner Domain.
Du kannst die wirkliche Auswirkung sehen, wenn du dir ein Sequenzdiagramm anschaust — aber nicht eines mit erfolgreichem Ausgang. Im Happy-Path würdest du nur sehen, dass Datenbankoperationen zu gRPC-Calls über das Netzwerk werden. Das Problem wird nur sichtbar, wenn Dinge fehlschlagen. Und das würde so aussehen:
Der Service fügt das Abonnement in die Datenbank ein und sendet die Message an Zeebe. Zeebe bestätigt sie und startet den Prozess sofort. Aber dann — vielleicht aufgrund eines Connection-Timeouts — schlägt der Commit fehl. Die Datenbank rollt zurück, wodurch das Abonnement verschwindet. Zeebe weiß jedoch nichts von diesem Fehler. Es hat die Message empfangen und begonnen zu arbeiten — sucht nun nach Daten, die nicht mehr existieren.
Das ist die Exception, die in unserem Beispielszenario auftritt. Und sie erscheint, weil wir die vorherigen ACID-Garantien nicht mehr haben. Stattdessen — mit verteilter Architektur — operieren wir unter einem anderen Paradigma namens BASE. Es bedeutet:
Aber verinnerliche eines: Dies ist keine Einschränkung — es ist ein Trade-off. Wir haben unmittelbare Konsistenz innerhalb eines Systems gegen unabhängige, resiliente Systeme getauscht, die unabhängig skalieren und ausfallen können. Mit ACID waren Operationen atomisch, aber eng gekoppelt. Mit BASE sind Operationen entkoppelt, aber eventual konsistent.
Nimm dir einen Moment, um dies sacken zu lassen. Deine Datenbanktransaktion kann erfolgreich sein. Dein Prozess kann starten. Dennoch ist dein System vorübergehend inkonsistent. Das ist die verteilte Realität — und sie erfordert andere Patterns, um Konsistenz über Systeme hinweg zu erhalten.
All diese Herausforderungen können auch bei Camunda 7 auftreten. C7 konnte embedded im selben Service wie deine Business-Logik laufen, oder es konnte in einem separaten Service laufen, der mit deinem Domain-Service über das Netzwerk interagiert. Der Unterschied ist: Embedding war eine Option bei C7. Mit Zeebe ist es das nicht. Wir verwenden C7 für den Kontext — um zu zeigen, wie Systeme früher aussahen — besonders da viele Teams aufgrund des End-of-Life von C7 migrieren. Also ist kein Ansatz besser oder schlechter.
Diese verteilte Realität — wo Systeme vorübergehend inkonsistent sein können — hat einen Namen: das Distributed Transaction Problem. Es ist nicht nur eine Zeebe-Herausforderung. Es ist eine grundlegende Eigenschaft jeder Architektur, bei der Operationen mehrere unabhängige Systeme umfassen.
Die Kernherausforderung ist einfach erklärt: Wenn eine Operation mehrere unabhängige Systeme umfasst, kannst du nicht garantieren, dass alle Systeme zusammen erfolgreich sind oder zusammen fehlschlagen. Das öffnet die Tür zur Inkonsistenz.
In einer einzelnen Datenbank mit ACID fungiert die Datenbank als Transaction-Coordinator. Sie sperrt Ressourcen, koordiniert Commits und rollt alles zurück, wenn Fehler auftreten. Sobald du Systemgrenzen überschreitest, existiert kein solcher Coordinator mehr. Jedes System verwaltet seinen Zustand unabhängig und trifft eigene Commit- oder Rollback-Entscheidungen — ohne Mechanismus, alle abzugleichen.
Das Ergebnis: Partieller Erfolg wird möglich. Ein System committet erfolgreich, während ein anderes fehlschlägt und zurückrollt. Wenn das passiert, sind deine Daten inkonsistent - ohne automatischen Weg zur Behebung. Die Gründe für solche Fehler sind zahlreich: Netzwerk-Timeouts, Verfügbarkeitsprobleme und mehr. Sie sind normale Bedingungen in verteilten Systemen, die — auch wenn selten — von Zeit zu Zeit auftreten. Vollständig verhindern kannst du sie nie.
Nochmal zusammengefasst: Die Herausforderung tritt auf, sobald Operationen über unabhängige Systeme hinweg koordinieren müssen — nicht nur bei Zeebe. Im Allgemeinen kann es auftreten, wenn du arbeitest mit:
Jede Systemgrenze wird zum Risikopunkt. Wenn das Distributed Transaction Problem dort zuschlägt und du keine geeigneten Patterns verwendest, zeigt es sich auf destruktive Weise:
Das sind keine Kleinigkeiten. Sie wirken sich direkt auf Geschäftsoperationen, Kundenvertrauen und Systemzuverlässigkeit aus.
Lass uns nun alles zusammenbringen. Wenn du Zeebe adoptierst, baust du ein verteiltes System. Eines, bei dem deine Anwendung und Zeebe über das Netzwerk koordinieren. Jedes hat seine eigene Datenbank. Jedes trifft unabhängige Commit-Entscheidungen. Anders als bei eingebetteten Engines — wo die Engine deine Transaktionsgrenze teilen konnte — ist dieses Sicherheitsnetz mit Zeebe weg.
Das bedeutet, dass das Distributed Transaction Problem jede Interaktion mit deiner Engine betrifft: jede Message, die du sendest, jeden Job, den deine Worker verarbeiten.
Die spezifischen Herausforderungen, auf die du triffst, hängen von deinen Koordinationsmustern, Error-Handling-Strategien, Netzwerkzuverlässigkeit, Systemlast und Transaktionskonfiguration ab. Aber bestimmte Fehlerszenarien tauchen wiederholt über Implementierungen und Umgebungen hinweg auf.
Diese Fehler clustern sich um zwei kritische Momente: wenn deine Anwendung Messages an Zeebe sendet und wenn Worker abgeschlossene Jobs bestätigen. Das Verstehen dieser Manifestationen hilft dir, sie zu erkennen, effektive Abwehrmaßnahmen zu entwerfen und geeignete Lösungen zu wählen:
1. Phantom-Instanzen: Prozess läuft ohne Daten
Dies ist das Szenario, das wir bereits mehrfach erwähnt haben. Deine Anwendung sendet eine Message an Zeebe, bevor die Datenbanktransaktion committed. Der Prozess startet sofort. Aber dann schlägt deine Transaktion fehl und rollt zurück. Das Ergebnis: Zeebe hat eine laufende Prozessinstanz, aber die Business-Daten, die sie benötigt, existieren nicht. Worker, die versuchen, das Abonnement abzurufen, bekommen eine Exception.
2. Vorzeitige Ausführung: Lesen von Uncommitted Data
Es gibt jedoch auch andere Varianten dieses Szenarios. Selbst wenn dein Commit schließlich erfolgreich ist, könnte die Engine der erste Task ausführen, bevor der Commit abgeschlossen wurde. Dies stellt ein Timing-Problem dar, weil es zum gleichen Szenario wie oben führen kann: Der Worker kann noch nicht auf die Daten zugreifen, die er benötigt. Aber es könnte auch schlimmer sein - was zu einem dritten Problem führt.
3. Vorzeitige Ausführung: Überschreiben von Daten
Dieses Szenario tritt auf, wenn wir Änderungen von einer Folge-Task ausführen und committen, bevor der vorherige Task abgeschlossen ist. Es ist besonders problematisch, wenn beide Tasks dieselben Daten aktualisieren. In solchen Fällen können wir den Zustand korrumpieren. Dies führt nicht nur zu Inkonsistenz, sondern auch zu Race Conditions, die manuelle Auflösung erfordern.
Das Koordinationsproblem betrifft nicht nur das Senden von Messages an Zeebe — es wirkt sich auch darauf aus, wie Worker ihre Jobs abschließen. Betrachte dieses Szenario: Ein Worker verarbeitet erfolgreich einen Job und committet Änderungen in deine Datenbank. Alles sieht gut aus. Aber wenn der Worker versucht, die Completion an die Engine zu bestätigen, tritt ein Fehler auf.
Jetzt stehst du vor zwei unterschiedlichen Ergebnissen, abhängig davon, warum die Bestätigung fehlgeschlagen ist:
1. Temporäre Fehler — aufgrund von Connectivity-Problemen
Diese Fehler können aus vielen Gründen auftreten, wie Netzwerkproblemen. Zu diesem Zeitpunkt ist der Job aus Zeebes Perspektive noch aktiv. Das bedeutet, Zeebe wird den Job nach einem Timeout wiederholen. Obwohl problematisch, ist dieses Szenario mit Idempotency-Patterns (die wir später behandeln werden) handhabbar.
2. Permanente Fehler — aufgrund von Job-Cancellation
Diese Fehler sind viel schwieriger zu handhaben. Sie treten auf, wenn der Job abgebrochen wird — zum Beispiel durch ein Boundary Event, das während der Verarbeitung durch deinen Worker ausgelöst wurde. Aus Zeebe’s Perspektive ist der Job nicht mehr aktiv. Zudem weiß Zeebe nicht, dass er auf der Domain-Seite erfolgreich ausgeführt wurde. Dies erzeugt eine permanente Inkonsistenz, die schwer zu erkennen ist und wahrscheinlich manuelle Intervention zur Auflösung erfordert.
Diese Szenarien sind keine theoretischen Edge Cases, die du ignorieren kannst. Sie passieren regelmäßig — auch wenn die hohe Zuverlässigkeit moderner Infrastruktur sie relativ selten macht. Was sie besonders herausfordernd macht, ist, dass sie nicht isoliert auftreten. Sie können sich kombinieren, was sie noch schwerer zu erkennen und zu beheben macht.
Die Koordinationsmuster, die wir im nächsten Abschnitt untersuchen, adressieren sie systematisch. Anstatt jedes Fehlermodus separat zu behandeln, bieten diese Patterns ein kohärentes Framework zur Aufrechterhaltung der Konsistenz über dein verteiltes System hinweg.
Für einen umfassenden Deep-Dive in alle Szenarien mit detaillierten Sequenzdiagrammen, Code-Beispielen und Schritt-für-Schritt-Analyse, schau dir das distributed-horcruxes Repository an.
Wo wir stehen: Wir koordinieren zwischen zwei autonomen Systemen — Zeebe und unserem Service — jedes mit eigener Datenbank und ohne gemeinsame Transaktionsgrenze. Fehler in einem der Systeme können unsere Daten inkonsistent lassen.
Die gute Nachricht? Weil Distributed Transactions eine allgemeine Herausforderung sind, gibt es bewährte Patterns. Die schlechte Nachricht? Es gibt keine Patentlösung. Jede Lösung hat Trade-offs, und die Wahl der richtigen hängt von deinen Anforderungen ab.
Wir schauen uns dein Toolkit in drei Blöcken an: Basis-Patterns, die das Koordinationsproblem direkt angehen, Idempotenz als Sicherheitsnetz für doppelte Operationen und Zeebe-spezifische Features, die dir helfen, diese Patterns effektiver umzusetzen.
Diese Patterns zeigen, wie Operationen über Systemgrenzen hinweg koordiniert werden, während Konsistenz erhalten bleibt.
Wenn Operationen fehlschlagen, lautet der erste Instinkt meist: “einfach wiederholen!”
Es ist ein gängiges Pattern in verteilten Systemen - einfach zu implementieren und in vielen Fällen funktioniert es gut.
Wenn du beispielsweise das Spring Framework und seine Retry-Library verwendest, musst du nur ein @Retryable zu einer Methode hinzufügen.
Sonst nichts.
Bei einem Fehler wird die Methode erneut ausgeführt, bis ein Limit erreicht ist.
@Service
@Transactional
class SubscribeToNewsletterService(
private val subscriptionRepository: NewsletterSubscriptionRepository,
private val zeebeClient: ZeebeClient
) {
@Retryable(maxAttempts = 3)
fun subscribe(email: String): SubscriptionId {
// logic to create a subscription & start the process
}
}
Im Kontext des Sendens von Messages an eine Process-Engine wie Zeebe ist dieser Ansatz jedoch meist falsch. Um zu verstehen warum, lass uns darüber nachdenken, was passiert, wenn ein Call fehlschlägt. Wenn in diesem Fall die gesamte Operation erneut durchgeführt wird - und nach einigen Retries erfolgreich ist, haben wir mehrere Prozessinstanzen in der Engine, aber nur ein Abonnement in der Datenbank. Dies ist eine ungelöste Inkonsistenz, die höchstwahrscheinlich Probleme verursacht.
Was wir daraus lernen sollten, ist Folgendes: Retries sind ein sehr mächtiges Tool in verteilten Systemen. Aber wenn man Orchestrierungsprobleme löst, schafft ihre Verwendung als alleinige Lösung typischerweise mehr Probleme, als sie löst. Retries funktionieren am besten, wenn Operationen idempotent sind — aber das behandeln wir später. Für jetzt schlussfolgern wir, dass wir eine Lösung brauchen, die garantiert, dass eine Message nur nach einem erfolgreichen Commit an Zeebe gesendet wird. Und genau das bietet das nächste Pattern.
Die Idee ist elegant: Anstatt es direkt aufzurufen, registrierst du den Call zu Zeebe als Callback, der nach erfolgreichem Commit ausgeführt wird.
Für solche Szenarien bietet beispielsweise das SpringBoot Framework sogenannte TransactionSynchronization Hooks.
Eine Implementierung, die solche Hooks verwendet, zentriert sich um zwei Komponenten. Erstens den Hook selbst, der den Call zu Zeebe implementiert - und falls nützlich, auch einige Pre-Commit-Checks, um die Sicherheit zu erhöhen, dass der Call erfolgreich sein wird:
class ProcessEngineCallSynchronization(
private val camundaClient: CamundaClient,
private val processEngineCall: (): Unit
) : TransactionSynchronization {
private val log = KotlinLogging.logger {}
override fun afterCommit() = try {
processEngineCall()
} catch (e: Exception) {
log.error(e) { "Failed to execute process engine call" }
throw e
}
override fun beforeCommit(readOnly: Boolean) {
val topology = camundaClient.newTopologyRequest().send().join()
val healthy = checkBrokerHealth(topology)
if (!healthy) {
throw IllegalStateException("No healthy broker found")
}
}
}
Und zweitens, ein Synchronizer, der diese Callbacks beim Transaction-Manager von Spring registriert. Er sucht nach einer laufenden Transaktion und fügt die Synchronisation zu ihrem Lifecycle hinzu:
class ProcessEngineSynchronizer(private val camundaClient: CamundaClient) {
fun executeAfterCommit(
processEngineCall: () -> Unit
) = TransactionSynchronizationManager.registerSynchronization(
ProcessEngineCallSynchronization(camundaClient, processEngineCall)
)
}
Mit diesen Komponenten bleibt dein Service-Code sauber und aussagekräftig.
Er verwendet einfach den Synchronizer und seine executeAfterCommit-Methode, um den Engine-Call zu registrieren & auszuführen.
@Service
@Transactional
class SubscribeToNewsletterService(
private val subscriptionRepository: NewsletterSubscriptionRepository,
private val engineSynchronizer: ProcessEngineSynchronizer,
private val zeebeClient: ZeebeClient
) {
fun subscribe(email: String): SubscriptionId {
// Save to database within transaction
val subscription = Subscription(email = email)
val savedSubscription = subscriptionRepository.save(subscription)
// Register Zeebe call to happen AFTER commit
engineSynchronizer.executeAfterCommit {
zeebeClient.newPublishMessageCommand()
.messageName("subscription-created")
.variables(mapOf("subscriptionId" to savedSubscription.id))
.send()
.join()
}
return savedSubscription.id
// Database commits first, then Zeebe call executes
}
}
Der Ausführungsfluss zeigt den entscheidenden Unterschied: Zeebe wird erst benachrichtigt, nachdem die Datenbank erfolgreich committed hat. Das Phantom-Instanz-Problem ist gelöst — Worker finden immer ihre Daten, weil garantiert ist, dass sie existieren, wenn die Message ankommt.
Vorteile
Das After-Transaction Pattern bietet mehrere Vorteile. Deine Datenbank bleibt konsistent. Das Pattern ist einfach zu verstehen sowie zu implementieren. Und du kannst sogar einen optionalen Health-Check vor dem Commit hinzufügen, um deine Erfolgsrate zu erhöhen.
Nachteile
Aber es gibt eine kritische Schwäche: Du hast das Problem gerade auf die andere Seite verschoben. Anstatt also Prozesse ohne Daten zu riskieren, riskierst du jetzt verwaiste Daten — das bedeutet Abonnements ohne Prozesse. Das liegt daran, dass wenn der Zeebe-Call nach dem Commit fehlschlägt, die Message verloren geht. Da es keinen persistenten Storage gibt, ist manuelle Intervention nötig, um dieses Problem zu lösen.
Fazit
After-Transaction funktioniert gut für nicht-kritische Szenarien und Proof-of-Concepts. Für Systeme, die garantierte Zustellung mit einem Minimum an manueller Intervention erfordern, brauchst du Persistenz. Und genau das bietet das dritte Pattern.
Das Outbox Pattern verfolgt einen überraschend einfachen Ansatz für dieses komplexe Problem: Es besagt, dass wenn du nicht zuverlässig über zwei Systeme hinweg koordinieren kannst, solltest du es zu einem Problem in einem System machen.
Daher schreibt es sowohl deine Business-Daten als auch die Message in deine Datenbank in einer atomaren Transaktion. Es gibt keinen sofortigen Call zu Zeebe — nur zwei Datenbank-Writes. Ein Hintergrundprozess liest später diese Messages und sendet sie an Zeebe. Dies stellt ACID-Garantien wieder her:
👉🏽 Entweder werden sowohl das Abonnement als auch die Message zusammen gespeichert, oder keines überlebt einen Fehler.
Das Pattern teilt die Koordinationsherausforderung in zwei unabhängige Belange: atomare Datenbank-Writes und zuverlässige Message-Zustellung. Es verwandelt ein komplexes verteiltes Problem in zwei einfachere, handhabbare Teile.
Belang 1: Atomarer Write in Datenbank und Outbox
Deine Anwendung schreibt sowohl die Business-Daten als auch den Message-Record in die Datenbank in einer einzelnen Transaktion — entweder werden beide gespeichert, oder keines überlebt einen Fehler. Dazu benötigt es eine zusätzliche Tabelle in deiner Datenbank. Die Outbox-Tabelle. Deine anderen Tabellen bleiben unberührt. Die Outbox fungiert als Staging-Area für Messages, die wir an Zeebe senden müssen.
-- Your existing business table
-- newsletter_subscriptions (id, email, status, created_at)
-- Add this new outbox table
CREATE TABLE process_messages (
id UUID PRIMARY KEY,
message_name VARCHAR(255) NOT NULL,
correlation_id VARCHAR(255),
variables JSONB NOT NULL,
status VARCHAR(50) NOT NULL, -- PENDING or SENT
retry_count INT DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Wenn die Tabelle vorhanden ist, muss dein Business-Service einfach die Messages an Zeebe auch in der Datenbank speichern. Noch keine Netzwerk-Calls zu Zeebe, also bleibt die Transaktion schnell:
@Service
@Transactional
class SubscribeToNewsletterService(
private val subscriptionRepository: NewsletterSubscriptionRepository,
private val outboxRepository: ProcessMessageRepository
) {
fun subscribe(email: String): SubscriptionId {
val subscription = subscriptionRepository.save(
Subscription(email = email, status = PENDING)
)
val message = ProcessMessage(
messageName = "subscription-created",
correlationId = subscription.id.toString(),
variables = mapOf("subscriptionId" to subscription.id),
status = MessageStatus.PENDING
)
outboxRepository.save(message)
return subscription.id // Both saved atomically
}
}
Belang 2: Background Polling und Sending
Stattdessen übernimmt ein separater Scheduler die Kommunikation mit Zeebe. Er pollt kontinuierlich die Outbox-Tabelle nach pending Messages. Wenn er eine findet, sendet er die Message an die Engine:
@Component
class ProcessEngineOutboxScheduler(
private val camundaClient: CamundaClient,
private val transactionManager: PlatformTransactionManager,
private val repository: ProcessMessageJpaRepository,
) {
private val log = KotlinLogging.logger {}
private val objectMapper = ObjectMapper()
@Scheduled(fixedDelay = 200)
fun sendMessages() {
log.debug { "Running scheduler to send messages to zeebe" }
var messagesProcessed = 0
while (processNextMessage()) messagesProcessed++
log.debug { "Scheduler finished sending messages to zeebe" }
}
private fun processNextMessage() = performInTransaction {
val message = repository.findFirstByStatusWithLock(MessageStatus.PENDING)
if (message == null) {
false
} else {
trySendMessage(message)
true
}
}
private fun trySendMessage(message: ProcessMessageEntity) {
try {
sendMessage(message)
val sentMessage = message.copy(status = MessageStatus.SENT)
repository.save(sentMessage)
log.info { "Successfully sent message ${message.messageName}" }
} catch (e: Exception) {
val retryCount = message.retryCount + 1
val retryMessage = message.copy(retryCount = retryCount)
repository.save(retryMessage)
log.warn(e) { "Retrying to send message ${message.messageName}" }
}
}
private fun sendMessage(message: ProcessMessageEntity) {
val variables = objectMapper.readValue(
message.variables,
object : TypeReference<Map<String, Any>>() {}
)
val messageId = "${message.correlationId}-${message.messageName}"
log.info { "Sending message ${message.messageName}" }
camundaClient.newPublishMessageCommand()
.messageName(message.messageName)
.correlationKey(message.correlationId)
.messageId(messageId)
.variables(variables)
.timeToLive(Duration.of(10, ChronoUnit.SECONDS))
.send()
.join()
}
}
Der Scheduler kann beispielsweise alle 200ms laufen.
Dabei verwendet er Datenbank-Locks (SELECT FOR UPDATE SKIP LOCKED), um Race Conditions zu verhindern — was erlaubt, dass mehrere Scheduler-Instanzen parallel laufen.
Wenn eine Message erfolgreich gesendet wurde, wird sie als SENT markiert.
Wenn das Senden fehlschlägt, wird der Retry-Counter erhöht und die Message bleibt PENDING für den nächsten Polling-Zyklus.
Wie bei den anderen Patterns gezeigt, sieht dieser Fluss in einem Sequenzdiagramm so aus:
Vorteile
Das Outbox Pattern garantiert eventuelle Message-Zustellung — wenn Zeebe down ist oder die Anfrage fehlschlägt, persistiert die Message in der Datenbank und wird wiederholt. Dies löst alle Timing- und Zuverlässigkeitsprobleme. Der atomare Storage sorgt für einen Alles-oder-Nichts-Ansatz: Entweder werden sowohl die Business-Daten als auch die Message gespeichert, oder keines überlebt. Diese Separation of Concerns trennt Datenbankoperationen sauber von Process-Engine-Interaktionen. Zusätzlich erhältst du einen Audit-Trail, da SENT Messages in der Datenbank bleiben, und eingebaute Retries behandeln Fehler automatisch.
Nachteile
Aber wie immer gibt es Trade-offs. Das Pattern führt zusätzliche Komplexität ein. Du musst die Outbox-Tabelle, den Scheduler implementieren und zusätzliche Infrastruktur handhaben. Daneben wirst du leichte Verzögerungen durch das Polling-Intervall erleben — typischerweise 100-200ms. Weiterhin brauchst du eine Message-Processing-Strategie. Dies könnte sicheres sequentielles Processing oder schnelleres paralleles Processing mit Ordering-Überlegungen sein. Messages könnten auch mehrfach gesendet werden, also sind Mechanismen wie Dead Letter Queues essentiell. Schließlich wird bei wachsender Datenbank eine Cleanup-Strategie für alte Messages notwendig.
Fazit
Auch wenn die Nachteile viel erscheinen, ist das Outbox Pattern für kritische Business-Prozesse, wo garantierte Zustellung essentiell ist, die Investition wert & meist ohne Alternative. Für weniger kritische Szenarien kann After-Transaction ausreichen - sollte aber dennoch mit Vorsicht verwendet werden. Die Komplexität, die du heute akzeptierst, baut die Zuverlässigkeit auf, auf die du morgen angewiesen bist.
Selbst mit dem Outbox Pattern hast du das Distributed Transaction Problem nicht als Ganzes gelöst. Es gibt noch eine fundamentale Herausforderung zu adressieren.
Betrachte, was passiert, wenn der Scheduler im falschen Moment fehlschlägt: Er liest eine Message aus der Outbox, sendet sie an Zeebe, crasht aber bevor er sie als SENT markiert. Wenn der Service neu startet, zeigt diese Message immer noch PENDING. Der Scheduler holt sie ein zweites Mal ab und sendet sie an Zeebe — erneut. Zeebe empfängt dieselbe Message zweimal.
Dies ist At-Least-Once-Delivery — eine Charakteristik verteilter Systeme, wo du garantieren kannst, dass eine Message ankommt, aber nicht, dass sie genau einmal ankommt. Dies ist nicht spezifisch für das Outbox Pattern. Viele Tools funktionieren so, einschließlich Message Brokern wie Kafka.
Also wie gehst du mit Duplikaten um, wenn sie unvermeidlich ankommen?
Die Lösung für dieses Problem heißt Idempotenz. Es ist ein Design-Prinzip, das häufig in verteilten Systemen verwendet wird. Es stellt sicher, dass die mehrfache Ausführung derselben Operation dasselbe Ergebnis erzeugt wie die einmalige Ausführung.
Ohne Idempotenz werden doppelte Messages gefährlich. Ein Abonnent könnte mehrere Willkommensmails erhalten, oder schlimmer — wenn dein Newsletter gebührenpflichtig ist, mehrfach belastet werden. Mit Idempotenz werden Duplikate harmlos. Um es mit einem Beispiel aus der physischen Welt zu veranschaulichen: Denke daran, wie du einen Fahrstuhlknopf drückst: Ob du ihn einmal oder zehnmal drückst, der Fahrstuhl kommt genau einmal zu deiner Etage.
Daher verschiebt sich die Herausforderung davon, alle Duplikate zu verhindern, was in verteilten Systemen unmöglich ist, dazu, sie sicher zu handhaben. Und dies ist durch Design erreichbar.
Bevor wir Idempotenz-Patterns untersuchen, lass uns verstehen, was eine Operation nicht-idempotent macht. Es ist das Gegenteil unserer früheren Definition: eine Operation, bei der wiederholte Ausführung jedes Mal unterschiedliche Ergebnisse erzeugt.
Dies gilt besonders für Operationen, bei denen der neue Zustand dynamisch basierend auf dem aktuellen Zustand berechnet wird — wie das Inkrementieren von Zählern oder das Anpassen von Salden. Stell dir zum Beispiel vor, dass jedes Mal, wenn ein User einen Newsletter abonniert, wir ein Signal veröffentlichen. Ein Worker fängt dieses Signal ab und verwaltet einen Subscription-Counter:
fun incrementSubscriberCount(newsletterId: UUID) {
val newsletter = repository.findById(newsletterId)
newsletter.subscriberCount++ // Non-idempotent!
repository.save(newsletter)
}
Wenn diese Operation zweimal läuft aufgrund eines Fehlers beim Bestätigen des Jobs, erhöht sich der Counter um zwei statt um eins — was deine analytischen Daten korrumpiert. Jede Ausführung ändert den Zustand auf Weisen, die sich zusammensetzen. Deshalb brauchen wir explizite Idempotenz-Patterns.
Operationen idempotent zu machen ist nicht Einheitsgröße für alle. Der richtige Ansatz hängt immer von den Charakteristiken deiner Operation ab. Daher gibt es mehrere Ansätze, wie man dies erreicht. Sie beinhalten:
Ansatz 1: Natürliche Idempotenz
Einige Operationen sind natürlich idempotent — oder können so umgestaltet werden.
Zum Beispiel erzeugt das Setzen eines Subscription-Status auf CANCELLED immer dasselbe Ergebnis, unabhängig vom vorherigen Zustand.
Führe es einmal oder zehnmal aus — das Ergebnis bleibt identisch.
fun cancelSubscription(subscriptionId: UUID, status: SubscriptionStatus) {
val subscription = repository.findById(subscriptionId)
subscription.status = CANCELLED
repository.save(subscription)
}
Dies funktioniert perfekt für reine State-Updates ohne Seiteneffekte — keine Mails, keine externen Service-Calls, keine Events. Aber sobald du Seiteneffekte hinzufügst, bricht natürliche Idempotenz zusammen. Du wirst zwei Mails statt einer senden. Und da die meisten Operationen solche Seiteneffekte haben, hat dieser Ansatz begrenzten Nutzen, was uns zum zweiten Ansatz führt.
Ansatz 2: Die Domain als Idempotenz-Guard verwenden
Die Idee dieses Patterns ist, deine Domain-Objekte zu verwenden, um Idempotenz zu erreichen.
Eine Lösung, die unter diese Kategorie fällt, wäre, ein Flag wie confirmationEmailSent zum Abonnement zu schreiben, um zu tracken, ob die entsprechende Operation bereits ausgeführt wurde.
data class Subscription(
val id: UUID = UUID.randomUUID(),
val email: EMail,
val status: SubscriptionStatus,
val confirmationEmailSent: Boolean = false // Idempotency flag
)
Jedes Mal, wenn der Worker den Task ausführt, egal ob es das erste Mal oder ein Retry ist, können wir dieses Flag prüfen. Wenn es Completion anzeigt, können wir die Operation überspringen:
Wie beim ersten Ansatz ist auch dies unkompliziert. Aber wie bei jedem vorherigen Pattern gibt es auch Nachteile. Jede Operation, die Idempotenz benötigt, braucht ihr eigenes Flag, was dein Domain-Modell mit technischen Belangen statt Business-Logik aufbläht. Dies schwächt das Domain-Design, erzeugt Wartungsaufwand und skaliert nicht.
Ansatz 3: Processed Operations Log
Um dieses Problem zu lösen, kann das dritte Pattern helfen: das Führen eines Logs verarbeiteter Jobs.
Seine Philosophie folgt dem Outbox Pattern.
Anstatt das Domain-Modell mit technischen Flags zu überladen, lagert es die Verantwortung für das Erreichen von Idempotenz aus.
Daher weist es jeder Operation eine eindeutige operationId zu und pflegt eine separate Tabelle, die trackt, welche Operationen bereits ausgeführt wurden.
// Separate table for tracking processed operations
CREATE TABLE processed_operations (
operation_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Bevor irgendeine Operation verarbeitet wird, prüft das System, ob ihre Operation-ID in dieser Tabelle existiert. Wenn ja, wird die Ausführung übersprungen. Wenn nicht, führt es die für den Use-Case erforderlichen Aktionen aus. Schließlich erstellt es einen neuen Eintrag in der Log-Tabelle, um zu erfassen, dass es den Task ausgeführt hat — wobei es erneut ACID-Prinzipien nutzt, um Konsistenz zu gewährleisten. Das liegt daran, dass sowohl der Log als auch die Business-Daten gespeichert werden, oder keines von beiden.
@JobWorker(type = "send-confirmation-email")
fun sendConfirmationEmail(job: ActivatedJob) {
val operationId = job.key.toString()
// Check if already processed
if (processedOperationRepository.existsById(operationId)) {
log.info("Operation $operationId already processed, skipping")
return
}
// Process the job
val subscriptionId = job.variablesAsType(ConfirmationVariables::class.java).subscriptionId
val subscription = subscriptionRepository.findById(subscriptionId)!!
emailService.sendConfirmationEmail(subscription.email)
// Record that we processed this operation
processedOperationRepository.save(
ProcessedOperation(operationId, "send-confirmation-email")
)
}
Also zusammengefasst: Dieser Ansatz hält dein Domain-Modell fokussiert auf Business-Logik, während er ein einzelnes, wiederverwendbares Pattern für alle Worker bereitstellt. Aber am wichtigsten: Da At-Least-Once-Delivery über verteilte Systeme hinweg üblich ist, ist dieser Ansatz nicht auf Zeebe beschränkt. Du kannst dieselbe Tabelle anpassen, um gegen jedes At-Least-Once-Delivery-Szenario zu schützen — wie bei der Verwendung von Kafka. Daher könnte dieses Pattern vielleicht eine deiner Standard-Idempotenz-Strategien sein.
Aber es gibt einen Haken: Das Operation-Log funktioniert nur gut, wenn dein Worker exklusiv in ein System schreibt: Deine Datenbank. Wenn du jedoch das Beispiel von oben nochmal betrachtest, bemerkst du vielleicht, dass der Worker mehrere Systeme koordiniert: Er schreibt in die Datenbank und ruft einen Mail-Service auf.
Wenn der Worker nach dem Aufruf des Mail-Service crasht, aber bevor das Log committed wird, wird die Mail zweimal gesendet. Bei einem Retry kann das Log dagegen nicht schützen, weil es nicht weiß, dass die Mail gesendet wurde. Entsprechend taucht das Distributed Transaction Problem wieder auf. Um dies zu lösen, könntest du eine weitere Outbox für den Call zum Mail-Service hinzufügen. Aber das führt wieder zu denselben At-Least-Once-Delivery-Herausforderungen.
Dies führt zur Schlussfolgerung, dass Idempotenz auf beiden Seiten jeder Interaktion existieren muss. Deine Worker brauchen sie, und auch die Services, die sie aufrufen — einschließlich unserer Engine. Und deshalb müssen wir untersuchen, was Zeebe auf der Empfängerseite bietet.
Zeebe bietet spezifische Features, die die Orchestrierungs- und Idempotenz-Patterns ergänzen, die wir behandelt haben. Wenn du sie durchdacht anwendest und mit den bereits erwähnten Patterns kombinierst, kannst du wirklich resiliente Systeme bauen.
Man könnte annehmen, dass Zeebes Message-Correlation bereits Idempotenz bietet. Und in einigen Szenarien imitiert es ihr Verhalten — aber es ist keine Garantie. Wenn du eine Message veröffentlichst, akzeptiert Zeebe sie immer und versucht, sie mit einer wartenden Prozessinstanz unter Verwendung eines Correlation-Keys und Message-Namens zu korrelieren. Daher hängt das Ergebnis von deinem Prozess-Design ab:
Intermediate Message-Catch Events: Wenn eine Prozessinstanz bei einem Message-Catch-Event wartet, korreliert die erste Message und bringt den Prozess voran. Wenn danach ein Duplikat ankommt, gibt es kein wartendes Event mehr — also verwirft Zeebe es. Dies imitiert Idempotenz und erzeugt ein sicheres Ergebnis, aber es ist nur glückliches Timing.
Message-Start-Events und Event-Subprozesse: Anders als Intermediate Events profitieren diese nicht von diesem Timing-basierten Schutz. Jede Message erzeugt neue Arbeit — startet eine neue Prozessinstanz oder triggert einen Subprozess. Duplikate werden nicht verworfen; sie multiplizieren deine Business-Logik-Ausführung.
Die wichtige Einsicht: Zeebes Correlation geht es um das Routing von Messages an die richtigen Instanzen, nicht um das Verhindern von Duplikaten. Es fragt nicht “habe ich diese Business-Message schon gesehen?” Es fragt nur “gibt es gerade ein Event, das wartet?” Also brauchst du für echte Deduplikation einen anderen Mechanismus.
Daher bietet Zeebe einen anderen Mechanismus jenseits von Correlation: messageIds.
Dies ist eine optionale Property, die du beim Veröffentlichen von Messages hinzufügen kannst.
Sie beruht darauf, dass Zeebe alle empfangenen Messages in einem Buffer speichert.
Wenn eine neue Message ankommt, prüft die Engine, ob diese messageId bereits im Buffer existiert.
Wenn ja, lehnt sie das Duplikat ab.
fun sendSubscriptionMessage(subscriptionId: UUID) {
val messageId = "subscription-created-$subscriptionId"
zeebeClient.newPublishMessageCommand()
.messageName("subscription-created")
.correlationKey(subscriptionId.toString())
.messageId(messageId) // Deduplication key
.variables(mapOf("subscriptionId" to subscriptionId))
.timeToLive(Duration.ofSeconds(10))
.send()
.join()
}
Bevor du dich jedoch allein auf dieses Feature verlässt, gibt es etwas Entscheidendes, das du verstehen musst: Laut seiner Dokumentation zu Message Uniqueness behält die Engine Messages nur für eine begrenzte Zeit in ihrem Buffer — die sogenannte Time-to-Live (TTL) der Message.
Nur während dieses Zeitfensters wird sie Messages ablehnen, die dieselbe messageId teilen wie eine bereits im Buffer befindliche.
Sobald die TTL abläuft und die Message den Buffer verlässt, wird Zeebe Messages mit derselben messageId erneut akzeptieren.
Das bedeutet, die Property bietet nur kurzfristige Deduplikation — was typischerweise Sekunden sind.
Es ist kein langfristiger Idempotenz-Schutz.
Eine Strategie zur Begrenzung von Problemen
Dennoch würde ich empfehlen, messageIds zu verwenden.
Sie sind eine effektive Lösung zum Filtern von Duplikaten während des kritischen kurzfristigen Zeitfensters — wenn beispielsweise Retries durch das Outbox Pattern mehrere Messages in kurzer Zeit veröffentlichen.
Du solltest es jedoch immer mit anderen Patterns wie idempotenten Workern für langfristige Sicherheit kombinieren.
Dieser defensive Ansatz gibt dir das Beste aus beiden Welten.
Neben den technischen Features von Zeebe spielt auch die Art, wie du deine BPMN-Prozesse modellierst, eine wichtige Rolle beim Aufbau von robusten Systemen. Es gibt verschiedene Modellierungsprinzipien, die helfen können — zwei möchte ich besonders hervorheben:
Nutze unterbrechende Boundary Events mit Bedacht
Boundary Events sind mächtige Modellierungskonstrukte, aber sie sollten mit Vorsicht eingesetzt werden. Beschränke ihre Nutzung auf Szenarien, wo eine Unterbrechung tatsächlich deine Business-Logik widerspiegelt. Wenn du unsicher bist, überlege, ob du das Event nicht nach dem Element modellieren kannst, statt es zu unterbrechen.
Vor allem solltest du unterbrechende Boundary Events nicht an Elementen anbringen, die keinen expliziten Wait State haben und bei denen du nicht kontrollierst, wann das Event ausgelöst wird. Das ist besonders problematisch bei Service Tasks — vor allem bei lang laufenden mit Boundary Events, die durch Timer oder Messages von außerhalb des Task-Scopes ausgelöst werden. Es spielt keine Rolle, ob das Event am Task selbst oder an einem übergeordneten Element wie einem Subprocess modelliert ist. In beiden Fällen bist du einem kritischen Risiko ausgesetzt:
Wenn das Event feuert, wird der Job abgebrochen, aber dein Worker hat vielleicht schon mit der Verarbeitung begonnen. Da der Job nicht mehr aktiv ist, kann der Worker die Completion nicht bestätigen. Das führt genau zu den Koordinationsproblemen, die wir zuvor besprochen haben und lässt deine Systeme in inkonsistenten Zuständen zurück.
Ein sichererer Ansatz ist, unterbrechende Boundary Events hauptsächlich auf Elementen mit expliziten Wait States zu verwenden. Das sind vor allem Message Receive Tasks und User Tasks. Diese Elemente pausieren die Ausführung und warten auf externe Eingaben, was bedeutet, dass wir kontrollieren, wann die Completion erfolgt. Wir haben explizite Use Cases, die explizite Commands an die Engine senden.
Wenn ein unterbrechendes Event bei so einem Element feuert, wird der Task abgebrochen. Wenn wir dann versuchen, ihn zu completen, wirft Zeebe einen Fehler. Soweit ist alles gleich wie bei Service Tasks. Aber hier ist der entscheidende Unterschied: Da wir die Completion explizit auslösen, können wir diesen Fehler in unserem Use Case abfangen und angemessen behandeln — zum Beispiel, indem wir die zugehörige Business-Transaktion ablehnen. Das macht die Unterbrechung vorhersehbar und gibt uns Kontrolle, um Inkonsistenzen zu vermeiden.
Aber auch dieser Ansatz ist nicht kugelsicher. Wenn dein Use Case in mehrere Systeme schreibt — wie Zeebe und deine Datenbank — stehst du immer noch vor der Koordinierungsherausforderung und ihren Problemen. Hier wird unser zweites Modellierungsprinzip wichtig.
Splitte Tasks, die mehrere Systeme koordinieren, in separate Tasks
Wenn ein einzelner Use Case deine Datenbank aktualisieren und eine Message an Zeebe senden muss, hast du ein Distributed Transaction Problem innerhalb dieses Tasks geschaffen. Statt sie so zu lassen, modelliere sie als separate Tasks in deinem Prozess. Lass jeden Task ein System handhaben. Wenn einer fehlschlägt, kann der Prozess diesen spezifischen Step retrying, ohne die anderen zu beeinflussen.
Dieser granulare Ansatz erhöht die Resilienz deines Prozesses und vereinfacht die Implementierung der Idempotenz-Patterns, die wir besprochen haben. Aber beachte, dass es die technische Komplexität deines Prozessmodells erhöht und nicht immer funktioniert. Wenn es nicht funktioniert, kann das ein Hinweis darauf sein, dass du ein Pattern wie Outbox brauchst.
Selbst mit Outbox Patterns, Idempotenz und vorsichtigem Boundary Event Modeling bleiben manche Szenarien unlösbar durch reine Prävention. Manchmal musst du bereits committete Arbeit rückgängig machen — während der Prozess noch läuft.
Stell dir einen erweiterten Newsletter-Flow vor: Ein User abonniert einen Premium Gaming Newsletter mit limitierten Plätzen. Dein Prozess reserviert einen Platz in der Subscriber Quota, sendet die Confirmation Email und verarbeitet dann die Payment. Aber die Payment schlägt fehl. Zu diesem Zeitpunkt hast du bereits den Platz reserviert und die Email gesendet — beides in ihre jeweiligen Systeme committed. Den Prozess einfach fehlschlagen zu lassen, hinterlässt einen inkonsistenten Zustand: einen reservierten Platz für einen nicht zahlenden User, der legitime Subscriber blockiert.
Hier wird BPMN Compensation wichtig. Du würdest ein Compensation Boundary Event an den Payment Task anhängen, das Compensation Handler triggert, wenn Payment fehlschlägt. Diese Handler führen deine “Undo”-Logik aus: den reservierten Platz im Quota-System freigeben und den Subscription-Status aktualisieren. Der Prozess kann dann ordentlich enden oder den User benachrichtigen zu retrying, aber dein System bleibt konsistent — der Platz ist verfügbar für andere.
Der Punkt: Compensations verhindern keine Distributed Transaction Problems. Sie bieten explizite Rollback-Pfade für Szenarien, wo Prevention nicht ausreicht, und halten deine Business-Logik konsistent, selbst wenn Downstream-Steps fehlschlagen.
Dieser Ansatz — Koordination von verteilten Operationen durch lokale Transaktionen mit Compensation-Logik — hat einen Namen: das SAGA Pattern. SAGA akzeptiert die Realität, dass du keine atomaren Transaktionen über Services hinweg haben kannst. Stattdessen orchestriert es eine Sequenz von lokalen Transaktionen, wo jeder Step entweder bis zum Erfolg retrying kann (Forward Recovery) oder Compensations triggert, um vorherige Arbeit rückgängig zu machen (Backward Recovery).
Zeebe ist wie geschaffen für die Implementierung von SAGA als Orchestration Pattern. Dein BPMN-Prozess wird zum SAGA Coordinator, Service Tasks repräsentieren lokale Transaktionen und Compensation Handler definieren deine Rollback-Logik. Das macht lang laufende Multi-Service-Transaktionen handhabbar ohne Distributed Locks oder Two-Phase Commits — und lässt dich resiliente Workflows bauen, die partielle Failures graceful handhaben.
Die Patterns, die wir abgedeckt haben — Outbox, Idempotenz, Compensations und SAGA — bilden ein umfassendes Toolkit für Distributed Transactions mit Zeebe. Für Implementierungsdetails und Deep Dives in diese Patterns, schau dir diese Ressourcen vom Camunda Team an:
Wir haben viel Terrain abgedeckt, vom Verstehen des Distributed Transaction Problems bis zur Implementierung von Patterns wie Outbox und Idempotenz. Lass uns nun alles zusammenbringen und seine Implikationen für dein Team und deine Systeme klären.
Dieser Artikel macht es leicht zu glauben, dass verteilte Systeme problematisch sind — mit Herausforderungen wie dem Distributed Transaction Problem und Lösungen, die wir früher nicht (oder selten) brauchten. Aber lassen wir uns nicht täuschen. Architekturentscheidungen waren immer Trade-offs und werden es immer sein.
Der Unterschied bei verteilten Systemen: Wir haben jetzt mehr Trade-offs zu navigieren. Im Gegenzug dafür, dass wir Herausforderungen wie Koordinationskomplexität akzeptieren, gewinnen wir Vorteile: Skalierbarkeit, Resilienz, cloud-natives Deployment und unabhängige Service-Evolution — Vorteile, die für die meisten Unternehmen unverzichtbar sind.
Die Frage ist nicht, ob verteilte Systeme gut oder schlecht sind — sondern, ob diese Trade-offs zu deinem Kontext passen. Das musst du objektiv beurteilen.
Mit diesem Verständnis können wir zum Kern dieses Artikels kommen: Das Distributed Transaction Problem ist nicht Zeebe-spezifisch. Es ist eine grundlegende Eigenschaft verteilter Systeme. Ob du mit Zeebe, Kafka oder einem anderen Remote-Service koordinierst - du begegnest immer denselben Herausforderungen.
Das sollte dich nicht davon abhalten, Zeebe zu verwenden. Es bleibt eine exzellente Wahl für Prozess-Orchestrierung und löst unzählige Use Cases. Wie jede Architekturentscheidung bringt es Vorteile und Herausforderungen. Der Schlüssel: Akzeptiere seine Natur und wende effektive Patterns an — während du sicherstellst, dass dein gesamtes Team dieses Verständnis teilt.
Die technischen Patterns, die wir besprochen haben, sind wichtig, aber sie werden ohne organisatorisches Buy-in nicht erfolgreich sein. Jeder in deinem Team muss die oben beschriebene Realität verstehen:
Dies ist nicht nur ein Entwickler-Problem. Es ist ein Team-Problem. Führe Brown-Bag-Sessions durch. Teile konkrete Beispiele aus deiner Domain. Mache das Unsichtbare sichtbar.
Sobald dein Team das Distributed Transaction Problem versteht, ist der nächste Schritt, systematisch Lösungen zu implementieren. Warte nicht darauf, dass Production-Fehler dich dazu zwingen. Die Patterns, die wir behandelt haben, geben dir die Tools — jetzt musst du sie strategisch anwenden:
Eine einzelne Outbox-Implementierung kann alle deine kritischen Workflows bedienen. Ein Processed-Operations-Log-Pattern kann alle deine Worker schützen — und potenziell auch deine Kafka-Events. Die Vorabinvestition zahlt sich über dein gesamtes System aus.
Über einzelne Patterns hinaus gibt es ein breiteres Prinzip: Mache Idempotenz zu einem grundlegenden Design-Prinzip für alle deine Services, nicht zum Nachgedanken. Jeder Worker, jeder Service, jede Integration sollte Duplikate sauber handhaben.
Es geht nicht darum, Komplexität hinzuzufügen — sondern darum, Zuverlässigkeit von Anfang an in deine Architektur einzubauen.
Zu guter Letzt reicht es nicht aus, nur Patterns zu implementieren — du musst verifizieren, dass sie funktionieren und erkennen, wenn sie fehlschlagen. So gehst du vor:
Dein Monitoring und Testing muss sich zusammen mit deiner Architektur entwickeln.
Um diesen Blogartikel und meinen Talk zu diesem Thema zu ergänzen, habe ich ein GitHub Repository erstellt. Es enthält:
Clone es, führe die Beispiele aus, bring Dinge zum Brechen und schau, was passiert. Der beste Weg, Distributed Transactions zu verstehen, ist, die Probleme und Lösungen aus erster Hand zu erleben.
Dies ist ein komplexes Thema, das von Community-Wissen profitiert. Welchen Herausforderungen begegnest du mit Distributed Transactions in deinen Systemen? Welche Patterns haben — oder haben nicht — für dich funktioniert? Hast du einen besseren Ansatz gefunden? Hast du Fragen? Oder auch Szenarien erlebt, die hier nicht behandelt wurden?
Ich würde gerne von deinen Erfahrungen hören und von deinen Ansätzen lernen. Teile deine Gedanken in den Kommentaren, öffne ein Issue auf GitHub oder kontaktiere mich direkt.
Dieser Artikel wäre ohne die vielen Gespräche, die ich geführt habe, nicht möglich gewesen. Ich bin allen dankbar, die sich die Zeit genommen haben, dieses herausfordernde Thema mit mir zu diskutieren — eines, bei dem im Kontext von Process-Engines umfassende Informationen relativ selten sind.
Besonderer Dank geht an Dominik Horn und Stephan Pelikan für ihren wertvollen Input. Eure Perspektiven haben sowohl das Repository als auch diesen Artikel mitgeformt.
Und an die Person, die dies genau in diesem Moment liest: Danke, dass du dir die Zeit genommen hast. Ich hoffe, dass, auch wenn dies ein ziemlich langer Artikel war, du das Lesen genossen hast und er dir hilft, resilientere verteilte Systeme zu bauen.
Rechtliches
