1. Das richtige Ziel der Behebung von lazy
Wenn die Anwendung mit LazyInitializationException abstürzt, bietet das Gehirn besonders müde schnelle Lösungen auf der Ebene von „Klebeband drauf und so tun, als ob es so war“. Das ist normal: das passiert jedem. Aber in der Persistenzschicht werden solche „schnellen Siege“ oft zu langsamen Niederlagen: SQL beginnt an unerwarteten Stellen aufzutreten, es treten schwer erklärbare Verzögerungen auf und der Code wird unvorhersehbar.
Die richtige Behebung beginnt mit einem Zielwechsel. Wir „entfernen die Ausnahme“ nicht als Symptom, sondern machen das Lesen von Daten zu einem Teil einer abgeschlossenen Serviceoperation, innerhalb derer die Transaktion lebt und der persistence context verfügbar ist. Dann wird der Lazy-Mechanismus einfach zu einem Werkzeug der Effizienz und nicht zu einer Falle. Und das Schönste: SQL lebt wieder dort, wo es hingehört — neben dem Code, der den use case formuliert.
Wenn man es auf einen Satz bringt: Lazy Loading ähnelt der Lieferung von Essen: Solange das Restaurant geöffnet ist (eine aktive Sitzung vorhanden ist), können Sie noch ein Gericht bestellen; wenn das Restaurant geschlossen ist (die Transaktion beendet ist), ist es sinnlos und ein wenig traurig, zu versuchen, „nachzubestellen“.
Nach dem Gespräch über no Session und die Versuchung, die Session einfach länger leben zu lassen, ist das Bild schon ziemlich ehrlich. Ein normaler Fix liegt nicht im Web-Layer und nicht in einem Konfigurations-Flag, sondern in der Form der Serviceoperation selbst: Das Lesen sollte dort enden, wo Hibernate noch einen funktionierenden persistence context hat. Daher werden wir nicht das Symptom beheben, sondern die Lesegrenze.
Die benötigten Daten zum Lesen bestimmen
Der häufigste logische Fehler ist zu denken, dass, wenn irgendwo eine „Bestellung“ benötigt wird, die gesamte PurchaseOrder nach außen zurückgegeben werden muss. Wenn irgendwo ein „Produkt“ benötigt wird, muss das gesamte Product nach außen gegeben werden. Das ist bequem … bis zur ersten Lazy-Association. Und dann haben wir eine Entität, die „echt“ aussieht, aber im Inneren „noch nicht geladen, kommen Sie mit einer aktiven Transaktion“ enthält.
Daher ist es nützlich (und vor allem zeitlich günstig), bevor man Code schreibt, die Frage zu beantworten: Was genau wird von außen benötigt? Nicht „welche Entität“, sondern „welche Felder/Werte“. Zum Beispiel: „Für die Produktkarte wird eine Beschreibungszeichenfolge benötigt“ oder „Für eine kurze Bestellübersicht werden die Nummer und die Anzahl der Positionen benötigt“. Und dann stellt sich plötzlich heraus, dass es nicht notwendig ist, überhaupt eine entity nach außen zurückzugeben.
Nachfolgend eine kleine Tabelle als Spickzettel zum Nachdenken (das ist kein Dogma, sondern eine bequeme Art, die Anforderung zu formulieren):
| Leseszenario (Use Case) | Was wird von außen wirklich benötigt | Wo wird es am besten vorbereitet | Was passiert, wenn man die Entität zurückgibt und auf Lazy hofft |
|---|---|---|---|
| Produktkarte | sku, name, description aus ProductDetails | In CatalogService innerhalb von @Transactional(readOnly=true) | product.getDetails().getDescription() kann außerhalb der Transaktion fehlschlagen |
| Bestellübersicht | orderNumber, itemCount | In OrderQueryService innerhalb der Transaktion | order.getItems().size() kann außerhalb der Transaktion fehlschlagen |
| Liste der Positionsnamen | Liste von productNameSnapshot | Im Service: order.getItems() lesen und eine Liste zusammenstellen | im Controller/externen Code wird das Durchlaufen der Sammlung leicht eine Ausnahme auslösen |
Beachten Sie das allgemeine Motiv: Wir streiten nicht mit Lazy als Phänomen. Wir verlegen lediglich den Zeitpunkt des Datenzugriffs dorthin, wo Hibernate das Recht (und die Möglichkeit) hat, SQL auszuführen.
3. Lesegrenze: Service + @Transactional(readOnly=true)
Man möchte @Transactional gerne als „Annotation zum Schreiben“ wahrnehmen und Lesevorgänge als „sicher“ und „keine Transaktion erforderlich“ betrachten. In Hibernate ist das eine gefährliche Illusion: Für Lazy Loading braucht man kein moralisches Recht, sondern eine konkrete Tatsache — die Session muss offen sein. Und die Session lebt in einer typischen Spring-Anwendung genau im Rahmen der Transaktion der Servicemethode.
Daher sieht die korrekte Lesegrenze so aus: Der externe Code (Controller, Konsolenbefehl, Test, anderer Service) ruft eine Servicemethode auf, und diese Methode erhält innerhalb der Transaktion alle benötigten Daten und gibt das bereits fertige Ergebnis nach außen zurück. Dabei ist readOnly = true hier nicht als „Turbo-Beschleuniger“ nützlich, sondern als Deklaration der Absicht: „Ich lese und habe nicht vor zu ändern“. Dies reduziert das Risiko versehentlicher Änderungen an der verwalteten Entität (und wir erinnern uns, dass dirty checking Überraschungen bereiten kann).
Den korrekten Ablauf kann man schematisch so darstellen:
flowchart TD
A["Externer Code (Controller/Test/Kommando)"] --> B["@Transactional(readOnly=true) Service-Methode"]
B --> C["EntityManager.find(...)"]
B --> D["Zugriff auf Lazy-Associationen innerhalb der Transaktion (Proxy/Collection werden initialisiert)"]
B --> E["Ergebnissammlung (String/DTO/List)"]
E --> A
Das Wesentliche: Wir schleppen keine halbinitialisierte Entität nach außen, in der Hoffnung, dass sie irgendwie selbst das Fehlende nachliest. Wir geben sofort das zurück, was wirklich benötigt wird.
4. Einen Wert zurückgeben, nicht eine Entität: Beispiel mit ProductDetails
Manchmal ist die Behebung eines Lazy-Problems buchstäblich eine einzige Zeile … wenn man vorher akzeptiert, dass nach außen nicht das gesamte Objekt benötigt wird. Stellen Sie sich vor, die UI/der externe Code möchte nur die Beschreibung eines Produkts anzeigen. Ein Product für eine einzelne Zeile zurückzugeben ist, als würde man einen LKW mieten, um ein einziges Brötchen zu transportieren. Technisch funktioniert es, sieht aber verdächtig aus.
Welcher spezifische Lazy to-one in Ihrem Projekt vorhanden ist — customer, details oder etwas anderes — ist nicht so wichtig. Das Prinzip ist dasselbe: Die benötigten Daten müssen innerhalb der Transaktion nachgelesen und das fertige Ergebnis nach außen zurückgegeben werden.
Im Commerce Persistence Lab kann man dies als Lesemethode im Katalogservice gestalten. Beachten Sie: Wir lesen die Beschreibung absichtlich innerhalb der Transaktion. Selbst wenn details ein lazy proxy ist, wird er „vor Ort“ initialisiert.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogReadService {
private final EntityManager entityManager;
public CatalogReadService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public String loadProductDescription(Long productId) {
// Wichtig: Das Lesen der Entität erfolgt innerhalb der Transaktion, daher ist der Persistenzkontext verfügbar
Product product = entityManager.find(Product.class, productId);
// Wichtig: Der Zugriff auf die Lazy-Association erfolgt hier, innerhalb der Transaktion (und nicht irgendwo außerhalb)
return product.getDetails().getDescription();
}
}
Hier sind zwei Gedanken wichtig. Erstens: product.getDetails() muss nicht sofort SQL ausführen: Es kann sich lediglich um einen Verweis/Proxy handeln. Zweitens: getDescription() ist bereits der „echte Datenzugriff“, und genau dieser kann einen SELECT auslösen. Aber wir tun dies dort, wo Hibernate eine offene Session hat, also wird es keine Probleme geben.
Wenn Sie mehrere Felder benötigen, ändert sich die Idee nicht: „Entität abgerufen, die benötigten Lazy-Felder innerhalb der Transaktion berührt, fertige Werte nach außen zurückgegeben“.
5. Ein kleines DTO innerhalb der Transaktion zusammenstellen: Beispiel OrderSummary
Ein etwas realistischeres Szenario: Der externe Code möchte nicht die „gesamte Bestellung“, sondern eine kurze Zusammenfassung. Zum Beispiel die Bestellnummer und die Anzahl der Positionen. Dies ist ein typischer Fall, in dem es unpraktisch ist, eine entity zurückzugeben: Erstens zieht sie die Lazy-Sammlung items mit sich; zweitens tauchen extern Versuchungen auf, „noch ein bisschen hineinzuschauen“ und versehentlich SQL außerhalb der Transaktion auszulösen.
Hier funktioniert ein einfaches DTO gut. In Java 25 (und generell im modernen Java) ist es bequem, ein record zu verwenden: es ist kompakt und lesbar.
// DTO für eine kurze Zusammenfassung: nach außen geht nur, was der Use Case wirklich benötigt
public record OrderSummary(String orderNumber, int itemCount) {
}
Und nun die Servicemethode. Wichtig: order.getItems().size() ist keine „kostengünstige Operation standardmäßig“. Für eine Lazy-Sammlung kann dies der Moment der Initialisierung sein. Daher sind wir verpflichtet, dies innerhalb der Transaktion zu tun.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderQueryService {
private final EntityManager entityManager;
public OrderQueryService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public OrderSummary loadOrderSummary(Long orderId) {
// Lesen der Bestellung innerhalb der Transaktion
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Wichtig: size() kann das Laden der Lazy-Sammlung items auslösen (d. h. SQL ausführen)
int itemCount = order.getItems().size();
// Rückgabe des DTO: externer Code kann nicht mehr „versehentlich“ auf Lazy-Associationen zugreifen
return new OrderSummary(order.getOrderNumber(), itemCount);
}
}
Technisch ist hier alles sehr „menschlich“: Der Service hat die Daten gelesen, das Ergebnis vorbereitet und nach außen zurückgegeben. Der externe Code kann jetzt nicht mehr versehentlich auf die Lazy-Sammlung zugreifen — er hat bereits eine Zahl erhalten, keine potentielle Zeitbombe.
6. Initialisierung von Lazy-Associationen im Service
In einer idealen Welt gehen DTOs und Primitive nach außen, während entitys innerhalb der Daten- und Serviceschicht bleiben. In der Realität können Sie sich manchmal in einer Zwischenphase befinden: Der Code ist bereits so geschrieben, dass er eine Entität nach außen zurückgibt, aber Sie möchten zumindest das Verhalten vorhersagbar machen und Abstürze vermeiden. Dies ist nicht der „beste Stil für immer“, aber als Übergangslösung kommt es vor.
Der einfachste Weg, eine Sammlung zu „initialisieren“, ist, sie innerhalb der Transaktion zu berühren. Zum Beispiel size() aufrufen oder in einer Schleife durchlaufen. Ja, das sieht ein wenig wie ein Ritual aus (Hibernate mag manchmal solche Rituale), aber der Sinn ist rational: Sie zwingen das ORM, das benötigte SQL dort auszuführen, wo es zulässig ist.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderLoadService {
private final EntityManager entityManager;
public OrderLoadService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public PurchaseOrder loadOrderInitialized(Long orderId) {
// Lesen der Bestellung innerhalb der Transaktion
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// Initialisierung der Lazy-Sammlung: Wir zwingen das ORM, items hier zu laden, und nicht irgendwo „draußen“
order.getItems().size();
// Rückgabe der Entität als Übergangslösung (besser wäre es, ein DTO zurückzugeben, aber manchmal muss man so vorgehen)
return order;
}
}
Der Vorteil dieses Ansatzes ist, dass er tatsächlich die LazyInitializationException für die konkrete Assoziation items entfernt. Der Nachteil ist, dass er das Problem nicht systemisch löst: Der aufrufende Code kann immer noch in eine andere Lazy-Association einsteigen (z.B. order.getCustomer().getEmail()), und alles wiederholt sich. Betrachten Sie dies daher als „punktweises Entschärfen“ und nicht als endgültige Architektur.
Manchmal versuchen Entwickler, „noch offensichtlichere Magie“ zu machen und verwenden Hibernate-spezifische Methoden. Zum Beispiel so:
import org.hibernate.Hibernate;
// Initialisierung über die Hibernate-API: funktioniert, bindet den Code aber an Hibernate
Hibernate.initialize(order.getItems());
Dies funktioniert nach derselben Idee (Initialisierung innerhalb der Transaktion), bindet den Code aber an die Hibernate-API. In einem Lehrprojekt versuchen wir, uns näher am verständlichen JPA-Denken zu halten, daher zeigen wir häufiger den „gewöhnlichen“ Zugriff auf die Sammlung als Trigger.
7. Durchgehendes Beispiel zum Lesen von Bestelldetails
Jetzt fassen wir alles in einem möglichst praktischen Szenario zusammen, das dem nahekommt, was im Commerce Persistence Lab wirklich benötigt wird. Angenommen, der externe Code (Controller, Test, Konsolenbefehl) muss die „Bestelldetails“ anzeigen: Bestellnummer und Positionen. Wir brauchen nicht den gesamten Entitätsgraphen, wir müssen OrderItem nicht als entity nach außen geben, und für Positionen sind besonders die Snapshot-Felder nützlich, die bereits in OrderItem leben (z.B. productNameSnapshot, productSkuSnapshot).
Wir erstellen zwei kleine DTOs:
// DTO einer einzelnen Bestellposition: Verwenden der Snapshot-Felder, um keine verknüpften Entitäten zu laden
public record OrderItemLine(String productSku, String productName, int quantity) {
}
import java.util.List;
// DTO „Bestelldetails“: Nummer + fertige Liste der Positionen (ohne Persistent Collections und Proxys)
public record OrderDetailsDto(String orderNumber, List<OrderItemLine> items) {
}
Und der Service, der innerhalb der Transaktion items initialisiert, sie durchläuft und das DTO zusammenstellt. Hier ist wichtig, dass wir genau die Snapshot-Felder nehmen, um keine zusätzlichen Lazy-Reads von verknüpften Entitäten zu provozieren.
import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderReadFacade {
private final EntityManager entityManager;
public OrderReadFacade(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public OrderDetailsDto loadOrderDetails(Long orderId) {
// Bestellung innerhalb der Transaktion finden (solange das „Restaurant geöffnet ist“)
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// DTO innerhalb der Transaktion zusammenstellen, um keine Lazy-Objekte nach außen zu tragen
List<OrderItemLine> lines = new ArrayList<>();
for (OrderItem item : order.getItems()) {
// Wichtig: Wir nehmen Snapshot-Felder aus OrderItem, um kein Lazy-Laden von Product auszulösen
lines.add(new OrderItemLine(
item.getProductSkuSnapshot(),
item.getProductNameSnapshot(),
item.getQuantity()
));
}
// Geben das bereits fertige Ergebnis des Use Case zurück
return new OrderDetailsDto(order.getOrderNumber(), lines);
}
}
Was haben wir in der Praxis erreicht? Der externe Code erhält ein Objekt, das bereits vollständig einsatzbereit ist: Es enthält keine Lazy-Proxys, keine Persistent Collections, kein Risiko, „versehentlich zu berühren und abzustürzen“. Und wenn Hibernate für items zur Datenbank gehen muss, wird es das innerhalb der Transaktion tun, im Service, an einer verständlichen Stelle.
Das ist der zentrale Gedanke der Vorlesung: Daten zu lesen ist Teil des Use Case. Wenn Sie eine Leseoperation aufbauen, sollte sie damit enden, dass ein „fertiges Ergebnis“ nach außen geht, nicht ein „Objekt, das theoretisch noch Daten nachlesen könnte, wenn es magisch richtig in Raum und Zeit platziert ist“.
8. Überprüfung der Behebung: SQL und Initialisierung
Nach dem Fix möchten Sie nicht nur sicher sein, dass der „Fehler verschwunden“ ist, sondern auch, dass SQL vorhersagbar geworden ist. Denn man kann es so „reparieren“, dass alles nicht mehr abstürzt, aber stillschweigend Abfragen an unerwarteten Stellen ausführt — und Sie tauschen einfach einen Schmerz gegen einen anderen (normalerweise teureren).
Der erste und ehrlichste Weg ist, das SQL-Log im Profil sql-trace anzusehen und sicherzustellen, dass die Abfragen während der Ausführung der Servicemethode erfolgen und nicht danach. In der Persistenzschicht ist SQL-Sichtbarkeit ein obligatorischer Teil des Denkens, daher ist dies keine „zusätzliche Option“, sondern ein Arbeitswerkzeug.
Der zweite Weg ist, punktuell zu prüfen, ob die Association initialisiert ist, wenn Sie es erwarten. Hibernate bietet das Utility Hibernate.isInitialized(...). Es sollte nicht zur „Geschäftslogik“ werden, aber für das Laborverständnis der Mechanik ist es nützlich.
import org.hibernate.Hibernate;
// Diagnoseprüfung: ob die Sammlung initialisiert wurde (innerhalb der Transaktion ist dies besonders aufschlussreich)
boolean initialized = Hibernate.isInitialized(order.getItems());
System.out.println("items initialized = " + initialized); // items initialized = true/false
Der Sinn dieser Prüfung ist einfach: Wenn Sie innerhalb der Transaktion bereits order.getItems() durchlaufen haben, sollte initialized true werden. Wenn Sie die Sammlung nicht berührt haben, wird es wahrscheinlich false sein. Und dann verstehen Sie leicht, warum beim Zugriff von außen alles abstürzt.
Das wichtigste Kriterium für einen „korrekten Fix“ lautet: Nach der Behebung haben Sie eine Antwort auf die Frage „Wo genau lebt das Lesen von Daten?“. Wenn die Antwort „innerhalb der Serviceoperation“ lautet, haben Sie das getan, was Sie wollten.
Hier ist es wichtig, zwei Aufgaben zu trennen. Wir haben bereits die correctness behoben: Die Daten werden an der richtigen Stelle gelesen und lassen den Code außerhalb der Transaktion nicht abstürzen. Aber das macht das Lesen noch nicht automatisch effizient: Innerhalb der korrekten Servicegrenze kann man durchaus unnötige Abfragen sammeln, wenn der Fetch-Plan ungünstig gewählt ist.
9. Typische Fehler bei der Behebung von Lazy-Problemen
Fehler Nr. 1: OSIV aktivieren oder Associationen auf EAGER umstellen, damit „es nicht abstürzt“.
Auf kurze Distanz ist es tatsächlich einfacher. Auf lange Sicht verlieren Sie die Kontrolle darüber, wann und warum SQL abgesetzt wird, und die Anwendung verwandelt sich in eine Quest „Finde die Stelle, an der plötzlich fünf weitere Abfragen aufgetreten sind“.
Fehler Nr. 2: Die entity aus dem Service zurückgeben und das DTO extern (im Controller oder „Formatter“) zusammenstellen.
Das sieht harmlos aus („Ich stelle doch nur die Antwort zusammen“), aber genau in diesem Moment greifen Sie bereits nach der Transaktion auf Lazy-Associationen zu. Der Fix ist hier fast immer derselbe: Die Zusammenstellung des Ergebnisses in die Transaktion verlagern, also in die Servicemethode.
Fehler Nr. 3: Eine LazyInitializationException „reparieren“, indem man eine Sammlung über size() initialisiert, und weiterhin die Entität nach außen zurückgeben.
Das erzeugt eine Illusion von Kontrolle: Heute haben Sie items vorab initialisiert, morgen greift jemand auf customer zu, übermorgen auf details, und das Problem lebt wieder auf. Dieser Stil verwandelt sich schnell in ein Spiel „Rate, welche Felder noch vorab berührt werden müssen“.
Fehler Nr. 4: Versehentliches Modifizieren einer verwalteten Entität in einem Read-Szenario.
Wenn Sie readOnly=true vergessen haben oder innerhalb des Lesens jemand einen Setter „nur zur Formatierung“ aufgerufen hat, könnte Hibernate entscheiden, dass dies eine echte Änderung ist, und ein UPDATE senden. Dann beheben Sie einen Lazy-Fehler und erhalten stattdessen eine Überraschung in Form eines Schreibvorgangs in die Datenbank.
Fehler Nr. 5: LazyInitializationException abfangen und mit einem Exception-Handler „verschlucken“.
Das führt fast immer zu kaputten Daten am Ausgang: Einige Felder sind leer, einige ungültig, und der Benutzer sieht seltsame Ergebnisse. In der Persistenzschicht ist die Ausnahme nützlich, weil sie ehrlich sagt: „Sie lesen Daten an der falschen Stelle“. Wenn man sie zum Schweigen bringt, verliert man einfach das Signal über den Fehler und lebt weiter in einer falschen Architektur.
GO TO FULL VERSION