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.

Lösung des N+1-Problems mit Spring Data JPA mittels Fetch Join und EntityGraph

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.

Order.javajava
@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
}
Customer.javajava
@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.

OrderService.java - Problematic codejava
@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.

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.

text
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.

OrderRepository.javajava
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.

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.

OrderService.java - Optimized codejava
@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.

OrderRepository.javajava
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.

Order.javajava
@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.

Order.javajava
@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.

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.

Order.javajava
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> 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.

OrderSummaryDto.javajava
public record OrderSummaryDto(
    Long orderId,
    String orderNumber,
    String customerName,
    String customerEmail
) {}
OrderRepository.javajava
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.

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.

OrderRepository.javajava
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.

OrderService.javajava
@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.

OrderRepositoryPerformanceTest.javajava
@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 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

Tags

#spring data jpa
#n+1 problem
#fetch join
#entitygraph
#performance

Teilen

Verwandte Artikel