Java Magazin 11/09

OR Mapping Antipatterns

Autor:

Für Java-Anwendungen, die Daten in einer relationalen Datenbank speichern, bieten O/R-Mapper eine einfache Möglichkeit, den Status von Objekten auf Tabellen in der Datenbank abzubilden. Der gesamte JDBC-Datenbankzugriff ist innerhalb des Frameworks gekapselt und der Code beinhaltet fast ausschließlich fachliche Logik. Diese Kapselung führt aber auch zu Missverständnissen und fehlerhaftem Einsatz der Frameworks.

Im ersten Teil dieser Serie wurden allgemeine Anti-Patterns im Bezug auf Datenbankzugriffe aus Java heraus vorgestellt. Diese Fortsetzung spezialisiert die beschriebenen Anti-Patterns auf Basis von Hibernate und JPA, um so alltägliche Probleme beim Einsatz von O/RMappern zu beleuchten und Tipps und Tricks zu zeigen, wie man sie vermeidet. O/R-Mapper gehören spätestens seit der Standardisierung durch das Java Persistence API (JPA) zum festen Bestandteil der Java-Plattform und erfreuen sich großer Beliebtheit.. Hibernate [1] ist das wahrscheinlich am weitesten verbreitete O/R-Mapping Framework, das auch Basis für die JPA-Implementierung des JBoss Application Servers ist. Die einfache Programmierschnittstelle und der schnelle Einsatz des Frameworks haben sicherlich zum großen Erfolg von Hibernate beigetragen. Auch das Spring- Framework bietet eine umfangreiche Unterstützung für Hibernate und JPA und erleichtert die Nutzung durch viele sinnvolle Hilfsfunktionen und Templates. Diese Einfachheit beim Einstieg in die O/R-Mapping-Welt täuscht aber über die eigentliche Komplexität dieser Frameworks hinweg. Bei komplexen Anwendungen merkt man sehr schnell, dass man Details der Framework- Implementierung kennen sollte, um sie ideal zu nutzen. Dieser Artikel beschreibt einige in der Praxis häufig auftretende Anti-Patterns, die sich schnell in Performanceproblemen manifestieren können.

Anti-Pattern: Ineffiziente Fetching-Strategien

Eine elementare Funktion von Hibernate/ JPA ist die Definition von Assoziationen zwischen Objekten, beispielsweise die Beziehung „Person hat Adressen“. Wird eine Person geladen, legt die so genannte Fetching-Strategie fest, ob die Adressen der Person sofort – eager loading – oder erst zu einem späteren Zeitpunkt – lazy loading – geladen werden. Wird keine Fetching-Strategie spezifiziert, werden die Assoziationen lazy geladen – das Hibernat Cachebedeutet, dass diese erst dann aus der Datenbank (oder dem Cache) gelesen werden, wenn darauf zugegriffen wird. Ist aber schon vor dem Laden eines Objekts bekannt, dass auf die Adressen der Person zugegriffen wird, wäre es besser, die Person inklusive der Adressen mit einem SQL Statement zu laden. Werden die Adressen mit der Fetching-Strategie eager geladen, erzeugt Hibernate eine Outer-join Query, um die Anzahl der Statements zu reduzieren.

In der Praxis ist es häufig nicht leicht festzulegen, welche Objekte lazy und welche eager geladen werden sollen, da die Objekte in unterschiedlichen Kontexten verwendet werden, man aber nur eine bestimmte Fetching-Strategie konfigurieren kann. Gerade bei mehrstufigen Beziehungen werden die kon-figurierten Fetching-Strategien schnell unübersichtlich und die Anzahl der Statements schwer vorherzusagen, die beim Zugriff auf ein Objekt und seine Daten ausgeführt werden. Der Einsatz von Annotationen macht es zwar einfacher, den Überblick zu behalten, löst aber das Problem der unterschiedlichen Anwendungsfälle nicht. In den meisten Fällen ist es ratsam, erst einmal alle Assoziationen lazy zu definieren und dann mit einem Tool wie dynaTrace Diagnostics zu analysieren, wie viele Statements tatsächlich bei bestimmten Anwendungsfällen ausgeführt werden. Auf Basis dieser Informationen können die Fetching-Strategien angepasst werden. Alternativ können zur Optimierung der Performance für bestimmte Aktionen find-Methoden in den DAOs definiert werden, die die Objektbäume mit einer optimierten HQL/EJB-QL Abfrage laden. Bewährt hat sich dabei auch der Einsatz des Generic DAO Patterns [2], das den zu programmierenden Code eines DAOs stark minimiert.

Anti-Pattern: Flush und Clear

Hibernate verwaltet die persistenten Objekte innerhalb einer Transaktion in der so genannten Session. Bei JPA übernimmt der EntityManager diese Aufgabe, und im Folgenden wird der Begriff „EntityManager“ auch als Synonym für die Hibernate Session verwendet, da beide auch von der Schnittstelle ähnlich sind. Solange ein Objekt an einen EntityManager gebunden ist (attached), werden alle Änderungen des Objekts automatisch mit der Datenbank synchronisiert. Man spricht hier vom Flushen der Objekte. Der Zeitpunkt der Objektsynchronisation mit der Datenbank ist nicht garantiert – je später ein Flush ausgeführt wird, desto mehr Optimierungspotenzial hat der EntityManager, weil z.B. Updates auf ein Objekt gebündelt werden können, um SQL Statements zu vermeiden.

Ein Aufruf von clear führt dazu, dass alle aktuell verwalteten Objekte des EntityManagers detached werden und der Status nicht mit der Datenbank synchronisiert ist. Solange die Objekte nicht explizit wieder attached werden, sind es ganz normale Java-Objekte, deren Veränderung keine Auswirkung auf die Datenbank mehr haben. Bei vielen Anwendungen, die Hibernate oder JPA einsetzen, werden flush() und clear() sehr häufig explizit aufgerufen, oft mit fatalen Auswirkungen auf Performance und Wartbarkeit der Anwendung. Der manuelle Aufruf von flush() sollte durch ein sauberes Design der Anwendung immer vermieden werden können und ist ähnlich zu bewerten wie ein manueller Aufruf von System. gc(), der eine manuelle Garbage Collection anfordert. In beiden Fällen verhindert man ein normales, optimiertes Arbeiten der Technologien. Bei Hibernate und JPA bedeutet das, dass in der Regel mehr Updates ausgeführt werden als notwendig wären, wenn der EntityManager selbst über den Zeitpunkt entschieden hätte. Der Aufruf von clear(), dem in vielen Fällen ein manueller flush() vorangegangen ist, führt dazu, dass alle Objekte vom EntityManager entkoppelt werden. Deshalb sollten klare Architektur- und Designrichtlinien festlegen, wo ein clear() aufgerufen werden darf. Ein typischer Anwendungsfall für das Verwenden von clear() ist im Batch Processing. Hier soll vermieden werden, dass mit unnötig großen Sessions gearbeitet wird. Zudem sollte im Javadoc der Methode immer explizit darauf hingewiesen werden, ansonsten kommt es zu unvorhersehbarem Verhalten der Anwendung, wenn ein Aufruf einer Methode dazu führen kann, dass der gesamte EntityManager- Kontext gelöscht wird. Speziell heißt das, dass die Objekte über merge() wieder in den Kontext des EntityManagers eingefügt werden müssen, und dafür in der Regel der Status der Objekte aus der Datenbank neu eingelesen werden muss. Je nach Fetching-Strategien muss sogar der Status eines Objekts manuell nachgelesen werden, um alle Assoziationen wieder attached zu haben. Im schlechtesten Fall werden sogar veränderte Objektdaten nicht persistiert.

Prepared Statement HQL-Abfrage

Anti-Pattern: Unzureichendes Caching

Bei Datenbanken gilt auf allen Ebenen: Die besten Datenbankzugriffe sind die, die gar nicht ausgeführt werden müs- javamagazin 1|2009 © Software & Support Verlag GmbH www.JAXenter.de Sonderdruck sen. Hierfür werden innerhalb der Datenbank und auch bei Hibernate und JPA Caches eingesetzt. Caching ist relativ komplex und eines der am meisten missverstandenen Konzepte von Hibernate. Es existieren drei unterschiedliche Caches:

  • First Level Cache: ist immer aktiv und bezieht sich auf eine Unit of Work, d.h. meist auf einen Serviceaufruf, da er an die aktuelle Session gebunden ist.
  • Second Level Cache: kann für bestimmte Entitäten konfiguriert werden. In diesem Fall werden die Objekte in einem transaktionsübergreifenden Cache abgelegt. Der Schlüssel für die Ablage im Cache ist der Primary Key der Entität. Der Cache kann clusterweit oder innerhalb einer JVM konfiguriert werden.
  • Query Cache: speichert das Ergebnis von einer HQL/EJB-QL-Abfrage in einem Cache. Dabei werden nur die Primary Keys der Ergebnisobjekte gespeichert, die dann über den Second Level Cache geladen werden. Der Query Cache funktioniert also nur zusammen mit dem Second Level Cache.

Der effiziente Einsatz der Caches hängt von der Anwendungslogik und der richtigen Cache-Konfiguration ab. Hibernate erlaubt die Konfiguration von unterschiedlichen Cache Providern. Die bekanntesten Implementierungen sind Ehcache [3] und JBoss Cache [4]. Zunächst muss analysiert werden, ob auf eine Entität nur lesend zugegriffen oder diese häufig geändert wird. Bei lesenden Zugriffen (z.B. bei Schlüsselwerten wie Länderlisten) macht ein read-only Second Level Cache sehr viel Sinn, weil dann die Entitäten alle im Speicher liegen und so Datenbankzugriffe fast vollständig vermieden werden können. Die richtige Größeneinstellung des Caches ist aber wichtig. Gibt es zu viele Instanzen von einem Objekt und können nicht alle in den Speicher geladen werden, sollte der Einsatz eines Caches genau geprüft werden – er kann dann Sinn machen, wenn nicht alle Daten gleich häufig verwendet werden. Werden die Entitäten im Cache auch verändert, muss man entscheiden, wie wichtig die Konsistenz der Daten im Cache mit der Datenbank ist. Hibernate unterstützt transaktionale Caches, bei denen jedes Update auf dem Cache direkt in der Datenbank persistiert wird, aber auch weniger restriktive Algorithmen, die beispielsweise die Gültigkeit von Daten auf Basis von Zeitstempeln regeln. Je nach Änderungshäufigkeit und Anforderungen an die Datenkonsistenz ist der Performancegewinn des Second Level Caches unterschiedlich zu bewerten. Eine genaue Analyse des Zugriffsverhaltens mithilfe eines Profileroder Monitoring-Tools ist deshalb sehr hilfreich bei der richtigen Konfiguration des Second Level Caches.

Es sollten aber noch einige Dinge beachtet werden, wenn der Second Level Cache eingesetzt wird, weil es häufig zu Verwunderungen kommt, dass selbst mit eingeschaltetem Second Level Cache noch Queries abgesetzt werden. Die Entitäten im Second Level Cache werden über ihren Primary Key identifiziert – das bedeutet im Umkehrschluss auch, dass nur die Queries aus dem Cache gelesen werden, die eine Entität über den Primary Key lesen. Hat man beispielsweise eine Klasse Person, die über eine eindeutige, laufende Nummer als Primary Key identifiziert werden kann, so können nur Abfragen durch den Second Level Cache optimiert werden, die eine Person über die laufende Nummer lesen. Werden beispielsweise Personen über Name und Vorname gesucht, würden diese Abfragen am Second Level Cache vorbei direkt auf die Datenbank gehen und man hätte keine Performance Ersparnisse, obwohl ein Second Level Cache für Personen existiert. Um auch bei der Suche nach Vor- und Nachnamen einen Cache nutzen zu können, muss für die Query der Query Cache aktiviert werden. In diesem Fall werden alle Primary Keys der Personen aus der Ergebnissemenge im Cache abgelegt. Erfolgt eine erneute Suche über den gleichen Namen, werden die Primary Keys aus dem Query Cache gelesen und mit ihnen eine Anfrage an den Second Level Cache ausgeführt, der dann die Personen aus dem Cache zurückliefern kann. Nur so können die Datenbank-Queries vermieden werden.

Ein weiterer Pitfall ist, dass die Daten im Second Level Cache nicht als Objekte, sondern in einer so genannten dehydrierten Form abgelegt sind. Die dehydrierte Form ist eine Art Serialisierung der Entitäten. Wird eine Entität aus dem Second Level Cache gelesen, muss Sie „dehydriert“ bzw. deserialisiert werden. Werden große Ergebnismengen auf eine Query zurückgegeben, die alle im Second Level Cache liegen, kann auch das zu Performanceproblemen führen. Jedes der Objekte muss aus dem Cache gelesen und deserialisiert werden. Vorteil ist, dass jede Transaktion eine eigene Objektinstanz vom Cache zurückgeliefert bekommt und so Concurrency-Probleme vermieden werden können – der Second Level Cache ist also Threadsafe. Gerade bei Schlüsselwerten, auf die nur lesend zugegriffen wird, kann der Second Level Cache im Vergleich zu einem normalen Objektcache langsamer sein. Auch hier empfiehlt es sich, die Anwendung genau zu analysieren und gegebenenfalls die Schlüsselobjekte in einem Cache oberhalb der Datenbankzugriffsschicht zu cachen. Die Details der unterschiedlichen Cache-Konfigurationen können hier nur angerissen werden, aber der richtige Einsatz der unterschiedlichen Caches ist gerade bei stark frequentier- ten Anwendungen unerlässlich, um eine gute Performance zu erreichen.

Anti-Pattern: Schlüssel zum Erfolg

Beim Einsatz der richtigen Primärschlüssel gibt es viele Diskussionen. Nutzt man so genannte Surrogate, bei denen der Primary Key ein vom System generierter Schlüssel ist (z.B. eine laufende Nummer vom Typ long), oder fachliche Schlüssel, bei denen sich die Schlüssel aus fachlichen Attributen der Entität zusammensetzen? Wichtig ist zu verstehen, welche Auswirkungen die Wahl des Schlüssels auf die Performance des Systems hat. Ein Surrogat hat den großen Vorteil, dass er relativ simpel ist und meistens aus nur einem numerischen Attribut besteht – bei der Prüfung von Entitäten auf Gleichheit muss also nur eine numerische Vergleichsoperation ausgeführt werden. Gerade bei JPA und Hibernate werden Entitäten sehr häufig verglichen, wenn beispielsweise geprüft wird, ob sich eine Entität in einem der Caches befindet. Ein fachlicher Schlüssel kann aus einer großen Menge von Attributen (gerade wenn er über Tabellen hinweg vererbt wird) mit unterschiedlichen Datentypen bestehen. Der Vergleich zweier Entitäten ist damit um Einiges aufwändiger, weil die Entitäten dann gleich sind, wenn alle Attribute identisch sind. Bei fachlichen Schlüsseln sollte also besonders viel Wert auf die equals()- und hashCode()-Methoden der Primary-Keys gelegt werden – hier kann unter Umständen ein Performanceengpass vermieden werden. Anti-Pattern: Explizite Queries Die einfachste Möglichkeit in Hibernate Daten abzufragen, ist das Verwenden der query()-Methode, um die Abfrage als String zu übergeben. Hibernate, oder JPA, bietet hier die Möglichkeit, Parameter zu verwenden und diese dann explizit an Werte zu binden. Dieser Ansatz macht auf der Ebene von Hibernate für Abfragen Sinn, die mehrmals wiederholt werden. Für eine einzelne Abfrage scheint sich hier kein Mehrwert zu ergeben. Sieht man sich den Ausführpfad einer Hibernate-Abfrage an, stellt man fest, dass Hibernate für jede Abfrage ein PreparedStatement verwendet. Dieses verwendet die HQL-Abfrage als Basis, um daraus die SQL-Abfrage zu generieren. Werden keine Parameter verwendet, wird wie im folgenden Beispiel ein PreparedStatement mit der expliziten Abfrage generiert. Dieses Prepared- Statement kann natürlich nicht wiederverwendet werden – außer es wird der exakt gleiche Datensatz geladen. Dadurch werden hier unnötige Ressourcen, meistens Cursor, auf der Datenbank verbraucht. Zusätzlich kann das Statement auch von anderen Transaktionen nicht wiederverwendet werden. Es empfiehlt sich also, immer parameterbasierte Abfragen zu verwenden, um den Vorteil von Prepared Statements auf der darunterliegenden Schicht optimal nutzen zu können. Weitere Informationen zu Prepared Statements folgen im nächsten Artikel.

Fazit

In diesem Artikel können die möglichen Fallstricke und Anti-Patterns beim Einsatz von Hibernate und JPA nur angerissen werden. Die Beispiele zeigen aber, dass der Einsatz von Hibernate und JPA nicht so einfach ist, wie das API es vermuten lassen würde. Für eine gute Performance müssen die Frameworks richtig konfiguriert und eingesetzt werden. Der Einsatz von Profilern und Monitoring- Tools während der Entwicklung ist deshalb besonders wichtig, weil so mögliche Engpässe frühzeitig identifiziert werden können – so kann man aus der Blackbox O/R Mapper eine Whitebox machen.

Links & Literatur

[1] Hibernate: www.hibernate.org

[2] Generic DAO Pattern: www-128. ibm.com/developerworks/java/ library/j-genericdao.html

[3] Ehcache: ehcache.sourceforge.net

[4] JBoss Cache: www.jboss.com/ products/jbosscache

[5] Hibernate-Caching-Anleitung: www.codecentric.de/export/sites /www/_resources/pdf/hibernate_ performance_tuning.pdf

[6] Novakovic, Reitbauer: Performance Anti-Patterns, Teil 1, Java Magazin 12.08

Vollständiger Artikel