Transactional REQUIRES_NEW als schädlich betrachtet — Fallstricke bei der Spring/Java-Transaktionsverarbeitung

Datenbanken und Transaktionen sind großartig, Spring ist großartig, Kotlin/Java sind großartig (natürlich ist Kotlin besser 😉), die Vereinfachung der Transaktionsverarbeitung ist großartig. Aber wie wir alle wissen, schafft die Vereinfachung von Dingen fast immer neue Fallstricke, wenn die vereinfachten Dinge nicht vollständig verstanden werden.
Das Problem
Die Spring-@Transactional-Annotation ist ein mächtiges Werkzeug, um das Transaktionsmanagement für Entwickler zu vereinfachen.
Wie immer gilt: Mit großer Macht kommt große Verantwortung / Kenne deine Werkzeuge.
Besonders die Variante @Transactional(propagation = kann schnell zur Falle werden (was ich persönlich bereits mehrfach erlebt habe).Propagation.REQUIRES_NEW)
Was ist Propagation.REQUIRES_NEW?
Oft denken Menschen bei dieser Propagationseinstellung daran, dass sie der Datenbank „magisch” mitteilt, eine verschachtelte Transaktion zu erstellen. Dieses gedankliche Modell ist jedoch falsch – viele Datenbanken haben gar keine verschachtelten Transaktionen, also muss etwas anderes passieren.
Was tatsächlich passiert, ist, dass Spring eine neue Verbindung zur Datenbank öffnet.

Transactional und Transactional(REQUIRES_NEW) in Interaktion mit dem Connection-Pool. „Connection 2″ ist nur ein Beispiel für eine aktuell freie Verbindung.
Das kann aus mindestens 2 Gründen problematisch sein:
Szenario 1: Die blockierende Ressourcenallokation für eine begrenzte Ressource (Verbindungen aus einem Pool) klingt bereits nach etwas, das wir aus der Informatik kennen, wenn wir von Deadlocks gehört haben.
Szenario 2: Das gleichzeitige Handhaben von 2 Verbindungen zur Datenbank, bei denen das Committen der einen vom Committen der anderen abhängt, ist ebenfalls eine schlechte Idee – insbesondere wenn die Verbindungen auf zusammenhängenden Tabellen operieren könnten.
Deadlocks auf allen Seiten
Kurze Zusammenfassung zu Deadlocks: „[…] ein Zustand, in dem jedes Mitglied einer Gruppe darauf wartet, dass ein anderes Mitglied, einschließlich sich selbst, eine Aktion ausführt […]” aus [2]
In Szenario 1 tritt der Deadlock auf der Java-Seite auf:
- Dort haben 2 (oder mehr) Threads jeweils eine Datenbankverbindung erworben (und damit den Java-DB-Connection-Pool geleert).
- Jetzt möchte jeder Thread eine weitere Verbindung erwerben und wartet damit darauf, dass die anderen Threads ihre freigeben.
- Die anderen Threads tun dasselbe, also würden sie eine Verbindung erst freigeben, nachdem sie eine zusätzliche erhalten haben.
Das Einzige, was diese Situation jemals auflösen/entsperren wird, ist ein Timeout auf der Connection-Pool-Seite. Dieses Timeout ist oft auf mindestens ein paar Sekunden konfiguriert, und bis dahin versuchen Benutzer/andere Systeme wahrscheinlich bereits hektisch, die problematische Anfrage erneut zu stellen, was garantiert, dass die Situation schlecht bleibt. Das System wird für fast alle Benutzer vollständig eingefroren/kaputt erscheinen, da die Mehrheit nur Fehlerantworten sieht und die wenigen, die tatsächlich durchkommen, langsam sind.
In Szenario 2 kann der Deadlock auf der Datenbankseite auftreten (obwohl er außerhalb des Geltungsbereichs des Beispiels dieses Repositories liegt):
- Der Java-Prozess hat eine Verbindung geöffnet und z. B. einen neuen Benutzer in eine Tabelle eingefügt.
- Dann hat der Java-Prozess (derselbe Thread) eine weitere Verbindung geöffnet (über einen Aufruf einer mit
@Transactional(propagation =annotierten Methode).Propagation.REQUIRES_NEW) - In dieser zweiten Verbindung versucht er, etwas in eine Benutzer-Präferenzen-Tabelle einzufügen und einen Fremdschlüssel auf den gerade eingefügten Benutzer zu verwenden.
- Wenn die innere Methode verlassen wird und Spring versucht, die „innere” Transaktion (die zweite Transaktion ist genauer) zu flushen, blockiert die Datenbank.
- Die Datenbank wartet darauf, dass die erste Verbindung abgeschlossen (committed) ist, bevor sie der zweiten das Committen erlaubt.
- Das ist aus Sicht der Datenbank völlig valide, da diese 2 Verbindungen völlig unabhängig sind.
- Die Java-Seite hat jedoch eine blockierende Abhängigkeit eingeführt, die in die entgegengesetzte Richtung läuft (die innere Methode wird nicht abgeschlossen/zurückgegeben, also kann die aufrufende Methode natürlich auch nicht abschließen).
HINWEIS: Das hängt ein wenig von der konkreten Datenbank ab, aber ähnliche Situationen können wahrscheinlich auf den meisten Datenbanksystemen mit leicht veränderten Szenarien reproduziert werden – z. B. durch DB-seitige Sperren auf andere Weise.
Reproduktion
Wenn ihr mitspielen/testen/… möchtet, findet ihr hier das Beispiel-Repository mit dem Code.
Wir starten die Anwendung und führen dann folgenden Befehl über das Terminal aus – verwendet entweder Git Bash oder WSL unter Windows:
seq 1 8 | xargs -I $ -n1 -P10 curl “http://localhost:8080/nested”
Beachtet, dass dieser Beispielcode nur das in Szenario 1 beschriebene Problem zeigt (Java-seitige Blockierung).
Das Problem identifizieren
Was können wir tun, wenn wir glauben, dass unsere Anwendung dieses Problem hat? Es könnte nur ein Gefühl sein, aber wie können wir sicher sein?
Wenn möglich, sollten wir das Logging unseres Connection-Pools aktivieren und Parameter wie connectionTimeout anpassen, damit wir Stacktraces sehen, wo Probleme auftreten.
Ein guter Ansatz wäre dann der folgende:
- Das System unter Last setzen (versucht, das System gut zu belasten – typischerweise parallele Anfragen), sodass Probleme sichtbar werden.
- Einige Thread-Dumps nehmen [4].
- Die Thread-Dumps und Logs analysieren.
Diese Daten können durch Ausführen der Beispiel-App und des bereitgestellten Bash-Skripts gesammelt werden.
Logs
Wenn wir uns in der glücklichen Situation befinden (wie bei der Beispiel-App), genügend Log-Anweisungen im Code zu haben, können die Logs bereits ausreichen, um das Problem zu identifizieren.
Ohne Logs
Nehmen wir dennoch an, wir hätten diese Logs nicht – oder wären noch nicht vollständig überzeugt.
In den Thread-Dumps würden wir Folgendes sehen:

Gekürzter Stacktrace, der 2 Threads zeigt, die bei datasource.getConnection() hängen
HINWEIS: Ich habe vieles entfernt, um es leichter erkennbar zu machen – aber wenn man einmal weiß, wonach man sucht, ist das einfach.
Wir sehen, dass:
- Die äußere Transaktion gestartet wurde (eine Verbindung erworben hat).
- Unser eigener Code läuft, der versucht, eine Service-Methode aufzurufen, aber bevor er dort ankommt…
- …der Spring-Proxy übernimmt, um das Transaktions-Handling zu erledigen und versucht, eine separate Transaktion zu erstellen.
- Dieser Code auf eine Verbindung wartet.
Wir sehen auch, dass mehrere andere Threads laufen, die genau an derselben Stelle blockiert sind. Auch ein Thread-Dump, den wir einige Sekunden später aufnehmen, sieht noch genauso aus. Wir können also davon ausgehen, dass die Ausführung nicht weiter vorangeschritten ist.
Andere Metriken/Indikatoren
Wenn wir Zugriff auf andere Metriken wie JMX-Werte haben, können diese ebenfalls gute Indikatoren sein.
In diesem Screenshot von JVisualVM sehen wir den aktuellen Status des Hikari Connection-Pools, während das Last-Bash-Skript läuft:

Pool-Status, der zeigt, dass viele Verbindungen warten (vom Pool angefordert, aber noch nicht bereitgestellt werden konnten).
Was wir erwarten zu sehen, um unsere Theorie zu bestätigen, dass unsere Anwendung unter Pool-Locking leidet, ist folgendes: Die Werte bleiben hoch (ändern sich nicht), während wir einige Male aktualisieren, und unsere simulierten Clients (curl-Befehle) machen keinen Fortschritt.
Mögliche Lösungen
Im Wesentlichen gibt es 3 Wege, wie das behoben werden kann:
1. Die „verschachtelte” Transaktion loswerden. Es ist möglicherweise gar nicht notwendig, 2 separate Transaktionen zu haben. Falls das der Fall ist, sie einfach durch die Verwendung des Standard-Propagationslevels loswerden.
2. Die 2 Transaktionen serialisieren. Anstatt eine innere Transaktion zu haben, die nach der ersten gestartet wird, aber vor der ersten abgeschlossen sein muss, könnte es eine Option sein, einfach 2 separate Transaktionen nacheinander zu haben.
- Der einfachste Weg ist, eine äußere Methode zu haben, die nicht transaktional ist und dann 2 transaktionale Services nacheinander aufruft.
- Falls das nicht so einfach ist, könnte Springs
TransactionSynchronizationManager.registerSynchronization[3] einen Versuch wert sein. Damit kann Code registriert werden, der ausgeführt wird, sobald die aktuelle Transaktion abgeschlossen ist.
3. Die Nebenläufigkeit auf der aufrufenden Seite auf ein Niveau begrenzen, das garantiert, dass der Fall niemals eintritt. Das könnte auf verschiedene Weisen geschehen.
- Eine Möglichkeit wäre die Reduzierung der erlaubten Parallelität eingehender Verbindungen (HTTP).
- Eine andere Möglichkeit ist das Einführen von anderem begrenzenden Code (z. B. nur rund um die Teile der Anwendung, von denen bekannt ist, dass sie verschachtelte Transaktionen benötigen).
- Das richtige Limit hängt von einigen Faktoren ab: die Menge der Verschachtelung, die die Anwendung hat (jede Ebene von verschachtelten Aufrufen an
Propagation.REQUIRES_NEWfügt eine weitere Verbindung hinzu, die von einem einzelnen Thread benötigt wird), die Datenbankverbindungspool-Einstellungen und die Menge der anderweitig belegten DB-Verbindungen. - Bedenkt auch, dass andere Teile der Anwendung möglicherweise ebenfalls DB-Verbindungen belegen: häufig laufende Tasks, lange laufende Jobs, Message-Queue-Nachrichtenverarbeitung, RPC-Endpunkte usw.
@Transactional(propagation = Propagation.REQUIRES_NEW) ist schlicht ein großartiges Werkzeug, das ich inzwischen mehrfach auf problematische Weise eingesetzt gesehen habe.
Einschränkungen
„Einfach die Pool-Größe erhöhen” ist KEINE Lösung. Und es ist nicht einmal eine gute Umgehungslösung für ein System von bedeutender Größe – siehe [1]. Natürlich sollte der DB-Connection-Pool für jede Anwendung eine angemessene Größe haben – aber nach dem Lesen von [1] werden wir vielleicht feststellen, dass er niedriger ist als anfänglich gedacht.
Das ist KEIN Problem mit dem HikariCP Connection-Pool. Wenn überhaupt, gibt uns HikariCP großartige Tools (wie leak-detection-threshold), um solche Probleme zu identifizieren, und viele andere Parameter, um unsere Connection-Pools ordentlich zu konfigurieren.
Das ist auch KEIN Problem/Bug in Spring oder sogar Java-spezifisch. Jedes System kann dieses Problem haben, wenn es folgende Eigenschaft hat: ein Pooling-Mechanismus mit einer festen Obergrenze und mehrere verschachtelte und blockierende Anfragen daran.
Fazit
Sollten wir @Transactional(propagation = Propagation.REQUIRES_NEW) nie wieder verwenden? Nein, das würde ich nicht so weit gehen.
Sollten wir jedes Mal, wenn wir es verwenden wollen, hinterfragen, ob wir es wirklich brauchen? Ja, definitiv. In meiner Erfahrung wird das Code-System normalerweise einfacher zu durchdenken, sobald wir den Aspekt des „gleichzeitigen Handhabens von 2 DB-Verbindungen” losgeworden sind.
Weiterführende Lektüre
[1] https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing
Agent Hub
Gehen Sie über vereinzelte AI-Piloten hinaus und schaffen Sie eine sichere, skalierbare Grundlage für Agentic AI in Ihrer gesamten Organisation.
Zur Demo




