# 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. - Published: 2026-03-17 - Updated: 2026-05-01 - Author: SharpSkill - Tags: spring data jpa, n+1 problem, fetch join, entitygraph, performance - Reading time: 12 min --- 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. > **Reale Auswirkungen von N+1** > > 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. ```java // Order.java @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 items = new ArrayList<>(); // Getters and setters omitted } ``` ```java // Customer.java @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. ```java // OrderService.java - Problematic code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // 1 query: SELECT * FROM orders List 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. ```sql -- 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 queries ``` ## Das 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. ```yaml # 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: TRACE ``` Die 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. > **In Produktion deaktivieren** > > 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. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Explicit fetch join to load customers @Query("SELECT o FROM Order o JOIN FETCH o.customer") List findAllWithCustomer(); // Multiple fetch join for several associations @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "JOIN FETCH o.items i") List findAllWithCustomerAndItems(); // Fetch join with WHERE condition @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "WHERE o.createdAt > :since") List findRecentOrdersWithCustomer( @Param("since") LocalDateTime since ); } ``` Fetch Join wandelt N+1-Abfragen in eine einzige optimierte Abfrage um. ```sql -- 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.id ``` Der Service verwendet nun die optimierte Methode, ohne den Geschäftscode zu ändern. ```java // OrderService.java - Optimized code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // Single query with join List orders = orderRepository.findAllWithCustomer(); // No additional queries return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), order.getCustomer().getName() // Already loaded )) .toList(); } } ``` ## 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. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Inline EntityGraph with attributePaths @EntityGraph(attributePaths = {"customer"}) List findAll(); // EntityGraph with multiple attributes @EntityGraph(attributePaths = {"customer", "items"}) List findByCreatedAtAfter(LocalDateTime since); // Named EntityGraph referencing entity definition @EntityGraph(value = "Order.withCustomerAndItems") List findByCustomerId(Long customerId); // Combination with custom query @EntityGraph(attributePaths = {"customer"}) @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%") List findByOrderNumberPrefix(@Param("prefix") String prefix); } ``` Benannte EntityGraphs werden direkt auf der Entität definiert, um sie in mehreren Repositories wiederzuverwenden. ```java // Order.java @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. ```java // Order.java @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 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. ```sql -- 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. ```yaml # application.yml spring: jpa: properties: hibernate: # Global default batch size default_batch_fetch_size: 25 ``` > **Batch vs. Fetch Join** > > Batch 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. ```java // Order.java @OneToMany(mappedBy = "order") @Fetch(FetchMode.SUBSELECT) private List items = new ArrayList<>(); ``` ```sql -- 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. ```java // OrderSummaryDto.java public record OrderSummaryDto( Long orderId, String orderNumber, String customerName, String customerEmail ) {} ``` ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // 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 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 findRecentOrderSummaries( @Param("since") LocalDateTime since ); } ``` Dieser Ansatz erzeugt eine optimale SQL-Abfrage ohne den Overhead des Entity-Mappings. ```sql 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. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Dynamic EntityGraph with Specification @EntityGraph(attributePaths = {"customer"}) List findAll(Specification spec); // Pagination with EntityGraph @EntityGraph(attributePaths = {"customer"}) Page findByCustomerNameContaining( String name, Pageable pageable ); // Slice for efficient pagination @EntityGraph(attributePaths = {"customer"}) Slice findByCreatedAtBefore( LocalDateTime date, Pageable pageable ); } ``` Bedingtes Laden ermöglicht es, je nach Kontext unterschiedliche Strategien anzuwenden. ```java // OrderService.java @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final EntityManager entityManager; public List 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 getOrdersForListing() { // Minimal graph for listing return getOrdersWithGraph("Order.withCustomer"); } public List getOrdersForDetail() { // Full graph for detail view return getOrdersWithGraph("Order.full"); } } ``` ## 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. ```java // OrderRepositoryPerformanceTest.java @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 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 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 FETCH` für häufig zugegriffene `@ManyToOne`-Beziehungen verwenden - ✅ `@EntityGraph` für wiederverwendbares deklaratives Laden anwenden - ✅ `@BatchSize` fü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 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/de/blog/spring-boot/spring-data-jpa-n-plus-1-fetch-join-entitygraph