Spring Data JPA N+1-Lösungen 2026: Fetch Join und EntityGraph
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.
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.
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.
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.
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 3.x führt Verbesserungen für die Verwaltung von EntityGraph und optimierten Abfragen ein.
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.
@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.
Fazit
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.
Checkliste zur N+1-Vermeidung:
- ✅ 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
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 Logging im Jahr 2026: strukturierte Logs in Produktion mit Logback und JSON
Vollständiger Leitfaden zu strukturiertem Logging in Spring Boot. Logback-JSON-Konfiguration, MDC für Tracing, Best Practices in Produktion und ELK-Stack-Integration.