Spring Data JPA N+1-Oplossingen: Fetch Join, EntityGraph en Hibernate 7.4

Volledige gids voor het detecteren en oplossen van het N+1-probleem in Spring Data JPA. Fetch join, @EntityGraph, batch fetching en strategieën voor queryprestaties.

Het N+1-probleem oplossen met Spring Data JPA via fetch join en EntityGraph

Het N+1-probleem vormt een van de meest voorkomende prestatievalkuilen in JPA. Een onschuldige query om 100 bestellingen op te halen kan 101 SQL-queries veroorzaken: één voor de bestellingen en daarna één voor elke bijbehorende klant. Deze stille vermenigvuldiging van queries verslechtert de prestaties en overbelast de database.

Praktische Impact van N+1

Een endpoint dat 50 artikelen met hun auteurs retourneert kan door N+1 van 10ms naar 500ms springen. Vroege detectie voorkomt kritieke productieproblemen.

Het N+1-Probleem in JPA Begrijpen

Het N+1-probleem treedt op wanneer JPA een collectie van entiteiten laadt en vervolgens een extra query uitvoert voor elke entiteit om de bijbehorende associaties te laden. Dit gedrag komt voort uit de standaard lazy loading van @OneToMany- en @ManyToMany-relaties.

Neem een klassiek model met bestellingen en klanten. Elke bestelling behoort tot een klant, en deze relatie gebruikt standaard lazy loading.

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
}

Wanneer een query bestellingen ophaalt en vervolgens de naam van de klant opvraagt, voert Hibernate voor elke bestelling een extra query uit.

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();
    }
}

Voor 100 bestellingen voert deze code 101 SQL-queries uit. De Hibernate-logs onthullen dit destructieve patroon.

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

Het N+1-Probleem Detecteren met Hibernate-Logs

De eerste stap bestaat uit het inschakelen van SQL-logs om problematische queries te identificeren. De volgende configuratie toont elke door Hibernate uitgevoerde query.

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

Hibernate-statistieken bieden een waardevolle samenvatting aan het einde van elke transactie.

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;
}

Het aantal van 101 JDBC-statements voor een eenvoudige bestellijst signaleert duidelijk een N+1-probleem.

Uitschakelen in Productie

SQL-logs en statistieken hebben invloed op de prestaties. Deze opties moeten in productie uitgeschakeld blijven en gereserveerd zijn voor ontwikkel- en testomgevingen.

Oplossing 1: Fetch Join met JPQL

Fetch join laadt associaties in één enkele SQL-query via een join. Deze expliciete aanpak lost N+1 op door alle benodigde gegevens in één keer op te halen.

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 transformeert N+1-queries in één geoptimaliseerde query.

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

De service gebruikt nu de geoptimaliseerde methode zonder de bedrijfscode aan te passen.

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();
    }
}

Klaar om je Spring Boot gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Oplossing 2: @EntityGraph voor Declaratieve Controle

De annotatie @EntityGraph biedt een declaratief alternatief voor fetch join. Deze definieert welke associaties moeten worden geladen zonder aangepaste JPQL te schrijven. Voor een breder overzicht van JPA-best practices, zie de Spring Data JPA-basismodule.

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);
}

Benoemde EntityGraphs worden direct op de entiteit gedefinieerd voor hergebruik in meerdere repositories.

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
}

De subgraph maakt het laden van geneste associaties mogelijk. Het bovenstaande voorbeeld laadt de bestellingen, hun items en het product van elk item in één query.

Hibernate 7 introduceert een vereenvoudigde tekstgebaseerde @NamedEntityGraph-syntax die de annotatieverbositeit vermindert:

Order.java - Hibernate 7 text-based syntaxjava
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
    name = "Order.full",
    graph = "customer, items(product)"
)
public class Order {
    // Fields unchanged
}

Deze enkele-regel definitie vervangt de uitgebreide geneste @NamedAttributeNode- en @NamedSubgraph-annotaties.

Oplossing 3: Batch Fetching voor Collecties

Batch fetching biedt een alternatief voor fetch join voor @OneToMany-collecties. In plaats van elke collectie afzonderlijk te laden, groepeert Hibernate de queries 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<>();
}

Met @BatchSize(size = 25) laadt Hibernate items voor 25 bestellingen tegelijk in plaats van 1. Voor 100 bestellingen daalt het aantal queries van 101 naar 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)

De globale configuratie van de batchgrootte geldt voor alle collecties in de applicatie.

yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # Global default batch size
        default_batch_fetch_size: 25
Batch vs Fetch Join

Batch fetching past bij gevallen waarin fetch join een te groot Cartesisch product genereert. Voor een bestelling met 10 items en 5 betalingen retourneert fetch join 50 rijen. Batch fetching voert 2 afzonderlijke, efficiëntere queries uit.

Paginering met Fetch Join in Hibernate 7.4

Een langdurige beperking van fetch joins was hun incompatibiliteit met paginering. Het combineren van setMaxResults() met collection fetch joins dwong Hibernate om alle overeenkomende rijen te laden en daarna de limieten in het geheugen toe te passen. De waarschuwing firstResult/maxResults specified with collection fetch; applying in memory wees op dit prestatieprobleem.

Hibernate 7.4 lost dit probleem op. Paginering met collection fetch joins werkt nu op databaseniveau met behulp van geneste queries. Hibernate bepaalt eerst de beperkte set van parent-entiteit-identifiers en haalt vervolgens de bijbehorende collecties alleen op voor die parent-rijen.

OrderRepository.java - Safe pagination with Hibernate 7.4java
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);
}

De gegenereerde SQL gebruikt nu een subquery voor paginering op databaseniveau:

sql
-- 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
)

Deze verbetering vermindert het geheugengebruik voor gepagineerde endpoints die parent-child relaties weergeven drastisch. Spring Boot 4.1 bevat Hibernate 7.4.5, waardoor deze optimalisatie standaard beschikbaar is.

Voor applicaties die nog oudere Hibernate-versies gebruiken, blijft het tweefasige ophaalpatroon de veiligste aanpak: haal eerst parent-ID's op met paginering, laad dan associaties voor die specifieke ID's.

Vergelijking van Laadstrategieën

Elke strategie biedt voordelen afhankelijk van de gebruikscontext. De volgende tabel vat de optimale gebruiksgevallen samen.

StrategieGebruikscontextVoordelenNadelen
Fetch Join@ManyToOne-relatiesEnkele SQL-queryCartesisch product met collecties
@EntityGraphDeclaratief ladenHerbruikbaar, leesbaarMinder flexibel dan JPQL
Batch Fetching@OneToMany-collectiesVermijdt Cartesisch productMeerdere queries
SubselectZelden geraadpleegde collectiesLaadt alleen wanneer nodigGecorreleerde subquery

De subselect-strategie laadt de hele collectie bij de eerste toegang tot een 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 Vermijden met DTO-Projecties

DTO-projecties bieden een radicale maar effectieve aanpak. Door alleen de noodzakelijke kolommen te selecteren, vermijden projecties volledig het laden van entiteiten en hun associaties. Dit patroon is bijzonder relevant voor lees-intensieve endpoints, zoals behandeld in de JPA-queries module.

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
    );
}

Deze aanpak genereert een optimale SQL-query zonder de overhead van entity-mapping.

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 > ?

Geavanceerde Configuratie met Spring Data JPA

Spring Data JPA in Spring Boot 4.x biedt verbeterd EntityGraph-beheer en query-optimalisatie. Begrip van transactiepropagatie wordt essentieel bij het combineren van deze fetch-strategieën met complexe bedrijfslogica.

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
    );
}

Voorwaardelijk laden maakt het mogelijk om verschillende strategieën toe te passen afhankelijk van de context.

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");
    }
}

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Prestatietests om N+1 te Detecteren

Geautomatiseerde tests verifiëren de afwezigheid van N+1-problemen door het aantal uitgevoerde SQL-queries te tellen. Uitgebreide teststrategieën worden behandeld in de Testcontainers-gids voor Spring Boot.

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();
    }
}

De assertie op getQueryExecutionCount() zorgt ervoor dat optimalisaties behouden blijven tijdens code-evolutie.

Sources

N+1-Preventiechecklist voor Spring Data JPA

Het N+1-probleem vormt een grote prestatie-uitdaging in Spring Data JPA, maar er bestaan verschillende effectieve oplossingen. Fetch join en @EntityGraph lossen de meeste gevallen op door associaties in één query te laden. Batch fetching biedt een alternatief voor grote collecties waar fetch join een Cartesisch product genereert. Met Hibernate 7.4 werkt paginering met fetch joins eindelijk op databaseniveau.

Aanbevelingen voor productie:

  • SQL-logs inschakelen tijdens ontwikkeling om meerdere queries te detecteren
  • JOIN FETCH gebruiken voor vaak geraadpleegde @ManyToOne-relaties
  • @EntityGraph toepassen voor herbruikbaar declaratief laden
  • @BatchSize configureren voor grote @OneToMany-collecties
  • DTO-projecties verkiezen voor alleen-lezen-bewerkingen
  • Tests schrijven die het aantal uitgevoerde queries verifiëren
  • SQL-logs en statistieken uitschakelen in productie
  • Kritieke endpoints regelmatig profileren met Hibernate-metrics
  • Upgraden naar Spring Boot 4.1+ om te profiteren van de Hibernate 7.4-pagineringsverbeteringen
Dagelijkse challenge

Zie jij de bug in Spring Boot?

Een echt codefragment, een verborgen bug, één poging per dag. Zonder account uit te proberen.

Anthony Fillion-Maillet

Geschreven door

Anthony Fillion-Maillet

Oprichter van SharpSkill

Al meer dan 10 jaar fullstack-ontwikkelaar. Hij leidt SharpSkill en staat in voor alles wat hier verschijnt.

Bijgewerkt op 2 september 2026

Tags

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

Delen

Gerelateerde artikelen