1. Batch Fetching anstelle eines großen JOIN
Nach JOIN FETCH kann man leicht in Euphorie verfallen: „Super, eine SQL statt zwanzig“. Aber sehr schnell erinnert die Realität daran, dass eine einzelne Abfrage auch ein Monster sein kann: Sie gibt mehr Zeilen zurück, das Ergebnis wird breiter und manchmal wird das natürliche Leseszenario gestört (insbesondere wenn Sie eine Liste lesen und Limit/Paginierung wünschen). In solchen Momenten wünscht man sich eine Lösung „dazwischen“: nicht ein riesiger JOIN, aber auch nicht N+1.
Bisher haben wir zwei Möglichkeiten besprochen, Hibernate mitzuteilen: „Diese Assoziationen werden bei diesem Lesevorgang frühzeitig benötigt“ – über JOIN FETCH und über EntityGraph. Es gibt jedoch Szenarien, in denen man den Graphen überhaupt nicht frühzeitig laden möchte: LAZY ist für uns in Ordnung, das Problem besteht nur darin, dass die sekundären SELECT einzeln ausgeführt werden. Genau hier setzt Batch Fetching an: Es belässt das lazy-Modell wie es ist, gruppiert aber die sekundären SELECTs.
Batch Fetching ist genau solch ein ingenieurstechnischer Kompromiss. Es verspricht nicht „alles in einem SQL“ und tut nicht so, als ob eine Liste von 20 Bestellungen mit 200 Positionen „kleine Daten“ wären. Es sagt ehrlich: Es sollen ruhig mehrere Abfragen sein, aber wir sorgen dafür, dass die sekundären Lesevorgänge nicht nach dem gleichen Muster „ein Elternteil nach dem anderen“ ablaufen.
Wenn man eine Analogie zieht: JOIN FETCH ist, als würde man die Lieferung von allem mit einem einzigen LKW bestellen: schnell, aber der LKW könnte durch das enge Tor nicht passen. Batch Fetching ist, als würde man mehrere Kleinbusse statt 100 Fahrten mit dem Roller bestellen: nicht „auf einen Schlag“, aber trotzdem wesentlich vernünftiger.
2. Idee von Batch Fetching: IN(...)
Batch Fetching in Hibernate ist ein Mechanismus, der die Anzahl der sekundären SELECT bei lazy loading reduziert. Die Idee ist einfach: Wenn Hibernate sieht, dass Sie beginnen, eine lazy-Assoziation (Proxy oder Sammlung) zu initialisieren, versucht es, nicht nur „ein Ding“ zu laden, sondern mehrere gleiche Dinge auf einmal als Batch – über mehrere IDs gleichzeitig, mit SQL der Form where id in(?, ?, ?).
Das heißt, N+1 wird zu einer angenehmeren Form: 1 + ceil(N / batchSize). Das ist wichtig: „plus eins“ ist nicht verschwunden, nur statt „weiteren N Abfragen“ erscheinen „weitere Abfragen, aber in Batches“.
Ein kleines Schema dessen, was in einer Transaktion passiert:
flowchart TD
A[Anfrage: Lade Liste von PurchaseOrder] --> B[Im Kontext gibt es 20 orders]
B --> C["Code greift auf order.getItems() zu"]
C --> D[Hibernate sieht: Sammlungen items sind noch nicht initialisiert]
D --> E["Wählt eine Gruppe von order_id (z.B. 10)"]
E --> F["SQL: select * from order_item where order_id in (...10 id...)"]
F --> G[Initialisiert items sofort für 10 Bestellungen]
G --> H[Nächster Zugriff auf items bei diesen Bestellungen erfolgt ohne SQL]
Eine sehr wichtige Grenze: Batch Fetching betrifft das Lesen (sekundäre SELECTs, lazy-Initialisierung). Es hat nichts mit „JDBC Batching“ beim Schreiben zu tun (wenn INSERT/UPDATE in Batches gesendet werden). Die Wörter sind ähnlich, aber die Bedeutung ist unterschiedlich, und sie zu verwechseln ist eine klassische Studentenfalle.
Um nicht zu verwechseln, ist es praktisch, eine kleine Tabelle im Kopf zu behalten:
| Begriff | Worum es geht | Wo sichtbar |
|---|---|---|
| Batch Fetching | über Gruppierung von lazy-Initialisierungen (sekundäre SELECTs) | select ... where id in (...) beim Lesen |
| JDBC-Batching | über batchweises Senden von INSERT/UPDATE/DELETE | Batches von Statements beim Schreiben |
3. Batch Fetching für to-one
Wenn man über N+1 spricht, denken viele nur an Sammlungen (OneToMany). Aber in der Realität kommt ein großer Teil von N+1 von to-one Assoziationen: ManyToOne und OneToOne, die standardmäßig oft LAZY sind. Ein Proxy sieht aus wie ein normales Objekt, aber der erste Versuch, ein Feld zu lesen, wird zu einem SELECT.
Im Commerce Persistence Lab gibt es ein sehr natürliches Beispiel – OrderItem → Product. Bestellpositionen (OrderItem) haben fast immer eine Referenz auf ein Produkt (Product), aber wir wollen nicht den gesamten Produktgraphen auf einmal laden, weil es viele und unterschiedliche Lesevorgänge gibt. Also ist product der Position lazy. Und dann beginnt eine einfache Schleife über die Positionen, einzelne Abfragen zu feuern.
Mini‑Beispiel (naiv, aber lebensnah):
import jakarta.persistence.EntityManager;
import java.util.List;
// Wir laden Bestellpositionen: hier lesen wir nur OrderItem, Product bleibt LAZY.
List<OrderItem> items = entityManager.createQuery(
"select i from OrderItem i where i.order.id = :orderId",
OrderItem.class
).setParameter("orderId", orderId)
.getResultList();
// Beim ersten Zugriff auf product kann Hibernate einen sekundären SELECT ausführen (und so für jedes item).
items.forEach(i -> System.out.println(i.getProduct().getSku())); // SKU-123...
Wenn product lazy ist und Batch Fetching nicht aktiviert ist, sehen Sie ungefähr ein solches Profil: eine Abfrage auf OrderItem, dann für jedes Item eine weitere Abfrage auf Product.
So aktivieren Sie @BatchSize für eine to-one Assoziation
Für eine to-one Assoziation können Sie @BatchSize direkt auf das Assoziationsfeld setzen. Das ist lokal und verständlich: „Wenn wir product lazy nachladen, dann tu das in Batches.“
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
import org.hibernate.annotations.BatchSize;
public class OrderItem {
@ManyToOne(fetch = FetchType.LAZY) // Lazy-Referenz: ohne Optimierungen leicht N+1
@BatchSize(size = 20) // Fordere Hibernate auf, Product in Batches zu laden (sekundärer SELECT mit IN)
private Product product;
// ...
}
Was ändert sich im SQL? Beim ersten Aufruf von getProduct().getSku() kann Hibernate eine Abfrage etwa dieser Form ausführen (vereinfacht):
-- Batch Fetching: eine Abfrage lädt mehrere Product anhand ihrer IDs.
select p.*
from product p
where p.id in (?, ?, ?, ?, ?)
Beachten Sie das „kann“. Batch Fetching ist kein Knopf „garantiere eine Abfrage“. Hibernate sammelt einen Batch aus den Proxys, die sich bereits im Persistenzkontext befinden und noch nicht initialisiert sind. Wenn sich nur ein Item im Kontext befindet – gibt es keinen Batch, und die Abfrage wird normal sein.
Und ein kleiner Punkt, der Anfänger oft überrascht: Batch Fetching tritt auf, wenn Sie einen Proxy angestoßen haben, aber mehrere auf einmal geladen werden. Das heißt, Sie öffnen einen Schrank, und Hibernate bringt gleich die Sachen aus neunzehn anderen Schränken mit, weil „es jetzt sowieso gebraucht wird“. Im normalen Leben wirkt das wie Magie; in ORM ist es einfach eine Optimierung von sekundären SELECTs.
4. Batch Fetching für Sammlungen
Sammlungen sind der zweite große Bereich von N+1. Und hier ist der Kompromiss „mehrere Abfragen, aber in Batches“ manchmal sogar nützlicher als bei to-one, weil JOIN FETCH einer Sammlung die Anzahl der Ergebniszeilen sehr schnell aufbläht. Besonders wenn es viele Bestellungen gibt und jedes Item nicht nur eines ist.
Nehmen wir ein typisches Backoffice-Szenario: Sie lesen die letzten 20 Bestellungen und zählen irgendwo im Code die Anzahl der Positionen oder geben die „ersten N Positionen“ in der Logik der Antwortbildung aus. Selbst wenn Sie bewusst kein JOIN FETCH machen, können Sie versehentlich items für jede Bestellung initialisieren und N+1 erhalten.
Naiver Code:
import jakarta.persistence.EntityManager;
import java.util.List;
// Wir laden nur Bestellungen: Die Sammlung items bleibt LAZY.
List<PurchaseOrder> orders = entityManager.createQuery(
"select o from PurchaseOrder o order by o.createdAt desc",
PurchaseOrder.class
).setMaxResults(20) // Typische Seitengröße der Liste
.getResultList();
// Der erste Aufruf von getItems() für jede Bestellung kann einen sekundären SELECT auslösen (und so N-mal).
orders.forEach(o -> System.out.println(o.getItems().size())); // 3, 5, 1...
Ohne Batch Fetching wird dies oft zu 21 Abfragen: 1 für die Bestellungen und 20 für die Sammlungen.
So aktivieren Sie @BatchSize für Sammlungen
Für eine Sammlung (OneToMany) setzen Sie @BatchSize direkt auf das Sammlungsfeld. Dann wird Hibernate, wenn es beginnt, items für eine Bestellung zu initialisieren, items als Batch für mehrere Bestellungen laden.
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.BatchSize;
public class PurchaseOrder {
@OneToMany(mappedBy = "order") // Assoziation bleibt LAZY, aber sekundäre SELECTs werden gruppiert
@BatchSize(size = 10) // Laden von items gleichzeitig für mehrere Bestellungen (per order_id IN (...))
private List<OrderItem> items = new ArrayList<>();
// ...
}
Wenn Sie 20 Bestellungen und eine Batch-Größe von 10 haben, erhalten Sie statt 20 Abfragen auf items ungefähr 2 Abfragen (wiederum unter der Bedingung, dass Sie innerhalb einer Transaktion tatsächlich items für verschiedene orders initialisieren).
Das SQL-Profil wird ähnlich sein:
select i.*
from order_item i
where i.order_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hier ist es wichtig, den „Preis“ der Lösung zu verstehen. Sie haben keinen großen Join gemacht, also multipliziert die Datenbank nicht die Zeilen „orders × items“ in einer Abfrage. Aber Sie laden trotzdem items – nur machen Sie es vernünftiger: mit zwei Abfragen statt zwanzig.
Warum dies nicht dasselbe ist wie JOIN FETCH
JOIN FETCH versucht, „alles auf einmal“ in einem SQL zu holen. Batch Fetching belässt das Laden lazy, optimiert aber die Situation, wenn Sie dieses Lazy trotzdem initialisieren mussten und Sie es seriell für eine Menge von Eltern tun.
Das heißt, Batch Fetching ist nicht „ich habe im Voraus entschieden, dass items benötigt werden“. Es ist eher „ich bin sowieso in Code gelandet, der items für viele Bestellungen anfasst; lasst uns wenigstens nicht 20 identische Abfragen hintereinander machen“.
5. Konfiguration: Annotation vs. global
Wenn Sie die Idee von Batch Fetching verstanden haben, ist die nächste logische Frage: „Wo genau wird das eingeschaltet, damit es sich nicht wie Schimmel im Projekt ausbreitet?“ Hier ist es wichtig, die Konfiguration kontrollierbar zu machen: Wir wollen keine „Magie überall“, sondern ein bewusstes Werkzeug.
In Hibernate gibt es normalerweise zwei Haupthebel: die lokale Konfiguration über @BatchSize (auf Assoziationen/Sammlungen oder auf Entitäten) und die globale Konfiguration über die Property hibernate.default_batch_fetch_size. Die lokale Konfiguration gewinnt dadurch, dass sie die Entscheidung direkt neben dem Modell dokumentiert: „Hier erwarten wir ein typisches Zugriffsmuster“. Die globale – dass Sie an einer Stelle ein grundlegendes Optimierungsniveau einschalten und punktuell überschreiben können.
Lokal: @BatchSize auf Assoziation oder Sammlung
Für eine Sammlung, wie wir bereits gemacht haben:
import org.hibernate.annotations.BatchSize;
@BatchSize(size = 10)
private List<OrderItem> items;
Für eine to-one Assoziation:
import org.hibernate.annotations.BatchSize;
@BatchSize(size = 20)
private Product product;
Auf Entitätsebene: @BatchSize auf Product
Manchmal ist es bequemer zu sagen: nicht „diese Assoziation hat batch size 20“, sondern „im Allgemeinen wird Product oft in Batches geladen, wenn es als Proxy vorkommt“. Dann kann man @BatchSize auf die Entität setzen:
import jakarta.persistence.Entity;
import org.hibernate.annotations.BatchSize;
@Entity
@BatchSize(size = 20) // Jegliche lazy-Proxys von Product werden dazu neigen, in Gruppen von 20 geladen zu werden
public class Product {
// ...
}
Das ist besonders nützlich, wenn Product an mehreren Stellen als lazy ManyToOne vorkommt (OrderItem.product, InventoryItem.product, Zuordnungen usw.) und Sie nicht dasselbe auf jedem Feld wiederholen möchten.
Global: hibernate.default_batch_fetch_size in application.yml
Wenn Sie Batch Fetching als „grundlegende Absicherung“ einschalten möchten (nicht als Ersatz für das Design von Lesevorgängen, sondern als sinnvollen Standardwert), können Sie eine globale Batch-Größe festlegen.
spring:
jpa:
properties:
hibernate:
# Globale Batch-Größe für Batch Fetching (sekundäre SELECTs bei lazy-Init)
default_batch_fetch_size: 20
Ein wichtiger Punkt: Die globale Einstellung ist ein Kompromiss. Wenn Sie einen zu kleinen Wert setzen, ist die Wirkung schwächer. Wenn Sie einen zu großen Wert setzen, beginnen Sie riesige IN(...) zu generieren, was auch nicht kostenlos ist.
6. Wahl der Batch-Größe
Der häufigste Fehler beim Tuning von ORM ist, Zahlen „nach Gefühl“ zu wählen und sie dann mit Glauben zu verteidigen, nicht mit Beobachtung. Bei Batch Fetching ist das besonders auffällig: Sie setzen @BatchSize(100), es gibt „weniger Abfragen“, aber plötzlich werden die Abfragen schwerer und die Datenbank beginnt, sich über große IN zu beschweren. Oder umgekehrt: Sie setzen 5, die Wirkung ist da, aber schwach, und Sie verstehen nicht, warum es immer noch „viel SQL“ gibt.
Der richtige Weg hier ist langweilig, aber ehrlich: Sie wählen eine Batch-Größe als Hypothese, aktivieren SQL-Trace und messen das Abfrageprofil für einen bestimmten read-case. In unserem Kurs ist das besonders einfach, weil wir die Profile sql-trace und (optional) Hibernate-Statistiken haben.
Einfache Arithmetik, die beim Denken hilft
Wenn Sie 20 Bestellungen laden und genau wissen, dass der Code items für alle 20 anfassen wird, dann:
— ohne Batch Fetching erhalten Sie potenziell 1 + 20 Abfragen;
— mit @BatchSize(10) erwarten Sie 1 + 2 Abfragen (im Idealfall);
— mit @BatchSize(20) erwarten Sie 1 + 1 Abfrage auf items.
Aber „im Idealfall“ ist das Stichwort. Batch Fetching hängt davon ab, was sich im Persistenzkontext befindet und wie genau die Initialisierung erfolgt. Manchmal haben Sie items nur für 3 Bestellungen angestoßen – dann hilft Ihnen batch size 20 gar nicht, weil es nichts zu batchen gibt.
Mini-Messung mit Hibernate Statistics
Wenn Sie die Hibernate-Statistiken aktiviert haben, können Sie eine sehr einfache Messung der Anzahl der SQL-Statements durchführen. Das ist kein vollständiges Performance-Audit, aber als Lernwerkzeug ist es hervorragend geeignet.
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
// Statistik aus der SessionFactory holen: Es sind die Hibernate-Zähler, nicht die Datenbankmetrik.
Statistics stats = entityManager.getEntityManagerFactory()
.unwrap(SessionFactory.class)
.getStatistics();
stats.clear(); // Zähler vor dem Start des Szenarios zurücksetzen
// ... hier den read-case ausführen ...
System.out.println("Prepared statements = " + stats.getPrepareStatementCount());
// Prepared statements = 3
Ja, das ist grob. Ja, in einem echten Projekt werden Sie sowohl auf die Form des SQL, die Anzahl der Zeilen als auch den Ausführungsplan schauen. Aber in der Lernphase hilft die Statistik, den grundlegenden Fakt zu erfassen: „Wir haben jetzt 3 Abfragen statt 21“ – und dann weiter zu analysieren, warum.
Wie erkennt man im SQL-Trace, dass Batch Fetching aktiviert ist? Sie sollten sehen, dass anstelle von wiederholten Abfragen mit einer einzelnen ID Abfragen mit IN(...) erscheinen. Wenn Sie PurchaseOrder.items lesen, sehen Sie where order_id in(...). Wenn Sie OrderItem.product lesen, sehen Sie where product_id in(...) oder where id in(...) auf Product.
Und noch eine Nuance: Die Batch-Größe ist ein Tuning-Knopf, keine religiöse Zahl. Oft stimmen vernünftige Werte mit der typischen Größe der „Portion“ überein, die Sie ohnehin lesen: zum Beispiel mit der Seitengröße einer Liste (20) oder mit der „halben Seite“ (10), um kein zu großes IN zu erzeugen und flexibel zu bleiben. Aber das sollte nicht aus der Luft gegriffen sein, sondern aus dem konkreten read-case abgeleitet werden.
7. Typische Fehler beim Batch Fetching
Fehler Nr. 1: Erwarten, dass @BatchSize alles in eine Abfrage verwandelt.
@BatchSize ist nicht JOIN FETCH. Es muss die Assoziation nicht sofort bei der ersten Abfrage laden. Es optimiert die sekundären Lesevorgänge, die bei der lazy-Initialisierung auftreten. Daher erhalten Sie „eine Abfrage“ nur in Einzelfällen, wenn die sekundäre Abfrage tatsächlich alles Nötige auf einmal laden kann und die Batch-Größe mit der Datenmenge übereinstimmt.
Fehler Nr. 2: @BatchSize „überall und wahllos“ setzen, weil „das weniger Abfragen ergibt“.
Batch Fetching ist ein Kompromiss mit Kosten. Es reduziert die Anzahl der Abfragen, kann aber die Größe jeder Abfrage und die Menge der materialisierten Daten erhöhen. Wenn Sie es überall aktivieren, riskieren Sie eine Situation „weniger Abfragen, aber jede schwerer und weniger vorhersagbar“. In einem Übungsprojekt ist das noch erträglich, aber in einem echten Projekt wird es schnell zu einem Nebel aus impliziten Nachladungen.
Fehler Nr. 3: Eine zu große Batch-Größe wählen und sich freuen, bis die Datenbank zu weinen beginnt.
Große IN(...) sind nicht kostenlos. Sie haben Kosten auf der Seite des Planers, es gibt Limits für die Anzahl der Parameter (und im Allgemeinen den gesunden Menschenverstand). Außerdem kann ein riesiger Batch dazu führen, dass Sie viele Daten „für den Fall der Fälle“ abrufen, obwohl der Code nur einen Teil anfasst. Batch-Größe ist nicht „je größer, desto besser“, sondern „ausreichend, um das typische Zugriffsmuster zu gruppieren“.
Fehler Nr. 4: Die Wirkung von @BatchSize im falschen Szenario (oder in der falschen Transaktion) prüfen.
Batch Fetching funktioniert innerhalb des Persistenzkontexts. Wenn Sie eine Liste von Bestellungen in einer Transaktion laden und dann irgendwo in einer anderen Schicht/außerhalb der Transaktion auf lazy-Assoziationen zugreifen, erhalten Sie entweder eine LazyInitializationException oder ein völlig anderes SQL-Bild. Batch Fetching sollte in einem reproduzierbaren Szenario getestet werden, in dem die „Eltern“ und die anschließende Initialisierung der Assoziationen in einer unit of work stattfinden.
Fehler Nr. 5: Denken, dass @BatchSize „nicht funktioniert“, weil die Abfrage trotzdem kein IN(...) ist.
Manchmal gibt es im Kontext wirklich keinen „Batch“ von gleichartigen nicht initialisierten Assoziationen. Sie haben eine Sammlung initialisiert und keine weitere – dann hat Hibernate nichts, woraus es einen Batch formen könnte. Oder Sie haben die Assoziation bereits auf andere Weise geladen (z.B. JOIN FETCH oder EntityGraph) – dann soll Batch Fetching auch nicht auftreten. In solchen Situationen ist das Fehlen von IN kein Fehler, sondern Logik.
GO TO FULL VERSION