Transaktionen in Mule

Keine Kommentare

Integrationsplattform und Transaktionen: Klingt nach einer gefährlichen, komplexen Kombination. Hat man dort nicht einen Zoo verschiedener Technologien, die von transaktionslos bis XA-Transaktionen alles bieten? Ich stelle heute einige Patterns vor, die einfach sind und dennoch zum Ziel führen.

HTTP und REST

Zum Einstieg eine bekannte Technik: REST-Services per HTTP. Klingt nicht nach Transaktionen, oder? Tatsächlich kann man mit REST-Services sehr wohl die gleiche Funktionalität wie mit Transaktionen erreichen, nur etwas anders als mit begin/commit und einem großen Lock. Es läuft eher optimistisch ab, so wie es OR-Mapper (wie z.B. Hibernate) hinter den Kulissen typischerweise auch handhaben.

Die erste Zutat ist ETag, ein Header-Feld, das seit HTTP 1.1 existiert. Es wird bei HTTP in der Antwort mitgeliefert und enthält eine Versionsinformation. Das Format ist nicht vorgegeben, es kann ein beliebiger String sein. Wichtig ist nur, dass es sich bei jeder Änderung des Inhalts auch ändert. Neben einer Versionsnummer eignet sich auch ein Hash-Wert als ETag.

Für das folgende Beispiel nutze ich eine Versionsnummer als ETag, die direkt aus der Datenbank stammt. Die einfache Beispieltabelle enthält eine ID (Integer, auto generated), einen VALUE (String) und die Versionsnummer (Integer).

GET – Daten lesen

Ein GET-Flow, der eine Zeile im JSON-Format liefert, sieht damit folgendermaßen aus:

get flow

Flow zum Lesen eines Objekts

Den Choice-Router hinter dem Select benötigen wir für den Fall, dass die übergebene ID ungültig ist, dann geben wir 404 zurück. Im anderen Fall wissen wir, dass der ResultSet genau eine Zeile enthält (ID ist primary key), also holen wir uns diese Zeile, speichern die Version in der Property ETag und wandeln das Ergebnis per DataWeave nach JSON um. So sieht es im XML aus:

<flow name="get:/values/{id}:api-config">
    <db:select config-ref="local-derby" doc:name="Database">
        <db:parameterized-query><![CDATA[select id, version, value
            from id_value
            where id = #[flowVars.id]]]></db:parameterized-query>
    </db:select>
    <choice doc:name="size == 1?">
        <when expression="#[payload.size() == 1]">
            <set-payload value="#[payload[0]]" doc:name="[0]"/>
            <set-property propertyName="ETag" value="#[payload.VERSION]" doc:name="ETag"/>
            <dw:transform-message doc:name="Transform Message">
                <dw:set-payload resource="classpath:dwl/create-get-result.dwl"/>
            </dw:transform-message>
        </when>
        <otherwise>
            <set-property propertyName="http.status" value="404" doc:name="404"/>
            <set-payload value="{ &quot;status&quot;: &quot;no found&quot; }" doc:name="not found"/>
        </otherwise>
    </choice>
</flow>

Was man noch hätte machen können: In der Anfrage If-Not-Match auswerten und ggf. ein 304 (not modified) statt des JSON-Dokuments zurückliefern. Das lohnt sich aber nur bei größeren Dokumenten.

PUT – Daten ändern

Interessanter wird das ETag, wenn ein Client den Inhalt verändern möchte. Wie sieht ein typischer Ablauf aus? Der Client lädt den Inhalt per GET vom Server (mit ETag) und merkt sich das ETag. Ein Anwender (oder Programm) ändert jetzt Werte (lokal) und schickt die geänderten Werte zum Server. Machen das zwei Clients parallel, würde ohne besondere Maßnahmen der zweite Client die Änderungen des ersten überschreiben (lost update problem).

Dagegen helfen zwei Standardverfahren:

  1. Der Client muss vor Veränderungen sperren (pessimistisches Locking). Funktioniert, blockiert aber und ist problematisch, falls ein Client den Lock nicht wieder freigibt (Absturz, Netzwerkfehler etc.).
  2. Der Client sperrt nicht, bekommt aber eine Fehlermeldung, wenn seine Änderung mit der eines anderen Clients kollidiert (optimistisches Locking). Er muss sich dann die neueste Version holen, seine Änderungen nochmals vornehmen und es dann wieder versuchen.

Das letzte Verfahren kennt vermutlich jeder Entwickler von seiner Versionsverwaltung: Bei parallelen Änderungen muss man erst die Änderungen des lieben Kollegen mit seinen eigenen zusammenführen (merge).

In unserem Fall sieht die Lösung ähnlich aus und stützt sich auf den HTTP-Header If-Match (siehe HTTP-Headerfelder). Der Client schickt seine geänderten Daten (im Body) und einen If-Match-Header. Die Versionsnummer aus dem Header zieht man bis zum Update-Statement durch. So sieht der Flow aus:

put flow

Flow zum Aktualisieren eines Objekts

Im XML sieht man das Update-Statement, das in der Where-Bedingung sowohl den Primary Key als auch die Versionsnummer enthält:

<flow name="put:/values/{id}:api-config">
        <dw:transform-message doc:name="Transform Message">
        <dw:set-payload resource="classpath:dwl/parse-put.dwl"/>
        </dw:transform-message>
        <db:update config-ref="local-derby" doc:name="Database">
        <db:parameterized-query><![CDATA[update id_value
            set version = #[payload.version + 1], value = #[payload.value]
            where id = #[payload.id] and version = #[payload.version]]]></db:parameterized-query>
    </db:update>
    <choice doc:name="updated?">
        <when expression="#[payload == 1]">
            <set-payload value="{ &quot;status&quot;: &quot;ok&quot; }" doc:name="status: ok"/>
        </when>
        <otherwise>
            <set-property propertyName="http.status" value="412" doc:name="412 - precondition failed"/>
            <set-payload value="{ &quot;status&quot;: &quot;precondition failed&quot; }" doc:name="status: not ok"/>
        </otherwise>
    </choice>
</flow>

Hier noch das DataWeave-Script zum Parsen des JSON-Dokuments im Body und der Versionsnumer im Header:

%dw 1.0
%output application/java
---
{
  version: inboundProperties."if-match" as :number,
  id: flowVars.id,
  value: payload.value
}

Das Update-Statement gibt die Anzahl der geänderten Zeilen zurück. Stimmte die Versionsnummer, ist es genau eine Zeile. Stimmt sie nicht (oder gibt es die Zeile überhaupt nicht, weil die ID falsch ist), ist der Wert 0, und wir beantworten den HTTP-Request mit 412 (precondition failed).

An die SQL-Statements habe ich sowohl beim GET als auch beim PUT keine Transaktion spezifiziert. Das bedeutet nicht, dass keine Transaktion existiert (bei SQL ist eigentlich immer eine Transaktion im Spiel). Es bedeutet, dass Mule eine Transaktion nur für dieses Statement beginnt und sofort wieder beendet. Dies geschieht dadurch, dass die SqlConnection im Zustand auto-commit belassen wird (der default für JDBC, siehe Oracle-Dokumentation).

Insgesamt sind wir damit transaktional sicher. Wir haben allerdings dem Client den schwarzen Peter zugeschoben. Bei Kommunikationsfehlern kann er den PUT einfach wiederholen. Komplizierter wird es bei Konflikten. Dann muss er entscheiden, wie er mit zwischenzeitlich geänderten Daten umgeht.

POST – Daten anlegen

Folgt man dem HTTP-Standard, kann man Daten sowohl mit PUT als auch mit POST anlegen. PUT funktioniert nur dann sinnvoll, wenn die ID vom Client vergeben wird und er sie damit in der URL angeben kann. Ansonsten führt man einen POST auf der übergeordneten Collection durch und lässt die ID vom Server generieren. Der muss sie natürlich an den Client zurückgeben. Diesen Weg habe ich im Beispiel gewählt:

post flow

Flow zum Anlegen eines Objekts

Erst im XML (oder im Property-Editor der DB-Komponente) sieht man, wie die von der Datenbank generierte ID den Weg zum Client findet:

<flow name="post:/values:api-config">
    <dw:transform-message doc:name="map [version, value]">
        <dw:set-payload resource="classpath:dwl/parse-post.dwl"/>
    </dw:transform-message>
    <enricher doc:name="Message Enricher" target="#[flowVars.generatedId]" source="#[payload[0]['1']]">
        <db:insert config-ref="local-derby" autoGeneratedKeys="true" autoGeneratedKeysColumnNames="id" doc:name="Database">
            <db:parameterized-query><![CDATA[insert into id_value (version, value)
                values (#[payload.version], #[payload.value])]]></db:parameterized-query>
        </db:insert>
    </enricher>
    <set-property propertyName="ETag" value="#[payload.version]" doc:name="ETag"/>
    <dw:transform-message doc:name="json result">
        <dw:set-payload resource="classpath:dwl/create-post-result.dwl"/>
    </dw:transform-message>
    <set-property propertyName="http.status" value="201" doc:name="201 - created"/>
</flow>

Beim db:insert wird die Option autoGeneratedKeys="true" autoGeneratedKeysColumnNames="id" gesetzt. Das führt dazu, dass nach dem Insert eine Liste mit einem Eintrag für jede angelegte Zeile in der Payload steht. Die Listenelemente sind Maps mit den generierten Keys. Leider sind die Keys in der Map nur Spaltennummern als String (Namen wären schöner), daher steht im Message-Enricher source="#[payload[0]['1']]". Mit Hilfe des Message-Enrichers schreiben wir die generierte ID in eine Flow-Variable, so dass die ursprüngliche Payload erhalten bleibt.

Welche Probleme können beim POST auftreten? – Auf der Datenbank kaum welche; wir haben wieder ein Statement, das in seiner eigenen Transaktion abläuft (auto-commit). Der Key wird von der Datenbank generiert, PK-Verletzungen sind damit ausgeschlossen. Bleiben Kommunikationsfehler, ungültige Werte oder Server-Crash (Mule oder DB) als Gründe dafür, dass der Client keine Antwort bekommt (oder eine Fehlermeldung).

Wie der Client reagieren soll, ist wieder von den fachlichen Anforderungen abhängig. Er kann den POST nochmals absetzen, dabei besteht jedoch das Risiko, dass Datensätze doppelt angelegt werden. Will man das vermeiden, muss man die Daten (z.B. mit Hilfe von fachlichen Schlüsseln) durchsuchen. Nun lässt sich erkennen, dass der POST trotz Fehlermeldung oder Timeout erfolgreich war. (Wenn auf dem Rückweg ein Fehler passiert ist.)

Zusammenfassung der REST-Lösung: Einfach, nutzt existierende Standards, erfordert aber unter Umständen zusätzliche Arbeit auf dem Client. Wenn ein Mensch die Daten eingibt/ändert, kann man die Arbeit an ihn delegieren. Oft ist mit gesundem Menschenverstand im Einzelfall einfacher zu entscheiden, was die richtige Reaktion auf einen Fehler ist, als die Programmlogik komplexer zu machen.

HTTP – aber mit mehreren Statements

In den bisherigen Beispielen wurde nur ein DB-Statement ausgeführt. Wie sieht es aber aus, wenn man mehrere Statements benötigt? Zieht man mehrere DB-Connectoren in den Flow und lässt alles auf den Default-Einstellungen, erhält man vermutlich nicht das gewünscht Verhalten: Die Transaktionskonfiguration steht auf JOIN_IF_POSSIBLE. Da aber keine Transaktion für den JOIN zur Verfügung steht, wird für jedes Statement eine eigene Connection (mit auto-commit) geholt und nach Ausführung des Statements wieder zurückgegeben. Jedes Statement läuft damit auch in einer eigenen Transaktion. Das hat zwei Nachteile:

  1. Connections zu holen und wieder freizugeben ist relativ teuer (auch mit einem Connection-Pool), man sollte es nur so oft wie nötig machen.
  2. Die Statements laufen in getrennten Transaktionen. Man verschenkt damit die Transaktionsisolation, die eine SQL-Datenbank serienmäßig bietet.

Um dies zu verhindern, platziert man einen Transactional-Scope in den Flow und zieht dort alles hinein, was in einer Transaktion zusammengefasst werden soll. Das sieht dann folgendermaßen aus:

simple-begin-or-join

Flow mit mehreren DB-Statements in Transactional-Scope

Jetzt kommen aber noch ein paar weitere Einstellungen dazu. Die einzelnen DB-Connectoren kann man auf JOIN_IF_POSSIBLE belassen, wir haben ja jetzt eine Transaktion, in die sie sich per JOIN einklinken können. Dafür haben wir jetzt am Transactional-Scope noch zwei weitere Einstellungen: Den Type und die Action. Beim Type können wir zwischen Simple-, Multi- und XA-Transaction wählen. Wenn wir im Mule-Projekt nur Datenbanktransaktionen nutzen, können wir es auf „Simple“ lassen (später mehr dazu). Im zweiten Dropdown besteht die Wahl zwischen ALWAYS_BEGIN und BEGIN_OR_JOIN. Hier sollte man vom Default abweichen und BEGIN_OR_JOIN wählen. Der Grund dafür ist eine höhere Flexibilität beim Kombinieren von Flows: Ein Flow mit BEGIN_OR_JOIN kann von einem anderen Flow mit oder ohne offener Transaktion aufgerufen werden. Wird er ohne offene Transaktion aufgerufen, öffnet/schließt er einfach eine eigene Transaktion.

Queues und Messages

Die Welt besteht nicht nur aus synchronem HTTP und Datenbanken. In Integrationsprojekten ist oft auch eine asynchrone Kommunikation über Messages gefordert. Nebenbei führt sie zu einer besseren Entkoppelung der beteiligten Systeme. Mule bietet Messaging in verschiedenen Varianten:

  • Interne VM-Queues, die innerhalb einer Mule-App (oder einer Mule-Domain, wenn die Apps auf dem selben Mule-Server laufen) genutzt werden können.
  • Mulesofts eigenes Cloud Messaging System Anypoint MQ, auf das per HTTP zugegriffen wird.
  • JMS-Queues von einem beliebigen, standardkonformen JMS-Provider. Für einige Provider bietet Mule einen erweiterten Connector mit herstellerspezifischen Features an (z.B. Websphere MQ).
  • Connectoren für weitere Messaging Provider bzw. Protokolle wie Kafka oder MQTT.

Die Systeme unterscheiden sich bezüglich ihrer Features und insbesondere darin, ob und wie sie Transaktionen unterstützen. Ich werde hier nur auf VM und JMS detaillierter eingehen, da sie Transaktionen unterstützen. Das Haupteinsatzgebiet für Messaging-Systeme sind zuverlässige asynchrone Übertragung von Daten. Sie unterstützen zwar auch Request/Response-Kommunikation, also im Endeffekt Remote Procedure Calls, auf diese werde ich jedoch nicht weiter eingehen, da dafür Protokolle wie HTTP deutlich effizienter sind.

Messages mit und ohne Transaktion empfangen

Will man mit einem VM- oder JMS-Endpoint Messages empfangen, so muss er im Source-Bereich eines Flows stehen. Jede Nachricht startet eine Flow-Ausführung. Damit der Flow asynchron läuft, belassen wir das Exchange Pattern auf dem Default „one-way“ (statt request-response).

Mit dem Drop-Down „Type“ lässt sich jetzt einstellen, welche Transaktionsart Mule verwendet:

  • No Transaction (default): Führt dazu, dass die Nachricht sofort commited und der Rest des Flows asynchron in einem getrennten Thread-Pool ausgeführt wird.
  • Client Ack Transaction: Nur bei JMS, es wird eine ACK-Nachricht an den Absender verschickt.
  • VM bzw. JMS Transaction: Es wird eine Single Resource Transaction auf dem VM/JMS-Provider gestartet, der Rest des Flows wird synchron ausgeführt. Eine Exception führt zu einem rollback, ansonsten erfolgt ein commit am Ende des Flows.
  • Multi Resource Transaction: Der Flow läuft wie im letzten Fall synchron, es können im Flow jedoch noch andere transactional Resources genutzt werden (später mehr dazu).
  • XA Transaction: Wie Multi-resource, allerdings mit echten XA-Transaktionen.

Der wesentliche Unterschied besteht zwischen No Transaction und Transaction (egal, welche Transaktionsart genutzt wird). Ohne Transaktionen arbeitet Mule mit getrennten Thread-Pools: Der erste ist dafür zuständig, Nachrichten zu empfangen und an den zweiten zu übergeben. Die Nachrichten werden nach dem Empfang sofort committed, die Ausführung des restlichen Flows findet im zweiten Thread-Pool statt. Mit diesem Vorgehen erreicht man den höchsten Durchsatz, verliert jedoch Zuverlässigkeit: Tritt in dem Flow ein Fehler auf, geht die Nachricht verloren, da der commit asynchron im vorigen Thread stattfindet.

Schaltet man dagegen Transaktionen ein (egal, welche Art), so wird nur ein Thread-Pool genutzt: Der Flow wird in dem Thread ausgeführt, der auch die Nachricht empfängt. Tritt in dem Flow eine Exception auf, wird automatisch ein Rollback auf dem Queue-Provider ausgeführt, so dass die Nachricht in die Queue zurückgestellt wird und nicht verloren geht.

Ich werde später auf die verschiedenen Varianten nochmals zurückkommen, relevant ist hier: Außer bei „No Transaction“ läuft der Flow synchron ab, der commit erfolgt nur dann, wenn der Flow erfolgreich war. Im Fehlerfall geht eine Nachricht also nicht verloren.

Vergiftete Nachrichten und Error-Queues

Die spannende Frage ist: Was passiert mit einer Nachricht, die im Laufe der Verarbeitung zu einer Exception geführt hat? Kommt drauf an… Es liegt teilweise in der Konfiguration des (externen) Queue Managers (ActiveMQ, WebsphereMQ etc.), teilweise am Flow. JMS-Provider unterstützen gewöhnlich Error-Queues. Alle Nachrichten, die eine Exception auslösen, werden in dem Fall in die Error-Queue umgeleitet (durch den rollback von Mule). Es kann eine globale Error-Queue oder mehrere Error-Queues geben, z.B. eine eigene für jede Queue. Die Nachrichten können somit einer gesonderten Verarbeitung (auch manuell) zugeführt werden oder später wieder in die ursprüngliche Queue verschoben werden.

Arbeitet man nicht mit Error-Queues auf Provider-Ebene, muss man in Mule eine Exception-Strategie etablieren. Ansonsten kann es zu einer Endlosschleife durch eine sogenannte „vergiftete Nachricht“ kommen: Eine Nachricht, die wiederholt den gleichen technischen oder fachlichen Fehler auslöst, wird in die Queue zurückgestellt und sofort wieder verarbeitet. Bis die CPU durchbrennt oder jemand den Stecker zieht.

Geht man davon aus, dass nur reproduzierbare Fehler vorkommen, kann man direkt nach dem ersten Fehlversuch abbrechen. Arbeitet man jedoch mit wackligen Partnern, die ab und zu wegen technischer Probleme ausfallen, sind mehrere Versuche eine gute Idee. Dafür bietet Mule die Rollback-Exception-Strategie. Sie bietet zwei „Fächer“, in die man weitere Message-Prozessoren ziehen kann: Der obere Bereich wird immer ausgeführt, wenn ein Fehler aufgetreten ist. Wenn auch der letzte Versuch fehlgeschlagen ist, wird zusätzlich der untere Bereich () ausgeführt. Die Anzahl der Wiederholungsversuche lässt sich einstellen. Über dieses Konstrukt lassen sich Fehler ins Log schreiben und – falls alle Versuche scheitern – die Nachricht in einer Error-Queue ablegen. Das Konzept der Error-Queues lässt sich somit auch nutzen, wenn der Messaging-Provider es überhaupt nicht unterstützt (also auch mit den VM-Queues von Mule). Diese Variante ist aber etwas unsicherer: Tritt im Exception-Handler eine Exception auf, committed Mule den Empfang der Nachricht trotzdem, die Nachricht geht also verloren.

Message Driven Flow mit Rollback-Exception-Strategie

Message Driven Flow mit Rollback-Exception-Strategie

Hier noch die XML-Darstellung:

<flow name="queue-to-db-flow">
    <vm:inbound-endpoint exchange-pattern="one-way" path="myqueue" doc:name="myqueue">
        <ee:multi-transaction action="ALWAYS_BEGIN"/>
    </vm:inbound-endpoint>
    <db:insert config-ref="Derby_Configuration" doc:name="Database">
        <db:parameterized-query><![CDATA[insert into data (id, value) values (1, 'some stuff')]]></db:parameterized-query>
    </db:insert>
    <logger message="BAM!" level="INFO" doc:name="BAM!"/>
    <validation:is-true message="BAM!" expression="#[false]" doc:name="Validation Fail"/>
    <rollback-exception-strategy maxRedeliveryAttempts="0" logException="false" doc:name="Rollback Exception Strategy">
        <logger message="Rollback" level="INFO" doc:name="Rollback"/>
        <on-redelivery-attempts-exceeded>
            <logger message="Exhausted" level="INFO" doc:name="Exhausted"/>
        </on-redelivery-attempts-exceeded>
    </rollback-exception-strategy>
</flow>

Multi Resource Transaction

Bis hier hatten wir im Flow immer nur ein System, das Transaktionen unterstützt. Das können durchaus mehrere Operationen sein, beispielsweise wenn ein Flow aus einer JMS-Queue liest und in eine weitere JMS-Queue schreibt. Beide Operationen können durch eine gemeinsame Transaktion verbunden sein, solange sich beide per connector-ref auf den gleichen Connector beziehen. Mit einer Single Resource Transaction funktioniert es aber nicht mehr, wenn verschiedene Connectoren im Spiel sind.

Jetzt kommen die drei Transaktionsvarianten ins Spiel, die Mule unterstützt. Zur Erinnerung: Eine Transaktion gilt immer für einen definierten Bereich, der entweder vom Connector am Anfang des Flows oder einen transactional scope im Flow aufgespannt wird.

Single Resource Transaction / No Transaction

Wie der Name Single-Resource Transaction schon andeutet, gehört zu dem aufgespannten Bereich genau eine transactional resource. Was ist nun, wenn man in einem per JMS gestarteten Flow eine DB-Operation durchführen möchte? Lässt man die Transaktionsart auf JOIN_IF_POSSIBLE stehen, wird man mit einer Exception belohnt: Die JDBC-Transaktion lässt sich nicht mit einer JMS-Transaktion verheiraten. Was dagegen funktioniert: Für die DB-Operation NO_TRANSACTION einstellen, sie holt sich dann eine Connection (im Modus auto-commit), führt die Operation aus und gibt die Connection zurück (so wie in den HTTP-Beispielen oben). Man hat damit nur kein gemeinsames transaktionales Verhalten: Tritt hinter der Datenbankoperation im Flow eine Exception auf, so wird zwar die JMS-Transaction zurückgerollt, die DB-Transaction wurde jedoch schon committed.

Multiple Resource Transaction

Mit der Multiple Resource Transaction setzt Mule ein bekanntes Pattern um, hat dafür aber noch einen schönen Namen erfunden: „1.5 phase commit“. Was verbirgt sich dahinter? Es ist ein nicht komplett sicheres, aber in vielen Fällen ausreichendes Verfahren, das mehrere transactional resources verbindet. In einem transactional scope (oder einer Transaktion, die am Beginn des Flows gestartet wurde) können mehrere resources gesammelt werden. Intern speichert Mule sie in einer Liste.

Tritt eine Exception auf, wird auf allen Listeneinträgen ein rollback ausgeführt. Wird der Flow erfolgreich beendet, wird auf allen Listeneinträgen ein commit ausgeführt. Die Reihenfolge der Liste wird vorher umgedreht: Das heißt, die letzte geöffnete Transaktion wird zuerst committed. Warum? Es reduziert die Wahrscheinlichkeit, Daten zu verlieren. Es kann aber dazu führen, dass Operationen doppelt ausgeführt werden. Nehmen wir als Beispiel einen Flow, der aus einer Queue liest (erste Transaktion) und in eine Datenbank schreibt (zweite Transaktion). Die Queue-Transaktion wird erst committed, wenn die Daten sicher in der Datenbank liegen. Im schlimmsten Fall passiert ein Fehler zwischen den beiden commits, oder der commit auf der Queue schlägt fehl. In diesen Fällen würde die Nachricht ein zweites Mal verarbeitet, sobald die Verbindung zum JMS-Provider wieder besteht. Es gilt hier wieder, die fachlichen Anforderungen genauer zu betrachten: Kann die doppelte Ausführung toleriert werden, oder führt sie zu Fehlern?

XA Transaction

Oft genannt, aber in der Praxis gefürchtet und wenig genutzt: Echte verteilte Transaktionen nach dem Open-XA-Standard. Mule unterstützt XA, ist dabei aber auch auf XA-fähige Datenbanken, JMS-Provider etc. angewiesen. In vielen Fällen heißt das, einen anderen Treiber bzw. Connector zu verwenden oder zumindest andere Einstellungen vorzunehmen. Zusätzlich muss man noch den Transaktionsmonitor aktivieren, bei Mule wird dazu Bitronix eingesetzt, der sich um das 2-Phasen-Commit-Protokoll kümmert.

Mit etwas Glück spielen alle Komponenten mit und halten sich an die XA-Spezifikation. Oft sind es jedoch Details, die zu Ärger führen: Nicht jeder JDBC-Treiber kommt mit dem Connection-Pool von Bitronix klar. Außerdem sieht das XA-Protokoll auch nur im Erfolgsfall so einfach aus wie auf der verlinkten Wikipedia-Seite. Dort fehlen die diversen Fehlerfälle: Jederzeit – auch während des Commits – können DB-Server, JMS-Provider oder Mule mit dem Bitronix-Transaktionsmonitor abstürzen. Oder die Kommunikation bricht zusammen. Diese Fehlerfälle gehören auch zum Protokoll. Doch wer garantiert, dass sie auch jemand mit dem im Projekt verwendeten Produktmix getestet hat? Wenn es schief geht, kann man mit DB- bzw. JMS-Tools nach noch offenen Transaktionen suchen und entscheiden, was mit ihnen geschehen soll.

XA Transactions versprechen die schöne heile Welt, sind jedoch mit höherer Komplexität (auch im Betrieb) und höheren Kosten verbunden. 2-Phasen-Commit heißt schließlich, dass man zwei statt einen Roundtrip im Netzwerk durchzuführen hat.

Ein Beispiel für XA werde ich jetzt hier nicht bringen, die Details stehen in der Mule-Dokumentation. Wie schon erwähnt: Man muss den Bitronix-Transaktionsmanager aktivieren (eine Zeile im XML), Connectoren gegen XA-Connectoren austauschen, und in der Theorie funktioniert alles. Mit ActiveMQ, MySQL (genauer: MariaDB) und VM-Connectoren habe ich es mal kurz probiert, das hat sogar funktioniert. Es wäre interessant, diese Konfiguration in einem Performancetest gegen Multi Resource Transactions laufen zu lassen.

Best Practice

So, jetzt haben wir mehrere Varianten gesehen, wie Mule Transaktionen verwalten kann. Welche soll man jetzt nehmen? Beraterantwort: Kommt drauf an. Es gibt kein one size fits all, aber je nach Situation angemessene Lösungen.

Eine transactional resource

Beginnen wir mit einfachen Mule-Applikationen, die nur eine Fassade vor einer Datenbank darstellen, kann man alles auf den default-Einstellungen stehen lassen und mit einer Transaktion pro Statement arbeiten. Werden mehrere Statements in einem HTTP-Call ausgeführt, fasst man sie in einem transactional scope zusammen. Hier wird es jedoch schon gefährlich: Schnell denkt man, mit dem einfachen Fall auszukommen, landet dann aber doch mehreren Statements. Kann man dies nicht grundsätzlich im Projekt ausschließen, sollte man gleich Nägel mit Köpfen machen und grundsätzlich den transactional scope benutzen. Das ist auch flexibler, wenn es darum geht, Code in andere Flows zu verschieben.

Einen anderen (einfachen) Fall stellen Mule-Anwendungen dar, die zwischen HTTP und einem Queue-System (z.B. ActiveMQ) vermitteln. Startet der Flow per HTTP, gelten die gleichen Grundsätze wie bei den Datenbanken. Startet der Flow durch Empfangen einer Nachricht und versendet Daten per HTTP, so hat man die Wahl: Lässt man den empfangenden Endpoint transaktionslos laufen, erhält man „at most once“-Semantik. Lässt man ihn dagegen mit einer Transaktion laufen, ist der Flow synchron, und Fehler in der HTTP-Kommunikation führen zum Abbruch. Die Nachricht wird also später erneut verarbeitet (durch Retry oder über den Umweg einer Error-Queue), man erhält damit „at least once“-Semantik. Hier muss man einfach auf Basis der Anforderungen Sicherheit vor Datenverlust und Performance gegeneinander abwägen.

Es muss auch nicht immer Mule sein: Ist eine Anwendung, die ein REST-Interface zur Verfügung stellt und sonst nur mit genau einer Datenbank arbeitet, in Mule richtig aufgehoben? Einige einfache Aufrufe hat man mit Mule schnell zusammengeklickt, sobald aber etwas Logik hinzukommt (Validierungen etc.), wird es schnell umständlich: Dann entstehen schnell riesige und unübersichtliche Flows. Das Problem lässt sich umschiffen, wenn komplexe Logik in Java-Transformer bzw. Java-Components auslagert wird (siehe Blogpost Von Mule nach Java und zurück). In manchen Fällen ist ein Microservice (z.B. mit Spring Boot und Hibernate), der nach oben eine REST-Schnittstelle zur Verfügung stellt, jedoch die bessere Lösung.

Mehrere transactional resources

Bei mehreren transactional resources muss man zwei Fälle unterscheiden: Startet der Flow mit einem transactional connector? Wenn ja, startet man direkt hier eine Multiple Resource Transaction. (Ob man hier always begin oder begin or join einträgt, macht keinen Unterschied.)

Aufpassen muss man jetzt mit den weiteren transactional resources: In einfachen Fällen kann man mit einem always join arbeiten, es wird dann aber bezüglich Wiederverwendung von Flows schwierig. Arbeitet man mit mehrenen Flows, die man an verschiedenen Stellen wiederverwenden möchte und weiß nicht sicher, ob schon eine Transaktion gestartet wurde, setzt man besser einen transactional scope ein, der auf multiple resource und begin or join eingestellt wird. Somit kann der Flow sowohl mit einer gestarteten Transaktion als auch ohne Transaktion loslaufen.

Der zweite Fall sind Flows, die ohne Transaktion starten, z.B. durch HTTP oder Polling/Scheduler. Hier muss man auf jeden Fall selbst eine Transaktion öffnen. Also zieht man einen transactional scope direkt an den Anfang des Flow, mit multiple resource und begin or join. Der Rest des Flows (und alle aufgerufene Flows) laufen dann innerhalb dieser Transaktion. Das Pattern ist auch mit dem oben angegebenen Pattern von weiteren transactional scopes kompatibel, die durch begin or join im Zweifelsfall an der Transaktion teilnehmen.

Wie schon oben erwähnt, würde ich von XA-Transaktionen die Finger lassen, wenn es nicht wirklich gute Gründe für sie gibt. Der Aufwand für das XA-Protokoll bedingt auf jeden Fall mehrere Netzwerkaufrufe, was immer eine höhere Latenz bedeutet. Dazu kommt, dass man für den konkret eingesetzten Produktmix die Fehlervarianten auch testen sollte.

Mule schreibt in der Dokumentation, dass Multiple Resource Transactions aufwändiger als Single Resource Transactions sind, was nach einem Blick in den Quelltext aber schnell relativiert ist: In einem Fall ist einem Thread eine transactional resource direkt zugeordnet, im anderen Fall eine Liste, die vorm Commit umgedreht wird. Der Mehraufwand dafür dürfte im Vergleich zu den Aufrufen an JDBC- oder JMS-Provider vernachlässigbar sein.

Zusammenfassung

Verteilte Systeme sind komplex, vor allem dann, wenn nicht nur der Schönwetterfall beachtet wird. Man sollte sich auch im Klaren sein, dass es Grenzen gibt: Keine exactly-once-Semantik bei remote procedure calls, CAP-Theorem etc. Auch bei vielen automatisierten Fallback-Szenarien wird man immer mal wieder manuell eingreifen müssen. Eine gute Idee ist es daher, nicht zu viel zu versuchen und bei Fehlern in die richtige Richtung zu fallen. Was ist schlimmer: Eine doppelt ausgeführte Aktion oder eine vergessene Aktion? Hier hilft nur eine Diskussion mit dem Fachbereich.

Bevor im Projekt alle möglichen Patterns durcheinander eingesetzt werden, sollte man sich zurücklehnen und nachdenken: Was brauche ich wirklich? Mit welchen Patterns möchte ich im Projekt arbeiten? Diese sollten dann einheitlich eingesetzt werden, so dass sich auch Flows wiederverwenden lassen, ohne die gesamte Aufrufkette zu kennen.

Ich möchte mich an dieser Stelle noch bei Stefan Cordes bedanken, durch viele Diskussionen im Rahmen eines Mule-Projekts hat er wesentlich zu diesem Blogpost beigetragen.

Roger Butenuth

Dr. Roger Butenuth hat in Karlsruhe Informatik studiert und anschließend in Paderborn promoviert (Kommunikation in Parallelrechnern). Er hat langjährige Erfahrung in der Projekt- und Produktentwicklung.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.