×

Hibernate – 24x schneller Datenbankperformance ohne ORM

Einleitung

Hibernate ist eines der gängigsten ORM (Object Relational Mapping)-Frameworks und aus der Java-Welt nur schwer wegzudenken. Es erleichtert Java-Entwicklern die Interaktion mit relationalen Datenbanken. Doch auch wenn zahlreiche Mechanismen von Hibernate Java-Entwicklern das Leben enorm erleichtern, können sie in gewissen Szenarien durchaus zum Fallstrick werden.

In einer sehr datenintensiven Anwendung haben wir Hibernate durchgehend für sowohl kleine als auch große Datenbankabfragen verwendet. Dabei mussten wir feststellen, dass einige große Datenbankabfragen inakzeptable Laufzeiten von bis zu zehn Minuten aufwiesen. Eine Analyse zeigte, dass die Ursache der Performanceproblematik in der Art und Weise lag, in der Hibernate die Abfragen strukturierte. Anstatt auf entsprechende Hibernate-Mechanismen zur Lösung des Problems zurückzugreifen, nahmen wir das Problem zum Anlass eines Experiments: Wir entschieden uns, für dieses spezielle Problem Hibernate als Vermittlungsebene auszulassen und stattdessen eine native Lösung selbst zu entwickeln.

Problemstellung

Die betroffene Softwarelösung wies einen für Java-Anwendungen typischen Technologiestack auf: Eine Spring-Boot-Anwendung, welche Hibernate als ORM-Framework nutzte, um ihre Daten in einer relationalen Datenbank zu verwalten.

Die genannten Performanceprobleme traten bei spezifischen Datenabfragen auf, in denen mehrere Hunderttausend Zeilen aus einer Tabelle mit Dutzenden von Millionen Zeilen geladen werden mussten und zusätzlich Joins auf weitere Tabellen notwendig waren. Für ein besseres Verständnis der Performance-Problematik, müssen wir zunächst genauer verstehen, was Hibernate eigentlich tut, wenn es für uns Java-Objekte aus der Datenbank abruft.

Was macht Hibernate?

ORM allgemein

Wie oben bereits erwähnt, ist Hibernate ein leistungsstarkes ORM-Framework, wobei ORM für Object Relational Mapping steht. Dieser Begriff beschreibt Hibernates Aufgabe ziemlich treffend: Es überbrückt die Lücke zwischen den Welten der objektorientierten Programmierung und der der relationalen Datenbanken. Es ermöglicht, Java-Objekte direkt in die Datenbank zu speichern, aus ihr zu laden und sie zu aktualisieren, ohne dafür erst aufwendig Konvertierungslogik schreiben zu müssen.

ORM konkret

Während Daten in objektorientierten Sprachen wie Java als hierarchische Objektbäume strukturiert werden, werden Daten in Datenbanken in flachen Tabellen organisiert, welche sich per Fremdschlüssel referenzieren.

Möchte man also eine objektorientierte Datenstruktur in einer Datenbank persistieren, so müssen die einzelnen Hierarchieebenen in separaten Tabellen gespeichert werden und dort per Fremdschlüssel referenziert werden. Will man die Daten wieder laden, so muss explizit eine SQL-Abfrage geschrieben werden, welche die Daten aus den verschiedenen Tabellen zusammensucht. Das Ergebnis dieser Abfrage muss dann in eine Java-Objektstruktur konvertiert werden.

Sehen wir uns hierzu ein Beispiel an. Wir haben zwei Entitäten, eine Hauptentität Parent und eine untergeordnete Entität Child. Jede Instanz von Parent besitzt genau eine Instanz von Entität Child.

@Entity
@Table(name = "parent")
public class Parent {
@Id
private UUID id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
private Child child;
}
@Entity
@Table(name = "child")
public class Child {
    @Id
    private UUID id;
}

Wenn wir nun eine Instanz von Parent anhand ihrer ID aus der Datenbank laden wollen, so muss Hibernate mehrere Dinge tun:

  1. Die korrekte Zeile anhand ihrer ID aus der Tabelle der Entität Parent laden
  2. Die im Attribut child referenzierte Zeile aus der Tabelle für Entität Child laden
  3. Aus beiden geladenen Objekten ein gemeinsames Java-Objekt erstellen

Genau in diesem Prozess lag die Ursache der Performanceprobleme.

Hibernates 1-zu-1-Beziehungen

Wenn Hibernate eine Entität mit 1-zu-1-Beziehung lädt, so macht es dies standardmäßig in zwei Schritten.

  1. Es lädt in einer ersten Abfrage die Hauptentität,
  2. in einer zweiten Abfrage lädt es die Kindentität nach

Es kommt also zu zwei Abfragen. Dies ist beim Laden eines einzelnen Parent-Objekts kein Problem. Doch was geschieht, wenn – wie in unserem Anwendungsfall – 300.000 Parent-Objekte geladen werden?

In diesem Fall lädt Hibernate zunächst die 300.000 gesuchten Zeilen aus der Parent-Tabelle. Anschließend löst es für jedes geladene Objekt die 1-zu-1-Beziehung in einer separaten Abfrage auf. Das heißt, dass Hibernate im Anschluss an die ursprüngliche Abfrage 300.000 zusätzliche Einzelabfragen für die Kindobjekte durchführt, was enorm ineffizient ist.

Unsere Lösung

Wir haben uns dafür entschieden, für die großen performancekritischen Abfragen die Abrufe ohne Hibernate nativ und ohne Joins zu implementieren. Die Strategie war hier die folgende:

  1. Hauptentität als Primärschlüsselabfrage laden
  2. Rohdaten in Java-Objekte umwandeln und für Kindobjekte zunächst Dummies anlegen
  3. Kindzeilen in einzelner Abfrage laden
  4. Dummy-Kindobjekte mit Daten befüllen

Hauptentität als Primärschlüsselabfrage laden

Über ein parametrisiertes PreparedStatement werden die benötigten Zeilen aus der Parent-Tabelle geladen. Das Resultat ist ein ResultSet, aus welchem die Zeilen Schritt für Schritt abgerufen werden können.

Rohdaten in Java-Objekte umwandeln

Aus dem ResultSet werden die Zeilen ausgelesen und die Daten in Java-Objekte geschrieben. Aus dem ResultSet müssen die Daten spaltenweise ausgelesen werden. Da wir nur die Daten aus der Parent-Tabelle haben, enthält die Spalte für das Attribut child lediglich die ID des referenzierten Child-Objekts. Wir erstellen an dieser Stelle zwar bereits ein Kindobjekt, setzen aber zunächst nur sein ID-Attribut. Alle anderen Attribute bleiben leer.

Daten der Kinder per Primärschlüsselabfrage in einer einzelnen Abfrage laden

Um die Kindzeilen effizient in einer einzelnen Datenbankanfrage abrufen zu können, werden die entsprechenden IDs der Child-Objekte gesammelt. Über ein weiteres PreparedStatement werden anschließend die Kindzeilen in einer einzelnen Primärschlüsselabfrage geladen.

Kindobjekte mit Daten befüllen

Aus dem ResultSet werden die Daten in die bisher leeren Kindobjekte übertragen. Nun verfügen wir über vollständige Java-Objekte. Der Gesamtabruf ist somit abgeschlossen und das Ergebnis liegt strukturiert in Form typsicherer Java-Objekte vor.

Alternativen

JPA verfügt über verschiedene Typen des Joins. Neben den allgemein bekannten Joins wie LEFT JOIN, RIGHT JOIN oder INNER JOIN, existiert auch der besondere FETCH JOIN. Wird die Kindreferenz in der JPQL-Query direkt per FETCH JOIN angefordert, so lädt Hibernate die entsprechende Referenz direkt, anstatt sie in separaten Einzelabfragen nachzuladen.

@Query("SELECT p FROM parent p JOIN FETCH p.child")

Evaluation der Lösung

Vorteile

Die Lösung führte zu einem enormen Performancegewinn. Eine enorm sehr große Datenabfrage, welche zuvor über JPA bis zu 8 Minuten benötigte, lieferte ihre Antwort nun in unter 20 Sekunden. Der Hauptgrund ist darin zu sehen, dass das Abrufen der referenzierten Entitäten lediglich über zwei Primärschlüsselabfragen stattfindet. Solche Abfragen sind in Datenbanken stark optimiert.

Auch im Vergleich mit Stichproben, in denen Fetch-Joins eingesetzt wurden, zeigte die native Implementierung mit zunehmender Datenbankgröße einen Performancevorteil. Fetch-Joins zeigten für sich aber bereits einen enormen Performancegewinn, weshalb der Vorteil der nativen Implementierung hier deutlich geringer ausfiel.

Nachteile

Ungeachtet des erreichten Performancegewinns hat die hier vorgestellte Eigenimplementierung zahlreiche Nachteile:

  1. Die Lösung benötigt über native Datenbankabfragen einen deutlich höheren Implementierungs- und Testaufwand. Während eine Lösung über Hibernate nur eine einzige JPQL-Abfrage benötigt, muss die komplette Abfragelogik selbst implementiert werden.
  2. Die Korrektheit jeder selbstgeschriebenen Zeile muss eigens überprüft werden, während die Abfragemechanismen Hibernates von einer weltweiten Community täglich eingesetzt werden und somit praxiserprobt sind.
  3. Das System ist schwieriger zu erweitern, da jegliche Anpassung über zusätzliche Tests abgesichert werden muss.

Fazit

Hibernate ist ein enorm nützliches Framework und nimmt Entwicklern viel Implementierungs- und Testaufwand ab. Es bringt jedoch auch einen hohen Overhead im Vergleich zu direkten Datenbankabfragen mit sich. Solange es zu keinen ernsthaften Performanceproblemen kommt, ist die Verwendung eines solch etablierten Frameworks ein großer Gewinn. Sollte es bei der Abfrage großer Datenmengen jedoch zu Performanceproblemen kommen, ist eine Analyse empfehlenswert. Die Wahrscheinlichkeit ist hoch, dass sich die Probleme mit Mechanismen wie Fetch-Joins auf der Ebene des Frameworks bereits zufriedenstellend lösen lassen.

Eine vollständige, manuelle Eigenimplementierung, wie sie hier vorgestellt wurde, hat durchaus das Potential, maximale Performance im Vergleich zu einfacheren Alternativen zu erreichen. Stellt man dieses Potential aber ins Verhältnis mit den beschriebenen Nachteilen, ist eine Lösung über das Framework selbst stets zu bevorzugen. Unabhängig davon war die Implementierung einer nativen Lösung eine lehrreiche Erfahrung, welche sich im praktischen Einsatz als sehr performant und stabil erwiesen hat.

Von Christian Schmitz | 13.04.2026
Christian Schmitz

Softwareentwicklung