Tücken der Stapelverarbeitung mit JPA

Print Friendly, PDF & Email

Ist es möglich, eine komplexe Stapelverarbeitung um den Faktor 20 zu beschleunigen, einzig durch Hinzufügen einer Zeile Code? Ja – zumindest dann, wenn man zuvor diese Zeile vergessen und dadurch einen Performanceeinbruch um den Faktor 20 erlitten hat.

Manchmal ist es schon erstaunlich, welche Auswirkungen kleine Änderungen haben. Aber der Reihe nach und zum Mitdenken – vielleicht kommt jemand schon vor dem Ende des Artikels auf die Lösung…

Die Anforderung: Volltextsuche über alle Webseiten

Die Web-Anwendung in meinem aktuellen Projekt benötigt eine Volltextsuche. Um schnell eine erste Fassung produktiv zu setzen, haben wir uns entschieden, die Volltext-Funktionalität von Postgresql zu nutzen. Die Architektur der Web-Anwendung basiert darauf, dass für jeden Webseiten-Typ eine EJB-Servicemethode ein maßgeschneidertes Datenobjekt liefert, das alle Informationen enthält, die auf einer Webseite angezeigt werden.

Da der Umfang des Auftritts noch überschaubar ist (rund 10.000 Seiten), wollten wir den Index jede Nacht vollständig generieren. Der Aufbau einer Webseite braucht rund 200-500ms – die Vollindizierung sollte also nicht viel länger dauern als etwa eine Stunde.

Die Indizierung übernahm ein Singleton-EJB, das durch eine @Schedule-Annotation einmal in der Nacht aufgerufen wird. Diese Methode nutzt die bestehenden Services, um die Daten aller Webseiten nacheinander zu laden. Für jede Seite bereitet die Methode die Daten einzeln auf und übergibt sie einem weiteren Service, der sie in den Volltextindex einträgt.

Das Problem: Langsam laufende JPA-Queries in der Stapelverarbeitung

Beim ersten Testlauf benötigte jedoch schon die Indizierung einer kleinen Teilmenge der Seiten eine Dreiviertelstunde. Ein Blick in die Log-Meldungen zeigte, dass das Laden der Daten für eine Webseite rund vier Sekunden benötigt, obwohl doch dieselbe Methode genutzt wird wie für den Aufbau der Webseite.

Ein genauerer Blick zeigte, dass jede JPA-Query mindestens 200ms braucht – selbst die einfachsten Abfragen, die sonst in etwa 1ms durchlaufen. Irgendetwas bremste den Datenzugriff also enorm aus.

Als nächstes starteten wir die Stapelverarbeitung im Debugger. Interessanterweise liefen die ersten Zugriffe so schnell wie erwartet. Nach einer Weile wurden die Zugriffe jedoch immer langsamer.

Und da hat es Klick! gemacht.

Die Ursache: Der First-Level-Cache

Sofern man keine anderen Einstellungen explizit angibt, läuft jeder Aufruf einer EJB-Methode in einer Transaktion. Damit die Domänendaten aus der Datenbank jederzeit konsistent sind, verknüpft der JPA-Provider intern jede Transaktion mit einer Unit-of-Work, die einen First-Level-Cache verwaltet. Jede Entität, die während der Transaktion geladen wird, kommt in diesen Cache.

Das Problem ist nicht der Cache selbst, sondern die Prüfung, ob sich die im Cache gelegenen Entitäten geändert haben. Zur Performance-Optimierung schreibt der JPA-Provider nämlich üblicherweise Änderungen an den Entitäten erst dann in die Datenbank, wenn dies notwendig ist. Notwendig ist dies immer dann, wenn eine Datenbankabfrage gestartet wird. Das heißt: Vor dem Ausführen jeder Query muss der JPA-Provider den vollständigen Inhalt des First-Level-Caches auf Änderungen absuchen.

Das war unsere Crux: Da die vollständige Stapelverarbeitung in einer einzigen Transaktion ablief, wurde der First-Level-Cache immer größer. Die 200ms Verzögerung vor jeder Query waren durch die Prüfungen auf geänderte Objekte zu erklären.

Die schnelle Lösung bestand darin, die Transaktion der Singleton-EJB, die die Daten verarbeitet, auszuschalten, und zwar durch die Annotation

Und siehe da: Jetzt läuft sie Indizierung mit dem erwarteten hohen Tempo durch.

Wie immer hat eine vermeintlich einfache Lösung ihre Schattenseite: Da nun der Neuaufbau des Indexes nicht mehr vollständig transaktional gesichert ist, kann ein Fehler dazu führen, dass der Index unvollständig ist.

Aber das ist ein Problem, um das wir uns erst morgen kümmern.

1 Kommentar

  1. Sehr schön beschrieben!

    Das ist eine „Falle“, in die ich auch kürzlich reingetappt bin. Wir haben nach einigen Versuchen ScrollableResults mit Hibernate + MySQL JDBC aktivieren können (war leider auch nicht selbsterklärend). Wir dachten, jetzt können wir relativ große Batch Jobs elegant laufen lassen, ohne dabei viele Objekte auf einmal laden zu müssen oder das „Scrolling“ selbst zu implementieren (die DB kann das wesentlich besser!).

    Das Problem dabei war, daß eben der 1LC beim Iterieren immer größer wurde, die Anwendung immer langsamer, der Heap immer mächtiger. Nach einigen Profiling Sessions haben wir die Ursache, ähnlich wie in Deinem Artikel, im 1LC gefunden.
    Die Lösung war relativ einfach: Eine Adapter Klasse, die beim Iterieren „alte“ Entities aus dem Session Cache entfernt. Da wir nur vorwärts iterieren, und nach dem Batch Job die Entitäten nicht mehr benötigen, ist das eine solide Lösung für unseren UseCase. Allerdings unschön, da ich eine solide, konfigurierbare Lösung in Hibernate erwartet hätte.

Leave a comment