CodeGym /Kurse /Hibernate deep-dive /Ladestrategie nach Szenario

Ladestrategie nach Szenario

Hibernate deep-dive
Level 7, Lektion 3
Verfügbar

1. Strategie: nach Szenario, nicht nach Annotation

Baseline ist bereits geschafft: Ein und dasselbe Szenario „Bestellliste + customer.email“ hat gezeigt, dass SQL nicht aus dem Nichts kommt. Nun ist die nächste Frage wichtig: Ist das überhaupt ein Listenszenario oder haben wir es bereits unbemerkt in eine Halbkarte pro Zeile verwandelt?

Man möchte besonders am Anfang „Hibernate mit einer einzigen Einstellung besiegen“. Das ähnelt dem Gedanken: „Ich setze überall ein Passwort "12345", um es nicht zu vergessen.“ Sieht praktisch aus, aber die Folgen … sind schnell spürbar.

In ORM ist die Ladestrategie keine Frage der Schönheit einer Annotation, sondern des Lesevertrags. In einer Bestellliste werden normalerweise kompakte Daten benötigt: Bestellnummer, Status, Gesamtbetrag, Erstellungsdatum. In einer Bestellkarte werden Kunde, Lieferadresse und Positionen benötigt. In einer Produktkarte werden ProductDetails benötigt, während in einer Katalogliste meistens sku, name, status, price ausreichen.

Das Problem beginnt, wenn wir versuchen, einen einzigen „universellen Graphen“ auf Entity-Ebene zu erstellen: „Lass alles EAGER sein, dann fällt es bestimmt nicht aus.“ Das ergibt eine Universalität auf dem Niveau von „immer eine Winterjacke tragen“. Sie fallen nicht – ja, aber im Sommer wird es schwer.

In unserem Projekt Commerce Persistence Lab wird dies besonders deutlich: PurchaseOrder ist mit Customer verknüpft, enthält eine Kollektion OrderItem, und Product hat ProductDetails. Dies sind natürliche Domänenbeziehungen, aber die Lesevorgänge der Domäne sind unterschiedlich. Wenn wir sie nicht in Gedanken und im Code trennen, landen wir unweigerlich bei zwei Extremen: entweder N+1 (Nachladen „einzeln“) oder Overfetching (Laden von Überflüssigem).

Lesevertrag: Was die Methode zurückgibt

Jetzt kommt eine wichtige Wendung. Wir hören auf zu denken „was es mir bequem macht, im Objekt zu haben“ und beginnen zu denken „was ich in diesem Szenario zeigen/zurückgeben muss“. Das ist eine kleine Änderung der Formulierung, rettet aber vor einer Menge unnötigem SQL.

Stellen Sie sich vor, Sie schreiben eine Methode printOrderList() für eine Backoffice-Tabelle mit Bestellungen. Diese Methode ist nicht „irgendwie die Bestellungen ausdrucken“. Sie muss eine bestimmte Menge an Feldern ausgeben (oder zurückgeben). Wenn im Vertrag der Liste keine Bestellpositionen enthalten sind, dann ist jeder Zugriff auf order.getItems() innerhalb dieser Methode überflüssig. Nicht „könnte nützlich sein“, sondern eindeutig überflüssig: Es verletzt den Vertrag und macht den Preis des Lesens unvorhersehbar.

Um dies etwas anschaulicher zu machen, halten wir eine einfache Tabelle fest: „Szenario → welche Daten werden wirklich benötigt“. Das ist kein SQL, kein Hibernate, sondern die Bedeutung.

Szenario Beispielergebnis Root-Entity Welche Beziehungen dürfen berührt werden
Bestellliste (Tabelle) orderNumber, status, totalAmount, createdAt PurchaseOrder normalerweise keine; maximal – „IDs“ über Proxy
Bestellkarte Kunde, Adresse, Positionen PurchaseOrder customer, items (und vorsichtig – was innerhalb von items)
Produktliste (Katalog) sku, name, status, price Product normalerweise berühren wir details nicht
Produktkarte Beschreibung, Eigenschaften, Garantie Product details (weil das der Sinn der Karte ist)

Beachten Sie eine wichtige Idee: Wir diskutieren noch nicht, wie genau diese Beziehungen effizient geladen werden. Wir halten nur ehrlich fest, ob sie überhaupt in diesem Szenario benötigt werden. Das ist wie das Erstellen einer Einkaufsliste, bevor Sie mit einem Einkaufswagen in der Größe „Kleinbus“ durch den Laden rennen.

4. Access Pattern: Getter verändern den Preis

Hibernate liest nicht Ihre Gedanken. Es liest Ihren Code. Wenn Ihr Code eine Beziehung berührt, muss Hibernate korrekten Zugriff auf die Daten gewährleisten. Und dafür muss es manchmal SQL ausführen. In diesem Moment wird die „Ladestrategie“ von einer Abstraktion zu einer sehr konkreten Sache: welche Getter Sie aufrufen.

Das ist besonders tückisch in Java, weil ein Getter harmlos aussieht. order.getCustomer() – scheint nur ein Feldzugriff zu sein. Aber in ORM kann das ein „Knopf: mach SQL“ sein. Und schon haben Sie nicht „die Bestellnummer ausgedruckt“, sondern einen Spaziergang von Hibernate zur Datenbank und zurück veranstaltet.

Sehen wir uns ein minimales Beispiel aus unserer Domäne an. Zuerst – ein „Listen“-Zugriff, bei dem wir nur Felder der Root-Entity verwenden:

// Listen-Szenario: Arbeiten NUR mit Feldern der Root-Entity.
// Wichtig: Beziehungen nicht berühren, um unnötiges SQL zu vermeiden.
String row = order.getOrderNumber() + " " + order.getStatus();
System.out.println(row); // ORD-2026-0001 NEW

Hier gibt es keinen Zugriff auf Beziehungen. Wenn wir eine Liste von PurchaseOrder geladen haben, arbeiten wir mit dem, was bereits im Objekt vorhanden ist.

Und nun eine kleine Änderung – und das Szenario ist keine „Liste von Überschriften“ mehr:

// ACHTUNG: getCustomer() kann zum Nachladen des Kunden führen (SQL),
// insbesondere wenn customer — LAZY und wir in einer Schleife über die Bestellliste sind.
String row = order.getOrderNumber() + " " + order.getCustomer().getEmail();
System.out.println(row); // ORD-2026-0001 alice@example.com

Aus geschäftlicher Sicht ist das „nur eine zusätzliche Spalte Email“. Aus Sicht von Hibernate ist es eine Anforderung, Kundendaten bereitzustellen. Und wenn wir das in einer Schleife über die Bestellliste tun, sind wir sehr nahe an N+1.

Eine noch tückischere Variante sind Kollektionen. Manchmal denkt man: „Ich durchlaufe items nicht, ich erfahre nur die size().“ Hibernate antwortet: „Oh, eine Kollektion, muss initialisiert werden.“

// Falle: Für Hibernate kann size() bei einer persistenten Collection SQL erfordern.
// Das heißt, es ist keine "konstante Operation" wie bei einem gewöhnlichen ArrayList.
int itemsCount = order.getItems().size();
System.out.println(itemsCount); // 3

Für JDK ArrayList.size() ist das eine konstante Operation. Für Hibernate persistent collection kann size() eine SQL-Abfrage bedeuten. Daher ist das Access Pattern wirklich ein „Vertrag über Getter“, ob Sie es wollen oder nicht.

5. Lesungen trennen: Liste und Karte

Der nächste Schritt ist sehr praktisch und hat fast immer sofortige Wirkung, selbst ohne feine Optimierung: Leseszenarien im Code trennen. Nicht versuchen, eine Methode zu machen, die sowohl „Liste“, als auch „Karte“ und auch „ein bisschen Details“ ist. Universelle Methoden im Persistenzcode sind normalerweise nur in einer Sache universell: Sie sind universell teuer und unvorhersehbar.

Wir werden hier keinen weiteren Demo-Service einführen. Einfacher und ehrlicher ist es, denselben OrderReadService, den wir bereits im Baseline-Szenario gemessen haben, in zwei verschiedene Lese-Verträge aufzuteilen: Liste und Karte.

Beispiel für das Gerüst desselben Bestell-Lese-Service:

package com.example.commerce.orders.query;

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderReadService {
    private final EntityManager em;

    public OrderReadService(EntityManager em) {
        this.em = em;
    }

    @Transactional(readOnly = true)
    public void printOrdersWithCustomerEmail() {
        // Dies ist das Baseline-Szenario aus der Messung: Liste der Bestellungen + Zugriff auf customer.email in der Schleife.
    }

    @Transactional(readOnly = true)
    public void printOrderList() {
        // Szenario "Liste": Der Vertrag sollte kompakt bleiben.
    }

    @Transactional(readOnly = true)
    public void printOrderCard(Long orderId) {
        // Szenario "Karte": Hier können Beziehungen bereits in den Vertrag eingehen.
    }
}

Hier sind zwei Dinge wichtig. Erstens: Getrennte Methoden – getrennte Verträge und getrennte Erwartungen an SQL. Zweitens: readOnly = true erinnert uns daran, dass dies ein Leseszenario ist, nicht „versehentlich etwas geändert und ein unerwartetes UPDATE bekommen“. Wir vertiefen uns heute nicht in Transaktionen, aber die Disziplin readOnly hilft, den Kopf im richtigen Modus zu halten.

6. Bestellungen: Liste ohne Nachladen

Das Listenszenario ist der Ort, an dem der Preis eines Fehlers maximal ist. Wenn eine Bestellkarte 5 Abfragen gelesen hat, ist das ärgerlich, aber normalerweise tolerierbar, weil die Karte einzeln ist. Wenn eine Liste mit 200 Zeilen zu 201+ Abfragen wird, ist das bereits ein Problem.

Daher sollten wir in der „Bestellliste“ bewusst auf das beschränken, was wirklich der Titel ist. Zum Beispiel drucken wir nur die Nummer und den Status. Im Beispiel unten berühre ich bewusst nicht den Kunden und die Positionen – nicht einmal „nur mal schauen“.

@Transactional(readOnly = true)
public void printOrderList() {
    // Wir verwenden dieselbe Root-Abfrage, halten das Szenario jedoch in den Grenzen der Liste.
    var orders = em.createQuery("select o from PurchaseOrder o", PurchaseOrder.class)
            .getResultList();

    for (var o : orders) {
        // Nur einfache Felder der Bestellung berühren: Das ist der Vertrag der Liste.
        System.out.println(o.getOrderNumber() + " " + o.getStatus()); // ORD-... NEW
    }
}

Ja, das sieht „zu einfach“ aus. Und darin liegt der Sinn: Die Einfachheit des Szenarios ist Ihre Kontrolle. Eine Liste muss kompakt sein. Wenn das Geschäft plötzlich sagt „wir wollen auch die Kunden-E-Mail“, ist das keine „kleine Bitte“, sondern eine Änderung des Szenarios. Und Sie sollten dies als Ingenieur behandeln: „Okay, jetzt ist die Liste reicher geworden, und wir müssen das Lesen dafür entwerfen“.

Hier ist es wichtig, zwei Dinge zu unterscheiden. Ein solches Refactoring entfernt das versehentliche Durchlaufen (accidental traversal): Die Liste greift nicht mehr auf customer und items zu, nur weil jemand einen Getter in die Schleife eingefügt hat. Das hebt jedoch nicht den Preis des Mappings selbst auf. Wenn eine Beziehung bereits durch EAGER aufgezwungen wird oder der Standard to-one unnötiges Laden mit sich bringt, kann die Liste auch ohne explizite Getter teuer bleiben. Dann liegt das Problem nicht mehr im Access Pattern, sondern im gesamten Fetch-Ansatz.

7. Bestellungen: Karte und Datentiefe

Eine Bestellkarte erfordert fast immer Beziehungen: Ohne Kunden und Positionen ist es keine Karte, sondern eine traurige Bestellnummer in der Leere. Hier können wir bereits auf customer und items zugreifen, da dies dem Vertrag der Karte entspricht.

Aber selbst in der Karte ist es nützlich, sich an die „zweite Ebene von N+1“ zu erinnern. Wenn Sie beispielsweise in einer Schleife über OrderItem auf item.getProduct().getDetails() zugreifen, können Sie eine Reihe von Abfragen zu Produkten erhalten. Daher enthält das Domänenmodell des Projekts nicht zufällig Snapshot-Felder in OrderItem: productNameSnapshot, productSkuSnapshot. Eine Bestellkarte kann Positionen meistens ohne Laden des vollständigen Product anzeigen.

Mini-Fragment, wie man eine Karte drucken kann, gestützt auf Snapshot statt auf Nachladen des Produkts:

// Snapshot-Felder sind eine Möglichkeit, eine Bestellposition anzuzeigen,
// ohne das Laden von Product/ProductDetails pro Zeile zu provozieren.
String line = item.getProductSkuSnapshot() + " x" + item.getQuantity();
System.out.println(line); // SKU-001 x2

Und hier ein Beispiel desselben Karten-Service (vereinfacht und kurz). Es zeigt die Idee: Eine Karte darf Beziehungen berühren, tut dies aber sinnvoll:

@Transactional(readOnly = true)
public void printOrderCard(Long orderId) {
    // Für die Karte lesen wir eine bestimmte Bestellung.
    PurchaseOrder o = em.find(PurchaseOrder.class, orderId);

    // Einfache Felder der Root-Entity – sicherer Teil der Karte.
    System.out.println(o.getOrderNumber());                 // ORD-2026-0001

    // ACHTUNG: Zugriff auf customer kann zusätzliches SQL auslösen (falls LAZY).
    System.out.println(o.getCustomer().getEmail());         // alice@example.com

    // ACHTUNG: size() der Kollektion kann das Laden von items auslösen (SQL).
    System.out.println(o.getItems().size());                // 3
}

Selbst dieser Code kann bereits mehrere Abfragen liefern (und das ist für eine Karte normal), aber er entspricht wenigstens ehrlich dem Szenario. Die Gefahr beginnt, wenn dieser „Karten“-Code versehentlich in eine Listenmethode übernommen wird, „damit es in der Tabelle schön aussieht“.

8. Katalog: Liste und Produktkarte

Der Katalog ist ein klassischer Ort, an dem Entwickler entweder zu viel laden oder zu oft nachladen. In unserem Projekt wurde ProductDetails nicht aus Liebe zur Komplexität in eine separate Entität ausgelagert, sondern weil Produktdetails nicht immer benötigt werden. In der Katalogliste stören sie meistens nur, während sie in der Karte das „Herz“ des Szenarios sind.

Das Listen-Szenario für Produkte sieht normalerweise so aus: sku, name, status, price. Und wenn Sie in diesem Szenario auf product.getDetails().getDescription() zugreifen, haben Sie die Liste in eine Reihe von Einzelzugriffen auf Details verwandelt, also potenziell in N+1.

Mini-Beispiel einer Katalogliste:

// Szenario "Katalogliste": Produktdetails werden normalerweise nicht benötigt.
// Daher greifen wir in diesem Pfad nicht auf product.getDetails() zu.
String row = product.getSku() + " " + product.getName();
System.out.println(row); // SKU-001 Coffee Grinder

Die Produktkarte ist eine andere Geschichte. Hier ist der Zugriff auf details durch den Sinn der Karte gerechtfertigt, daher ist ein „teureres“ Lesen zu erwarten, Hauptsache es ist kontrolliert:

// Szenario "Produktkarte": details sind Teil des Vertrags,
// daher ist der Zugriff hier logisch (und erwartungsgemäß kann es "teurer" sein).
String card = product.getName() + ": " + product.getDetails().getDescription();
System.out.println(card); // Coffee Grinder: Compact burr grinder...

Beachten Sie: Wir wählen noch nicht die technische Methode „wie mache ich das mit einer Abfrage“ oder „wie optimiere ich“. Für uns ist jetzt wichtig: Die Liste nicht nach den Regeln der Karte leben zu lassen. Das ist Use-Case-driven Fetching auf der Denkebene, noch vor allen Werkzeugen.

9. Algorithmus zur Auswahl der Ladestrategie

Man möchte die „richtige Strategie“ als fertiges Rezept erhalten. Aber in der Realität beginnt das Rezept mit Disziplin. Die gute Nachricht: Sie ist ziemlich mechanisch und kann selbst mit wenig Erfahrung angewendet werden – wenn Sie die Schritte ehrlich befolgen und sie nicht durch „wird schon gutgehen“ ersetzen.

Zuerst nennen Sie das Leseszenario und formulieren sein Ergebnis. Nicht „Bestellungen holen“, sondern „Zeilen für die Bestelltabelle holen“. Dann schreiben Sie auf, welche Felder wirklich benötigt werden. Danach schauen Sie auf den Code und prüfen: Welche Getter werden aufgerufen, welche Beziehungen werden berührt. Dann messen Sie den Baseline-Abfragezähler. Und erst danach entscheiden Sie, was zu tun ist: Vielleicht reicht es, den Zugriff auf die Beziehung aus dem Listenpfad zu entfernen – und das Problem verschwindet bereits in diesem Schritt.

Um dies leichter im Kopf zu behalten, hier ein Flussdiagramm (im Stil „wenn man ihm folgt, hat man weniger Chancen, auf Rechen zu treten“):

flowchart TD
    A["Benenne das Leseszenario"] --> B["Bestimme das Ergebnis: Welche Felder werden benötigt"]
    B --> C["Prüfe das Access Pattern: Welche Getter werden tatsächlich aufgerufen"]
    C --> D["Nimm Baseline auf: SQL Trace + Query Count"]
    D --> E{"Ist das Szenario eine Liste?"}
    E -->|ja| F["Entferne unnötige Zugriffe auf Beziehungen aus der Liste"]
    E -->|nein, Karte| G["Erlaube benötigte Beziehungen, aber bewusst"]
    F --> H["Wiederhole die Messung mit demselben Datenset"]
    G --> H["Wiederhole die Messung mit demselben Datenset"]

Dieses Schema verspricht nicht „immer eine einzige SQL-Abfrage“. Es verspricht etwas Wichtigeres: Sie werden aufhören, sich über das Geschehen zu wundern, weil Sie selbst vorher beschrieben haben, was das Szenario macht.

Und hier stellt sich die nächste technische Frage. Wenn Sie nach einem solchen Audit sehen, dass die Beziehung tatsächlich Teil des Lesevertrags ist, reicht es nicht mehr, sie einfach „nicht in der Liste zu berühren“. Sie müssen entscheiden, mit welchem Fetch-Ansatz diese Daten ohne unnötigen Preis geladen werden sollen.

10. Typische Fehler bei der Auswahl der Strategie

Fehler im Fetching-Design passieren normalerweise nicht, weil jemand ein „schlechter Entwickler“ ist, sondern weil das Gehirn versucht, die Welt zu vereinfachen: eine universelle Lösung zu finden. ORM rächt sich nicht bösartig, sondern langweilig und teuer – mit einer großen Anzahl von SQL-Abfragen. Im Folgenden einige Fehler, die besonders häufig in der Phase auftreten, wenn Sie bereits von N+1 gehört haben, aber noch nicht gelernt haben, das Lesen als Szenario zu entwerfen.

Fehler Nr. 1: Das Lesen nach der Entity-Struktur entwerfen, nicht nach dem Ergebnis.
Wenn Sie anfangen zu denken: „Eine Bestellung hat einen Kunden, also muss die Bestellliste den Kunden anzeigen“, geben Sie das UI-Design praktisch an die Tabellen- und Beziehungsstruktur ab. Die richtige Reihenfolge ist umgekehrt: Zuerst entscheidet die Liste, was sie anzeigt, und erst dann entscheiden Sie, was dafür gelesen werden muss. Manchmal stellt sich heraus, dass der Kunde in der Liste gar nicht benötigt wird, und Sie sparen sich Dutzende Abfragen ohne eine einzige Optimierung.

Fehler Nr. 2: In einer Methode Liste und Karte vermischen.
Das ist eine sehr häufige Praxis: „Ich habe eine Methode printOrders() gemacht, sie druckt sowohl Bestellnummer als auch E-Mail des Kunden und die Anzahl der Positionen.“ Bei einem kleinen Datenset kann das „normal“ aussehen. Bei realer Tabellengröße wird daraus eine Mähdrescher, die immer im Modus „Karte für jede Bestellung“ arbeitet. Am Ende bezahlen Sie für die Tiefe auch dort, wo sie nicht benötigt wird, und erhalten ein unvorhersehbares Wachstum der Abfragen.

Fehler Nr. 3: Glauben, dass „ich das Feld nicht im Code verwendet habe“ bedeutet „ich habe nicht dafür bezahlt“.
In Java kann man leicht übersehen, dass eine Beziehung indirekt berührt wurde: über toString(), über Logging, über String-Konkatenation, über size() einer Kollektion. ORM unterscheidet nicht zwischen „ich wollte nur mal sehen“ und „ich wollte eine Geschäftsoperation“ – es sieht den Datenzugriff und versucht, Korrektheit sicherzustellen. Daher ist es in Leseszenarien nützlich, im guten Sinne paranoid zu sein: zu wissen, welche Getter tatsächlich aufgerufen werden.

Fehler Nr. 4: Die Kosten des Lesens an einem einzelnen Objekt bewerten und das Ergebnis auf die Liste übertragen.
Eine Bestellung, eine Karte – fast immer „tolerierbar“. Aber eine Liste ist eine andere Mathematik. Dort wird jedes unnötige Nachladen zu einer linearen Abhängigkeit von der Anzahl der Zeilen. Daher müssen alle Schlussfolgerungen über Fetching genau an dem Szenario geprüft werden, das eine Größe hat (Dutzende/Hunderte Zeilen), sonst trainieren Sie nicht Technik, sondern Optimismus.

Fehler Nr. 5: Sofort „optimieren“ wollen, ohne das Szenario und die Baseline festgelegt zu haben.
Wenn jemand von N+1 hört, beginnt er oft, chaotisch Einstellungen zu ändern, „nützliche“ Annotationen hinzuzufügen und Abfragen umzuschreiben. Ohne klares Szenario und Messung wird das zur Lotterie: Sie sind nicht sicher, dass Sie genau das repariert haben, was kaputt war, und können die Verbesserung nicht belegen. Viel zuverlässiger ist es, zuerst den Vertrag des Szenarios und den Baseline-Abfragezähler festzuhalten und dann eine Sache nach der anderen zu ändern.

1
Aufgabe
Hibernate deep-dive, Level 7, Lektion 3
Gesperrt
Getrennte Katalogszenarien
Getrennte Katalogszenarien
1
Aufgabe
Hibernate deep-dive, Level 7, Lektion 3
Gesperrt
Bestelltabelle und Bestellkarte
Bestelltabelle und Bestellkarte
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION