Spring Data JPA N+1-Lösungen: Fetch Join, EntityGraph und Hibernate 7.4
Vollständige Anleitung zur Erkennung und Behebung des N+1-Problems in Spring Data JPA. Fetch Join, @EntityGraph, Batch Fetching und Strategien für die Abfrageleistung.

Das N+1-Problem stellt eine der häufigsten Performance-Fallen in JPA dar. Eine harmlose Abfrage zum Abrufen von 100 Bestellungen kann 101 SQL-Abfragen auslösen: eine für die Bestellungen und dann eine für jeden zugehörigen Kunden. Diese stille Vervielfachung der Abfragen verschlechtert die Performance und überlastet die Datenbank.
Ein Endpoint, der 50 Artikel mit ihren Autoren zurückgibt, kann aufgrund von N+1 von 10ms auf 500ms ansteigen. Eine frühzeitige Erkennung verhindert kritische Probleme in der Produktion.
Das N+1-Problem in JPA verstehen
Das N+1-Problem tritt auf, wenn JPA eine Sammlung von Entitäten lädt und anschließend eine zusätzliche Abfrage für jede Entität ausführt, um deren Assoziationen zu laden. Dieses Verhalten resultiert aus dem standardmäßigen Lazy Loading von @OneToMany- und @ManyToMany-Beziehungen.
Betrachten wir ein klassisches Modell mit Bestellungen und Kunden. Jede Bestellung gehört zu einem Kunden, und diese Beziehung ist standardmäßig lazy.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private LocalDateTime createdAt;
// ManyToOne relationship is lazy by default since JPA 2.0
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// OneToMany relationship lazy by default
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
// Getters and setters omitted
}@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Getters and setters omitted
}Wenn eine Abfrage Bestellungen abruft und dann auf den Kundennamen zugreift, führt Hibernate für jede Bestellung eine zusätzliche Abfrage aus.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// 1 query: SELECT * FROM orders
List<Order> orders = orderRepository.findAll();
// For each order, accessing customer triggers a query
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
// N queries: SELECT * FROM customers WHERE id = ?
order.getCustomer().getName()
))
.toList();
}
}Für 100 Bestellungen führt dieser Code 101 SQL-Abfragen aus. Die Hibernate-Logs offenbaren dieses zerstörerische Muster.
-- Query 1: fetch orders
SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o
-- Queries 2-101: fetch each customer
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3
-- ... 97 more identical queriesDas N+1-Problem mit Hibernate-Logs erkennen
Der erste Schritt besteht darin, SQL-Logs zu aktivieren, um problematische Abfragen zu identifizieren. Die folgende Konfiguration zeigt jede von Hibernate ausgeführte Abfrage an.
# application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
# Format SQL for better readability
format_sql: true
# Display session statistics (queries, time)
generate_statistics: true
logging:
level:
# Detailed SQL query logging
org.hibernate.SQL: DEBUG
# Display prepared statement parameters
org.hibernate.orm.jdbc.bind: TRACEDie Hibernate-Statistiken liefern am Ende jeder Transaktion eine wertvolle Zusammenfassung.
Session Metrics {
23421 nanoseconds spent acquiring 1 JDBC connection;
0 nanoseconds spent releasing 0 JDBC connections;
1254789 nanoseconds spent preparing 101 JDBC statements;
15478963 nanoseconds spent executing 101 JDBC statements;
0 nanoseconds spent executing 0 JDBC batches;
}Die Anzahl von 101 JDBC-Statements für eine einfache Bestellliste signalisiert eindeutig ein N+1-Problem.
SQL-Logs und Statistiken beeinträchtigen die Performance. Diese Optionen sollten in der Produktion deaktiviert bleiben und nur in Entwicklungs- und Testumgebungen verwendet werden.
Lösung 1: Fetch Join mit JPQL
Fetch Join lädt Assoziationen in einer einzigen SQL-Abfrage über einen Join. Dieser explizite Ansatz löst das N+1-Problem, indem alle erforderlichen Daten auf einmal abgerufen werden.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Explicit fetch join to load customers
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// Multiple fetch join for several associations
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"JOIN FETCH o.items i")
List<Order> findAllWithCustomerAndItems();
// Fetch join with WHERE condition
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"WHERE o.createdAt > :since")
List<Order> findRecentOrdersWithCustomer(
@Param("since") LocalDateTime since
);
}Fetch Join wandelt N+1-Abfragen in eine einzige optimierte Abfrage um.
-- Single query with join
SELECT o.id, o.order_number, o.created_at, o.customer_id,
c.id, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.idDer Service verwendet nun die optimierte Methode, ohne den Geschäftscode zu ändern.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// Single query with join
List<Order> orders = orderRepository.findAllWithCustomer();
// No additional queries
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
order.getCustomer().getName() // Already loaded
))
.toList();
}
}Bereit für deine Spring Boot-Interviews?
Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.
Lösung 2: @EntityGraph für deklarative Kontrolle
Die Annotation @EntityGraph bietet eine deklarative Alternative zum Fetch Join. Sie definiert, welche Assoziationen geladen werden sollen, ohne benutzerdefiniertes JPQL schreiben zu müssen. Eine umfassendere Übersicht der JPA-Best-Practices bietet das Spring Data JPA-Grundlagen-Modul.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Inline EntityGraph with attributePaths
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll();
// EntityGraph with multiple attributes
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByCreatedAtAfter(LocalDateTime since);
// Named EntityGraph referencing entity definition
@EntityGraph(value = "Order.withCustomerAndItems")
List<Order> findByCustomerId(Long customerId);
// Combination with custom query
@EntityGraph(attributePaths = {"customer"})
@Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%")
List<Order> findByOrderNumberPrefix(@Param("prefix") String prefix);
}Benannte EntityGraphs werden direkt auf der Entität definiert, um sie in mehreren Repositories wiederzuverwenden.
@Entity
@Table(name = "orders")
@NamedEntityGraph(
name = "Order.withCustomer",
attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("items")
}
)
@NamedEntityGraph(
name = "Order.full",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode(value = "items", subgraph = "items-product")
},
subgraphs = @NamedSubgraph(
name = "items-product",
attributeNodes = @NamedAttributeNode("product")
)
)
public class Order {
// Fields unchanged
}Der Subgraph ermöglicht das Laden verschachtelter Assoziationen. Das obige Beispiel lädt die Bestellungen, ihre Items und das Produkt jedes Items in einer einzigen Abfrage.
Hibernate 7 führt eine vereinfachte textbasierte @NamedEntityGraph-Syntax ein, die den Annotationsaufwand reduziert:
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
name = "Order.full",
graph = "customer, items(product)"
)
public class Order {
// Fields unchanged
}Diese einzeilige Definition ersetzt die ausführlichen verschachtelten @NamedAttributeNode- und @NamedSubgraph-Annotationen.
Lösung 3: Batch Fetching für Sammlungen
Batch Fetching bietet eine Alternative zum Fetch Join für @OneToMany-Sammlungen. Statt jede Sammlung einzeln zu laden, gruppiert Hibernate die Abfragen in Batches.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// Batch fetching on the collection
@OneToMany(mappedBy = "order")
@BatchSize(size = 25)
private List<OrderItem> items = new ArrayList<>();
}Mit @BatchSize(size = 25) lädt Hibernate Items für 25 Bestellungen gleichzeitig statt für eine. Bei 100 Bestellungen sinkt die Anzahl der Abfragen von 101 auf 5.
-- Without batch fetching: 100 queries
SELECT * FROM order_items WHERE order_id = 1
SELECT * FROM order_items WHERE order_id = 2
-- ... 98 more queries
-- With @BatchSize(size = 25): 4 queries
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25)
SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50)
SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75)
SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100)Die globale Konfiguration der Batch-Größe gilt für alle Sammlungen der Anwendung.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch Fetching eignet sich für Fälle, in denen Fetch Join ein zu großes kartesisches Produkt erzeugt. Bei einer Bestellung mit 10 Items und 5 Zahlungen liefert Fetch Join 50 Zeilen. Batch Fetching führt 2 separate, effizientere Abfragen aus.
Paginierung mit Fetch Join in Hibernate 7.4
Eine langjährige Einschränkung von Fetch Joins war deren Inkompatibilität mit Paginierung. Die Kombination von setMaxResults() mit Collection-Fetch-Joins zwang Hibernate dazu, alle übereinstimmenden Zeilen zu laden und dann die Limits im Speicher anzuwenden. Die Warnung firstResult/maxResults specified with collection fetch; applying in memory wies auf dieses Performance-Problem hin.
Hibernate 7.4 löst dieses Problem. Paginierung mit Collection-Fetch-Joins funktioniert nun auf Datenbankebene mittels verschachtelter Abfragen. Hibernate ermittelt zunächst die begrenzte Menge der Parent-Entity-Identifikatoren und holt dann die zugehörigen Collections nur für diese Parent-Zeilen.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItems(Pageable pageable);
@Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items")
Slice<Order> findAllWithCustomerAndItems(Pageable pageable);
}Das generierte SQL verwendet nun eine Subquery für die Paginierung auf Datenbankebene:
-- Hibernate 7.4: database-level pagination with fetch join
SELECT o.*, i.*
FROM orders o
JOIN order_items i ON o.id = i.order_id
WHERE o.id IN (
SELECT id FROM orders
ORDER BY created_at DESC
LIMIT 10 OFFSET 0
)Diese Verbesserung reduziert den Speicherverbrauch für paginierte Endpoints, die Parent-Child-Beziehungen darstellen, drastisch. Spring Boot 4.1 enthält Hibernate 7.4.5, wodurch diese Optimierung standardmäßig verfügbar ist.
Für Anwendungen, die noch ältere Hibernate-Versionen nutzen, bleibt das zweiphasige Abrufmuster der sicherste Ansatz: Zuerst Parent-IDs mit Paginierung abrufen, dann Assoziationen für diese spezifischen IDs laden.
Vergleich der Lade-Strategien
Jede Strategie bietet je nach Verwendungskontext Vorteile. Die folgende Tabelle fasst die optimalen Anwendungsfälle zusammen.
| Strategie | Anwendungsfall | Vorteile | Nachteile |
|---|---|---|---|
| Fetch Join | @ManyToOne-Beziehungen | Einzige SQL-Abfrage | Kartesisches Produkt mit Sammlungen |
| @EntityGraph | Deklaratives Laden | Wiederverwendbar, lesbar | Weniger flexibel als JPQL |
| Batch Fetching | @OneToMany-Sammlungen | Vermeidet kartesisches Produkt | Mehrere Abfragen |
| Subselect | Selten zugegriffene Sammlungen | Lädt nur bei Bedarf | Korrelierte Unterabfrage |
Die Subselect-Strategie lädt die gesamte Sammlung beim ersten Zugriff auf ein beliebiges Element.
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();-- Generated subselect query
SELECT * FROM order_items
WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?)N+1 mit DTO-Projektionen vermeiden
DTO-Projektionen bieten einen radikalen, aber effektiven Ansatz. Durch die Auswahl nur der notwendigen Spalten vermeiden Projektionen das Laden von Entitäten und ihren Assoziationen vollständig. Dieses Muster ist besonders relevant für leseintensive Endpoints, wie im JPA-Abfragen-Modul behandelt.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}public interface OrderRepository extends JpaRepository<Order, Long> {
// DTO projection with constructor
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c")
List<OrderSummaryDto> findAllOrderSummaries();
// DTO projection with condition
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c " +
"WHERE o.createdAt > :since")
List<OrderSummaryDto> findRecentOrderSummaries(
@Param("since") LocalDateTime since
);
}Dieser Ansatz erzeugt eine optimale SQL-Abfrage ohne den Overhead des Entity-Mappings.
SELECT o.id, o.order_number, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > ?Erweiterte Konfiguration mit Spring Data JPA
Spring Data JPA in Spring Boot 4.x bietet verbessertes EntityGraph-Management und Abfrageoptimierung. Das Verständnis von Transaktionspropagierung wird wichtig, wenn diese Abrufstrategien mit komplexer Geschäftslogik kombiniert werden.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Dynamic EntityGraph with Specification
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll(Specification<Order> spec);
// Pagination with EntityGraph
@EntityGraph(attributePaths = {"customer"})
Page<Order> findByCustomerNameContaining(
String name,
Pageable pageable
);
// Slice for efficient pagination
@EntityGraph(attributePaths = {"customer"})
Slice<Order> findByCreatedAtBefore(
LocalDateTime date,
Pageable pageable
);
}Bedingtes Laden ermöglicht es, je nach Kontext unterschiedliche Strategien anzuwenden.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final EntityManager entityManager;
public List<Order> getOrdersWithGraph(String graphName) {
// Dynamic EntityGraph retrieval
EntityGraph<?> graph = entityManager
.getEntityGraph(graphName);
return entityManager
.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.loadgraph", graph)
.getResultList();
}
public List<Order> getOrdersForListing() {
// Minimal graph for listing
return getOrdersWithGraph("Order.withCustomer");
}
public List<Order> getOrdersForDetail() {
// Full graph for detail view
return getOrdersWithGraph("Order.full");
}
}Fang an zu üben!
Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.
Performance-Tests zur Erkennung von N+1
Automatisierte Tests überprüfen das Fehlen von N+1-Problemen, indem sie die ausgeführten SQL-Abfragen zählen. Umfassende Teststrategien behandelt der Testcontainers-Leitfaden für Spring Boot.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryPerformanceTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private EntityManager entityManager;
@PersistenceContext
private EntityManager em;
private Statistics statistics;
@BeforeEach
void setUp() {
// Enable Hibernate statistics
Session session = entityManager.unwrap(Session.class);
SessionFactory factory = session.getSessionFactory();
statistics = factory.getStatistics();
statistics.setStatisticsEnabled(true);
statistics.clear();
}
@Test
void findAllWithCustomer_shouldExecuteSingleQuery() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch with fetch join
List<Order> orders = orderRepository.findAllWithCustomer();
// Then: single query executed
assertThat(orders).hasSize(50);
assertThat(statistics.getQueryExecutionCount())
.as("Should execute only 1 query with fetch join")
.isEqualTo(1);
}
@Test
void findAll_withoutOptimization_triggersNPlus1() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch without optimization
List<Order> orders = orderRepository.findAll();
// Access customers to trigger lazy loading
orders.forEach(o -> o.getCustomer().getName());
// Then: N+1 queries (51 instead of 1)
assertThat(statistics.getQueryExecutionCount())
.as("Should detect N+1 problem")
.isGreaterThan(1);
}
private void createTestOrders(int count) {
for (int i = 0; i < count; i++) {
Customer customer = new Customer();
customer.setName("Customer " + i);
customer.setEmail("customer" + i + "@test.com");
entityManager.persist(customer);
Order order = new Order();
order.setOrderNumber("ORD-" + i);
order.setCustomer(customer);
order.setCreatedAt(LocalDateTime.now());
entityManager.persist(order);
}
entityManager.flush();
}
}Die Assertion auf getQueryExecutionCount() stellt sicher, dass die Optimierungen während der Code-Entwicklung erhalten bleiben.
Sources
- Hibernate 7.4 What's New – Paginierung mit Fetch Joins nun sicher auf Datenbankebene
- Spring Boot 4.1 Release Notes – enthält Hibernate 7.4.5.Final
- Hibernate ORM Documentation – offizielle Referenz für Abrufstrategien und EntityGraph
N+1-Präventions-Checkliste für Spring Data JPA
Das N+1-Problem stellt eine große Performance-Herausforderung in Spring Data JPA dar, doch es existieren mehrere effektive Lösungen. Fetch Join und @EntityGraph lösen die meisten Fälle, indem sie Assoziationen in einer einzigen Abfrage laden. Batch Fetching bietet eine Alternative für große Sammlungen, bei denen Fetch Join ein kartesisches Produkt erzeugt. Mit Hibernate 7.4 funktioniert Paginierung mit Fetch Joins endlich auf Datenbankebene.
Empfehlungen für die Produktion:
- SQL-Logs in der Entwicklung aktivieren, um mehrere Abfragen zu erkennen
JOIN FETCHfür häufig zugegriffene@ManyToOne-Beziehungen verwenden@EntityGraphfür wiederverwendbares deklaratives Laden anwenden@BatchSizefür große@OneToMany-Sammlungen konfigurieren- DTO-Projektionen für Read-Only-Operationen bevorzugen
- Tests schreiben, die die Anzahl der ausgeführten Abfragen überprüfen
- SQL-Logs und Statistiken in der Produktion deaktivieren
- Kritische Endpoints regelmäßig mit Hibernate-Metriken profilieren
- Auf Spring Boot 4.1+ aktualisieren, um von den Hibernate 7.4-Paginierungsverbesserungen zu profitieren
Findest du den Bug in Spring Boot?
Ein echter Codeausschnitt, ein versteckter Bug, ein Versuch pro Tag. Zum Ausprobieren ohne Konto.

Geschrieben von
Anthony Fillion-MailletGründer von SharpSkill
Seit über 10 Jahren Fullstack-Entwickler. Er leitet SharpSkill und verantwortet alles, was hier erscheint.
Aktualisiert am 2. September 2026
Tags
Teilen
Verwandte Artikel

Spring GraphQL Interview: Resolver, DataLoader und Lösungen für das N+1-Problem
Vorbereitung auf Spring GraphQL Interviews mit diesem vollständigen Leitfaden. Resolver, DataLoader, Umgang mit dem N+1-Problem, Mutationen und Best Practices für technische Fragen.

Spring Boot 3.4 Virtual Threads: Interview-Fragen und Performance-Benchmarks
Java 21 Virtual Threads mit Spring Boot 3.4 meistern: 15 Interview-Fragen, Performance-Benchmarks und Migrationsmuster für erfolgreiche technische Interviews.

Spring Boot Observability 2026: OpenTelemetry, Distributed Tracing und Interview-Fragen
Spring Boot Observability mit OpenTelemetry und Micrometer Tracing beherrschen. OTLP-Export konfigurieren, die Observation API nutzen und Interview-Fragen meistern.