Home ORDIX AG             Dienstleistung             Trainingsshop    Kunden / Referenzen Aktuelles    Kontakt
Home  Pfeil  ORDIX News  Pfeil  3/2006  Pfeil  Java/XML
suche: 
Der Artikel richtet sich an Softwareentwickler- und architekten, die Hibernate in ihren Projekten einsetzen (wollen).

Glossar

Cache
Erhöht die Leistung einer Anwendung, indem wiederholte Zugriffe auf langsame Datenstrukturen durch Zwischenspeicherung im Hauptspeicher beschleunigt werden.
JVM
Java Virtual Machine. Programm, in dem Java-Programme ausgeführt werden. Eine JVM ist vom Betriebssystem abhängig, während das Java-Programm unabhängig vom Betriebssystem ist.

Gut gepuffert ist halb gewonnen - Reihe Hibernate (Teil III)

Caching in Hibernate

Im dritten Artikel unserer Reihe zum Hibernate-Framework widmen wir uns dem Thema Caching. Neben einem theoretischen Einstieg liefert dieser Artikel auch praktische Tipps für den Gebrauch und die Anpassung des Hibernate-Caches an die eigenen Bedürfnisse.

Warum puffern?

Das Puffern (engl.: Caching) von Daten ist in der EDV ein "alter Hut". Die Vorgehensweise basiert darauf, dass häufig benötigte Daten von einem langsamen Medium (z. B. Festplatte) in den Hauptspeicher kopiert werden.

Gleichzeitig werden Zugriffe auf diese Daten über eine Zwischenschicht geleitet. Diese prüft zunächst immer den Puffer auf die angeforderten Daten.

Sind diese dort vorhanden, so werden die Daten aus dem schnellen Puffer geliefert und nicht vom langsamen Quellmedium geladen. Man spricht von "Cache-Hits" im positiven Fall und "Cache-Misses", wenn die Daten erst in den Puffer eingelesen werden müssen.

Diese Art von Caching ist allgegenwärtig: egal, ob es der Inhalt eines BIOS-Bausteins im PC ist, oder ob es die Bilder einer häufig besuchten Website sind - sie alle werden gepuffert.

In Hibernate dient das Puffern dazu, die Anzahl an benötigten Datenbankzugriffen zu reduzieren. Ohne Puffer müsste Hibernate bei jedem Laden eines Objekts durch eine Session oder Query auf die Datenbank zugreifen.

Dies würde unweigerlich zu einer starken Belastung der Datenbank und einer spürbar langsameren Ausführungszeit der auf Hibernate basierenden Anwendung führen.

Um diese K.O.-Kriterien zu umgehen, legt Hibernate-Objekte, die initial von einer Session geladen werden, im Puffer ab. Bei einem erneuten Zugriff wird das Objekt dann bei aktuellem Pufferstatus aus diesem geliefert und die zeitaufwändige Datenbankabfrage entfällt. Andernfalls wird das Objekt neu aus der Datenbank abgefragt, und dabei auch der Puffer aktualisiert.

Und das auch noch gleich doppelt!

Intern verwendet Hibernate zum Puffern ein zweistufiges Verfahren in Form von 1st und 2nd Level Caches (siehe Abbildung 1).

Abb. 1: Aufbau des Hibernate-Caches.
Abb. 1: Aufbau des Hibernate-Caches.

Der 1st Level Cache ist kurzlebig und mehrfach vorhanden. Er wird von der aktuellen Hibernate-Session selbst implementiert und ist immer aktiv. Alle Objekte, die eine Session geladen hat, werden bis zum Ende der Session auch von dieser in ihrem 1st Level Cache abgelegt. Mit dem Schließen der Session werden auch die Referenzen aus dem zugehörigen 1st Level Cache entfernt.

Der 2nd Level Cache ist optional, langlebiger und nur einmal pro Applikation vorhanden. Er wird durch die Session-Factory über die Schnittstelle "CacheProvider" lediglich gesteuert, aber nicht direkt durch Hibernate implementiert. Hibernate liefert das Interface "CacheProvider", welches die Schnittstelle zwischen der Session-Factory und einer Cache-Implementierung definiert.

Von letzterer gibt es verschiedene Ausprägungen, insbesondere wird zwischen JVM-Level und Cluster-wide unterschieden. Clusterweite Cache-Implementierungen können über JVM-Grenzen hinaus auch Verbunde mit einem gemeinsamen Cache versorgen.

Hebel hier, ...

Generell erfolgt die Konfiguration des 2nd Level Caches zweigleisig. Hibernate selbst hat keinen direkten Einfluss auf die Konfiguration des Puffers, also z. B. auf dessen Größe. Dies erfolgt spezifisch für die jeweilige CacheProvider-Implementierung. Ein Beispiel für den CacheProvider Easy Hibernate Cache (EHCache) zeigen wir im weiteren Verlauf des Artikels.

Auf der Hibernate-Seite wird lediglich definiert, dass und wie der 2nd Level Cache genutzt werden soll. In der Standardeinstellung ist der 2nd Level Cache selbst bereits aktiv, so dass lediglich das Caching für einzelne Klassen aktiviert werden muss.

Innerhalb der Mapping-Dateien (*.hbm.xml) wird pro Klasse die zu verwendende Art des Caching definiert. Hibernate steuert damit das Verhalten bei konkurrierenden Zugriffen und kennt die in Abbildung 2 gezeigten Ausprägungen. Die Einstellung wird im Mapping unterhalb von mit dem Element vorgenommen.

Cache Mode Beschreibung
read only Für Objekte, deren Attribute niemals verändert werden.
nonstrict read/write Sollte verwendet werden, wenn eine parallele Änderung eines Datensatzes unwahrscheinlich ist. In diesem Modus ist nicht garantiert, dass die vom Cache gelieferte Version des Objekts auch die aktuell in der Datenbank vorhandene ist.
read/write Sichert die Konsistenz zwischen dem Puffer und den Sätzen in der Datenbank zu ("read committed").
transactional Sichert für den gesamten Lauf einer Transaktion zu, dass die darin enthaltenen Daten gültig sind ("repeatable read").
Abb. 2: Konfigurationsparameter für den Cache Mode.

Das in Abbildung 3 gezeigte Beispiel definiert, dass alle Instanzen des Typs Fixwert über einen read-only 2nd Level Cache vorgehalten werden. Andere Cache-Modi würden analog eingetragen, indem man das Attribut usage mit einem anderen Wert als read-only versieht.

<class name="de.ordix.Fixwert" mutable="false">
<cache usage="read-only"/>
.... 
</class>
Abb. 3: Konfiguration des Cachings für eine Klasse.

... Stellschrauben dort

Wenn Hibernate auf die Zusammenarbeit mit dem 2nd Level Cache vorbereitet wurde, gilt es noch, den Cache selbst zu konfigurieren.

Der EHCache ist der bekannteste Vertreter der JVM-Level Cache-Provider, weil dieser zusammen mit Hibernate ausgeliefert wird und auch in der Standardeinstellung bereits aktiv ist. Abbildung 4 zeigt eine einfache Beispielkonfiguration für EHCache.

<ehcache>
	<diskStore path="java.io.tmpdir"/>
	<defaultCache
		maxElementsInMemory="5000"
		eternal="true"
		overflowToDisk="true"
	/>
	<cache name="de.ordix.BigObject"
		maxElementsInMemory="10"
		eternal="false"
		timeToIdleSeconds="300"
		timeToLiveSeconds="600"
	/>
</ehcache>

Abb. 4: Konfigurationseinstellungen aus ehcache.xml.

Damit die Konfiguration beim Start automatisch eingelesen wird, muss sie lediglich unter dem Namen ehcache.xml im Klassenpfad der Anwendung liegen. Die Datei definiert Default-Einstellungen für alle Puffer und konfiguriert spezifische Pufferregionen.

Hibernate bedient EHCache so, dass es zu puffernde Klassen jeweils in eine Pufferregion legt, die dem voll qualifizierten Namen der Klasse entspricht.

Sollte eine solche Pufferregion nicht konfiguriert sein, wird beim Start eine Warnung ausgegeben und die Standardwerte aus dem Default-Cache kommen zur Anwendung.

Das Beispiel aus Abbildung 4 legt für den Default-Cache fest, dass maximal 5000 Objekte in einer Pufferregion im Speicher gehalten werden sollen (maxElementsInMemory).

Bei Überschreiten dieser Grenze werden - je nach erforderlichem Platzbedarf - Objekte auf die Disk in das temporäre Verzeichnis der JVM ausgelagert (overflowToDisk, diskstore path).

Außerdem werden Objekte niemals automatisch aus dem Cache entfernt (eternal). Für die Klasse BigObject wird die Pufferregion so eingestellt, dass maximal 10 Instanzen gepuffert werden.

Die Instanzen werden, sofern sie unbenutzt sind, nach 300 Sekunden, spätestens jedoch nach 600 Sekunden aus dem Cache entfernt. Für den Default-Cache sind im Gegensatz dazu keine Zeitwerte angegeben.

"Dieselbe Anfrage nochmal?" ;-)

Neben dem gezielten Einlesen von ganzen Objektgraphen werden oft auch Objektmengen mit Hilfe von Queries ermittelt.

Wird eine bestimmte Query wiederholt durchgeführt und verändert sich die Ergebnismenge nur selten, ist sie ein Kandidat für den Query Cache. Dieser verhindert die wiederholte Ausführung einer Datenbankabfrage, indem er sich die Ergebnismenge für eine bestimmte Query intern merkt.

Der Query Cache selbst speichert nur die IDs der Objekte innerhalb der Ergebnismenge. Die Instanzen selbst werden dann, wie bei einem Session.get(), über den 2nd Level Cache bezogen. Der Query Cache bedingt also folglich den Einsatz eines 2nd Level Caches.

Ob die Ergebnismengen innerhalb des Query Caches noch aktuell sind, prüft Hibernate intern anhand eines Zeitstempels.

Ist die letzte Änderung an einer Tabelle älter als das aktuell im Cache vorhandene Abfrageergebnis, so wird die Ergebnismenge aus dem Puffer geliefert. Andernfalls wird eine neue Abfrage gegen die Datenbank durchgeführt und der Query Cache aktualisiert.

Der Query Cache ist standardmäßig abgeschaltet und muss daher über den Schalter hibernate.cache.use_query_cache true in der Hibernate-Konfiguration aktiviert werden.

Dies erstellt zwei neue Pufferregionen für die Abfrageergebnisse (org.hibernate.cache. StandardQueryCache) und die Zeitstempel (org.hibernate.cache.UpdateTimestampsCache), die innerhalb der Datei ehcache.xml konfiguriert werden (zur Syntax siehe Abbildung 4).

Da ein Puffern der Ergebnismenge nur für bestimmte Abfragen Sinn macht, muss dies explizit im Code angefordert werden.

Dazu bietet die Klasse Query die Methode setCacheable(). Wenn vor der Ausführung der Query [z. B. mit .list()] das cacheable-Attribut auf true gesetzt wird, veranlasst dies die Nutzung des Query Caches für diese Abfrage.

Weitere Möglichkeiten

Hibernate bietet noch einiges mehr an Möglichkeiten im Bereich Caching. Über die SessionFactory lässt sich z. B. das gezielte Entleeren bestimmter oder aller Pufferregionen anstoßen.

Die Beschreibung der Methode Session-Factory.evict() erklärt, wie es funktioniert. Über Query.setCacheMode(CacheMode.REFRESH) lässt sich auch für eine Query gezielt das erneute Laden der Ergebnismenge anfordern.

Wer Zahlenmaterial benötigt (z. B. Cache Hit / Miss Ratio), sei auf die Methode SessionFactory.getStatistics() verwiesen. Alternativ kann man die Zahlen auch via JMX über ein MBean beziehen.

Darum prüfe, wer sich ewig bindet

So schön das Puffern auch ist, so sollte eines nie außer Acht gelassen werden: Ein Puffer erkennt niemals von selbst Veränderungen in der darunterliegenden Datenschicht. Wenn neben Hibernate eine weitere Anwendung schreibend auf der gleichen Datenbank arbeitet, ist es nicht ohne weiteres möglich, die Konsistenz des Puffers zu gewährleisten.

Achtung auch bei manuellen Eingriffen! Im Zweifel sollte zumindest im direkten Anschluss an ein externes Update der 2nd Level Cache geleert werden.

Eine Garantie, dass man mit dieser Methode wirklich alle Instanzen erwischt (insbesondere die in 1st Level Caches), gibt es nicht.

Im Zweifel sollte daher die Anwendung angehalten und neu gestartet werden, da man sonst Gefahr läuft, dass die gerade geänderten Daten direkt wieder durch einen alten Hibernate-Stand überschrieben werden.

Nun ist das Anhalten und Starten einer Anwendung vielleicht in Ausnahmefällen ein probates Mittel, dieser Gefahr zu entgehen.

Bei einem alltäglichen, parallelen, schreibenden Zugriff einer anderen Anwendung ist aber eine andere Lösung notwendig. Auch dafür kann Hibernate etwas aus dem Hut zaubern: "Optimistic concurrency control" ist hier das Schlagwort, über das wir in einer der nächsten Ausgaben berichten werden.

Dies ist aber auch der einzige Nachteil. Ansonsten gilt: Wenn die Puffer gut auf die Applikation abgestimmt sind, verhelfen der Object Cache und der Query Cache zu einer enormen Leistungssteigerung. Fazit: Sehr empfehlenswert.

Michael Heß (info@ordix.de).