Wie bleibt die Anwendung schnell? Performance-Regressionstests mit Hibernate!

Print Friendly, PDF & Email


In einer typischen Java-Enterprise-Anwendung steht und fällt die Performance mit den Datenbankzugriffen. Im Java-Code kommt es darauf an, sowohl die Anzahl der Datenbank-Roundtrips als auch die Größe der Ergebnismengen klein zu halten. Dies zu erreichen ist mit JPA, Hibernate & Co. schwierig – eine optimierte Anwendung langfristig schnell zu halten, aber noch schwieriger.

Die typische Java-Enterprise-Anwendung, die hier gemeint ist, hat einen mehrschichtigen Aufbau einschließlich einer Datenzugriffsschicht, die per JPA/Hibernate/EclipseLink auf eine relationale Datenbank zugreift. Für eine hohe Performance ist natürlich auch das Datenschema sehr wichtig. Gut gesetzte Indexe und eine optimierte Datenbankkonfiguration sind eine wichtige Basis für schnelle Zugriffe und einen hohen Durchsatz.

Datenbankzugriffe als Performance-Bremse

Meiner Erfahrung nach resultieren die meisten Performance-Probleme einer solchen Anwendung aber nicht aus der Datenbank selbst, sondern aus der Art und Weise, wie die Java-Anwendung auf die Datenbank zugreift. O/R-Mapper wie EclipseLink und Hibernate bieten viel Komfort, vor allem, wenn man sie per JPA verwendet. Dieser Komfort ist jedoch ein zweischneidiges Schwert!

Auf der einen Seite reichen wenige Zeilen Code aus, um Java-Objekte bzw. ganze -Objektgraphen in die Datenbank zu schreiben und wieder zu lesen. Auf der anderen Seite verbirgt dieser Komfort, was hinter den Kulissen wirklich vor sich geht. So manche unscheinbare Laderoutine, die in der Entwicklungumgebung mit wenigen Daten getestet wurde, entpuppt sich in einer produktiven Umgebung als wahrer Performance-Killer.

Die Ursachen dafür können vielfältig sein. Wie man sie entdeckt, und was man dagegen tun kann, bietet ausreichend Stoff für mehrere zukünftige Artikel. Kurz gesagt sind die beiden wichtigsten Performance-Bremsen:

  • Zu viele Datenbankzugriffe aufgrund des n+1 selects-Problem: Ein in den Speicher geladenes Objekt referenziert eine Liste anderer, noch nicht geladener Objekte. Wenn im Java-Code jedes dieser referenzierten Objekte verwendet wird, wird für jedes referenzierte Objekt die Datenbank ein weiteres Mal (oder weitere Male) abgefragt.
  • Zu große Datenmengen aufgrund von Kreuzprodukten: Angenommen, ein Objekt referenziert zwei verschiedene Listen anderer Objekte und beide Listen sollen sofort mit dem Objekt mitgeladen werden. Wenn der O/R-Mapper dazu nur ein einzige Datenbankabfrage ausführt, entsteht ein Kreuzprodukt, d.h. die Ergebnismenge enthält pro Kombination der beiden referenzierten Listen eine Zeile.  Die Ergebnismenge wächst also exponential mit der Anzahl der referenzierten Objekte.

Die Abfragen sind optimiert. Und dann?

Angenommen aber, die Anwendung ist hoch-optimiert und die Anzahl der Datenbankzugriffe und die Größe der Ergebnismengen wurden so weit wie möglich reduziert. Dann hat die Anwendung einen sehr fragilen Zustand erreicht! Denn jede kleine Änderung an den Mapping-Daten oder an den Datenbankabfragen kann die Optimierungen wieder zunichte machen.

Insbesondere eine LazyInitializationException verführt vielfach dazu, das Problem zu lösen, indem eine Objekt-Assoziation von Lazy-Loading (Laden bei Bedarf) auf Eager-Loading (sofortiges Mitladen) gestellt wird. Der funktionale Bug ist behoben, aber die Performance dahin. Denn auch kleine Änderungen an den Mappingdaten können große Wirkungen nach sich ziehen, teilweise an ganz anderen Stellen als vermutet. Bei kleinen Datenmengen wirkt sich eine solche Änderungen zudem kaum sichtbar aus und auf das Rauschen von SQL-Log-Meldungen wird nur selten geachtet.

Hier stellt sich die Frage, wie man eine Anwendung davor schützen kann, unbeabsichtigt ausgebremst zu werden!

Regressionstests mit Hibernate

Wenn man Hibernate direkt oder als JPA-Implementierung einsetzt, sind Hibernates interne Zugriffsstatistiken sehr nützlich. Auf Grundlage dieser Statistiken lassen sich Regressionstests schreiben, die überprüfen, ob bestimmte Services eine vorgegebene Anzahl von Datenbankzugriffen nicht überschreiten. Das Ziel ist also, einen Unit-Test zu schreiben, der einen Service ausführt und anhand der Hibernate-Statistiken die Anzahl der wirklich ausgeführten Datenbankzugriffe mit einem Erwartungswert vergleicht.

Dazu muss man zunächst in der Lage sein, Unit-Tests so auszuführen, dass man aus dem Test-Code heraus Zugriff auf den EntityManager erhält. In Spring-basierten Anwendungen ist dies durch das Spring-Testframework sehr einfach. Bei JEE-Anwendungen muss man sich mit dem Embedded Glassfish oder Arquilian behelfen. Die Grundlagen dazu führen an dieser Stelle jedoch zu weit.

Ein möglicher Test kann dann so ausschauen:

@Test
public void protectAgainstIncreasingNumberOfDatabaseRoundtrips() {

  Statistics statistics = 
    entityManager.unwrap(Session.class).getSessionFactory().getStatistics();
  statistics.setStatisticsEnabled(true);

  long numberOfStatementsBeforeCall = statistics.getPrepareStatementCount();
  service.executeSomeFunctionality();
  long numberOfStatements = statistics.getPrepareStatementCount()-numberOfConnectsBeforeCall;

  assertEquals("Check the number of database roundtrips", 10, numberOfStatements);
}

Zuerst besorgt man sich eine Referenz auf das Statistikobjekt und aktiviert die Aufzeichnung der Zugriffswerte. Dann führt man die zu prüfende Methode aus und vergleicht die Anzahl der Datenbankzugriffe vor und nach dem Methodenaufruf.

Die Dokumentation der Schnittstelle org.hibernate.search.stat.Statistics zeigt noch mehr Möglichkeiten, wie man die Vorgänge im O/R-Mapper feingranular prüft. Zwar sind solche Tests viel Handarbeit und sorgen regelmäßig für Nachbesserungsaufwand, wenn sich die Datenbankzugriffe gewollt verändern. Dafür bieten Regressionstests einen guten Schutz gegen unbeabsichtigte Veränderungen, die die Performance einer Anwendung spürbar verschlechtern können.

Und hier noch ein paar Tipps zum Weiterlesen:

Schreibe einen Kommentar

Fügen Sie die notwendige Nummer ins freie Feld ein *