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 = Propagation.REQUIRES_NEW) kann schnell zur Falle werden (was ich persönlich bereits mehrfach erlebt habe).

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.

connections-diagram.png

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 = Propagation.REQUIRES_NEW) annotierten Methode).
  • 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:

A terminal window displays a Java stack trace error, showing timed waiting threads and multiple lines referencing Spring Framework and HikariCP transaction management classes. The error highlights issues related to spring transaction handling, revealing potential java transaction pitfalls when using transactional requires_new within the application.

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:

A monitoring dashboard shows database connection pool metrics: 3 active connections, 3 idle connections, and 8 threads awaiting connection for Pool (HikariPool-1), which could signal Java transaction pitfalls or inefficient Spring transaction handling—especially when using propagation settings like REQUIRES_NEW. The MBeans browser panel lists connection details.

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_NEW fü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

[2] https://en.wikipedia.org/wiki/Deadlock

[3] https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/support/TransactionSynchronizationManager.html

[4] https://www.baeldung.com/java-thread-dump